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

nens / ThreeDiToolbox / #2613

21 Oct 2025 02:14PM UTC coverage: 36.554% (+1.8%) from 34.798%
#2613

push

coveralls-python

web-flow
Merge fa2f352b2 into 7ff7d80d5

539 of 739 new or added lines in 7 files covered. (72.94%)

2 existing lines in 1 file now uncovered.

5282 of 14450 relevant lines covered (36.55%)

0.37 hits per line

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

84.85
/processing/threedidepth_algorithms.py
1
# -*- coding: utf-8 -*-
2

3
"""
1✔
4
***************************************************************************
5
*                                                                         *
6
*   This program is free software; you can redistribute it and/or modify  *
7
*   it under the terms of the GNU General Public License as published by  *
8
*   the Free Software Foundation; either version 2 of the License, or     *
9
*   (at your option) any later version.                                   *
10
*                                                                         *
11
***************************************************************************
12
"""
13
from collections import namedtuple
1✔
14
from datetime import datetime, timedelta
1✔
15
from typing import List, Dict
1✔
16

17
import numpy as np
1✔
18
from osgeo import gdal
1✔
19
from qgis.core import QgsFeedback
1✔
20
from qgis.core import QgsProcessingAlgorithm
1✔
21
from qgis.core import QgsProcessingException
1✔
22
from qgis.core import QgsProcessingParameterColor
1✔
23
from qgis.core import QgsProcessingParameterEnum
1✔
24
from qgis.core import QgsProcessingParameterFile
1✔
25
from qgis.core import QgsProcessingParameterNumber
1✔
26
from qgis.core import QgsProcessingParameterRasterDestination
1✔
27
from qgis.core import QgsProcessingParameterRasterLayer
1✔
28
from qgis.core import QgsProcessingParameterString
1✔
29
from qgis.core import QgsProcessingUtils
1✔
30
from qgis.core import QgsRasterLayer
1✔
31
from qgis.PyQt.QtGui import QColor
1✔
32
from threedidepth.calculate import calculate_waterdepth, calculate_water_quality
1✔
33
from threedidepth.calculate import MODE_CONSTANT
1✔
34
from threedidepth.calculate import MODE_CONSTANT_VAR
1✔
35
from threedidepth.calculate import MODE_LIZARD
1✔
36
from threedidepth.calculate import MODE_LIZARD_VAR
1✔
37

38
from threedigrid.admin.gridresultadmin import GridH5WaterQualityResultAdmin
1✔
39
from threedigrid.admin.gridresultadmin import GridH5ResultAdmin
1✔
40
from threedigrid.admin.gridresultadmin import GridH5AggregateResultAdmin
1✔
41
from threedigrid.admin.gridresultadmin import CustomizedResultAdmin
1✔
42
from threedigrid.admin.gridresultadmin import CustomizedWaterQualityResultAdmin
1✔
43

44
import logging
1✔
45
from pathlib import Path
1✔
46

47
from threedi_results_analysis.processing.widgets.widgets import ThreediResultTimeSliderWidgetWrapper
1✔
48
from threedi_results_analysis.processing.widgets.widgets import SubstanceWidgetWrapper
1✔
49

50
from threedi_results_analysis.utils.color import color_ramp_from_data, COLOR_RAMP_OCEAN_HALINE
1✔
51
from threedi_results_analysis.utils.geo_utils import mask, multiband_raster_min_max
1✔
52
from threedi_results_analysis.utils.netcdf import substances_from_netcdf
1✔
53
from threedi_results_analysis.utils.styling import (
1✔
54
    apply_transparency_gradient,
55
    apply_gradient_ramp,
56
)
57

58
logger = logging.getLogger(__name__)
1✔
59
plugin_path = Path(__file__).resolve().parent.parent
1✔
60
Mode = namedtuple("Mode", ["name", "description"])
1✔
61

62

63
CALCULATION_STEP_END_INPUT = "CALCULATION_STEP_END_INPUT"
1✔
64
CALCULATION_STEP_INPUT = "CALCULATION_STEP_INPUT"
1✔
65
CALCULATION_STEP_START_INPUT = "CALCULATION_STEP_START_INPUT"
1✔
66
COLOR_INPUT = "COLOR_INPUT"
1✔
67
DEM_INPUT = "DEM_INPUT"
1✔
68
GRIDADMIN_INPUT = "GRIDADMIN_INPUT"
1✔
69
MODE_INPUT = "MODE_INPUT"
1✔
70
NETCDF_INPUT = "NETCDF_INPUT"
1✔
71
OUTPUT_DIRECTORY = "OUTPUT_DIRECTORY"
1✔
72
OUTPUT_FILENAME = "OUTPUT_FILENAME"
1✔
73
SUBSTANCE_INPUT = "SUBSTANCE_INPUT"
1✔
74
WATER_DEPTH_INPUT = "WATERDEPTH_INPUT"
1✔
75
WATER_DEPTH_LEVEL_NAME = "WATER_DEPTH_LEVEL_NAME"
1✔
76
WATER_DEPTH_OUTPUT = "WATER_DEPTH_OUTPUT"
1✔
77

78
WATER_QUALITY = "WATER_QUALITY"
1✔
79
WATER_QUANTITY = "WATER_QUANTITY"
1✔
80

81
SINGLE = "SINGLE"
1✔
82
MULTIPLE = "MULTIPLE"
1✔
83
MAXIMUM = "MAXIMUM"
1✔
84

85
STYLE_DIR = Path(__file__).parent / "styles"
1✔
86

87
# TODO: Replace two sliders with qgsrangeslider
88

89

90
class CancelError(Exception):
1✔
91
    """Error which gets raised when a user presses the 'cancel' button"""
92

93

94
class Progress:
1✔
95
    def __init__(self, feedback: QgsFeedback):
1✔
96
        self.feedback = feedback
1✔
97

98
    def __call__(self, progress: float):
1✔
99
        self.feedback.setProgress(progress * 100)
1✔
100
        if self.feedback.isCanceled():
1✔
NEW
101
            raise CancelError()
×
102

103

104
class BaseThreediDepthAlgorithm(QgsProcessingAlgorithm):
1✔
105
    """
106
    Base processing algorithm wrapping threedidepth functionalities
107
    """
108
    @property
1✔
109
    def data_type(self) -> str:
1✔
110
        """
111
        WATER_QUANTITY or WATER_QUALITY
112
        """
NEW
113
        return NotImplementedError("Subclasses must implement this method")
×
114

115
    @property
1✔
116
    def time_step_type(self) -> str:
1✔
117
        """
118
        SINGLE, MULTIPLE, or MAXIMUM
119
        """
NEW
120
        return NotImplementedError("Subclasses must implement this method")
×
121

122
    @property
1✔
123
    # Not implemented as proper abstractmethod because QgsProcessingAlgorithm already has a metaclass
124
    # and setting ABCMeta as metaclass creates complicated problems
125
    def output_modes(self) -> List[Mode]:
1✔
126
        """
127
        List of modes available for this processing algorithm
128
        """
129
        if self.data_type == WATER_QUANTITY:
1✔
130
            return [
1✔
131
                Mode(MODE_LIZARD, "Interpolated water depth"),
132
                Mode(MODE_LIZARD_VAR, "Interpolated water level"),
133
                Mode(MODE_CONSTANT, "Non-interpolated water depth"),
134
                Mode(MODE_CONSTANT_VAR, "Non-interpolated water level"),
135
            ]
136
        elif self.data_type == WATER_QUALITY:
1✔
137
            return [
1✔
138
                Mode(MODE_LIZARD_VAR, "Interpolated concentrations"),
139
                Mode(MODE_CONSTANT_VAR, "Non-interpolated concentrations"),
140
            ]
141

142
    @property
1✔
143
    def default_mode(self) -> Mode:
1✔
144
        """
145
        Mode to be set as the default in the user interface
146
        """
147
        if self.data_type == WATER_QUANTITY:
1✔
148
            return Mode(MODE_LIZARD, "Interpolated water depth")
1✔
149
        elif self.data_type == WATER_QUALITY:
1✔
150
            return Mode(MODE_LIZARD_VAR, "Interpolated concentrations")
1✔
151

152
    def calculation_steps(self, parameters, feedback):
1✔
153
        if self.time_step_type == SINGLE:
1✔
154
            return [parameters[CALCULATION_STEP_INPUT]]
1✔
155
        elif self.time_step_type == MULTIPLE:
1✔
156
            calculation_step_start = parameters[CALCULATION_STEP_START_INPUT]
1✔
157
            calculation_step_end = parameters[CALCULATION_STEP_END_INPUT]
1✔
158
            if calculation_step_end <= calculation_step_start:
1✔
NEW
159
                feedback.reportError(
×
160
                    "The last timestep should be larger than the first timestep.",
161
                    fatalError=True,
162
                )
NEW
163
                return {}
×
164
            calculation_steps = list(range(calculation_step_start, calculation_step_end))
1✔
165
            return calculation_steps
1✔
166
        elif self.time_step_type == MAXIMUM:
1✔
167
            return [None]
1✔
168

169
    @property
1✔
170
    def parameters(self) -> List:
1✔
171
        result = []
1✔
172

173
        # Gridadmin input
174
        gridadmin_param = QgsProcessingParameterFile(
1✔
175
            name=GRIDADMIN_INPUT,
176
            description="Gridadmin file",
177
            extension="h5"
178
        )
179
        gridadmin_param.setMetadata(
1✔
180
            {"shortHelpString": "HDF5 file (*.h5) containing the computational grid of a 3Di model"}
181
        )
182
        result.append(gridadmin_param)
1✔
183

184
        # NetCDF input
185
        netcdf_input_param = QgsProcessingParameterFile(
1✔
186
            name=NETCDF_INPUT,
187
            description="3Di simulation output (.nc)",
188
            extension="nc"
189
        )
190
        if self.data_type == WATER_QUANTITY:
1✔
191
            short_help_string = (
1✔
192
                "NetCDF (*.nc) containing the results or aggregated results of a 3Di simulation. "
193
                "When using aggregated results (aggregate_results_3di.nc), make sure to use 'maximum water level' "
194
                "as one of the aggregation variables in the simulation."
195
            )
196
        elif self.data_type == WATER_QUALITY:
1✔
197
            short_help_string = (
1✔
198
                "NetCDF (*.nc) containing the water quality results of a 3Di simulation. "
199
            )
200
        netcdf_input_param.setMetadata(
1✔
201
            {"shortHelpString": short_help_string}
202
        )
203
        result.append(netcdf_input_param)
1✔
204

205
        # Output type
206
        output_type_param = QgsProcessingParameterEnum(
1✔
207
            name=MODE_INPUT,
208
            description="Output type",
209
            options=[m.description for m in self.output_modes],
210
            defaultValue=self.default_mode,
211
        )
212
        if self.data_type == WATER_QUANTITY:
1✔
213
            short_help_string = (
1✔
214
                "Choose between water depth (m above the surface) and water level (m MSL), "
215
                "with or without spatial interpolation."
216
            )
217
        elif self.data_type == WATER_QUALITY:
1✔
218
            short_help_string = (
1✔
219
                "Use this setting to switch spatial interpolation on or off."
220
            )
221
        output_type_param.setMetadata(
1✔
222
            {"shortHelpString": short_help_string}
223
        )
224
        result.append(output_type_param)
1✔
225

226
        # Output filename
227
        output_filename_param = QgsProcessingParameterRasterDestination(
1✔
228
            name=OUTPUT_FILENAME,
229
            description="Output raster"
230
        )
231
        output_filename_param.setMetadata(
1✔
232
            {"shortHelpString": "File name for the output file."}
233
        )
234
        result.append(output_filename_param)
1✔
235

236
        if self.time_step_type == SINGLE:
1✔
237
            calculation_step_input_param = QgsProcessingParameterNumber(
1✔
238
                name=CALCULATION_STEP_INPUT,
239
                description="Time step",
240
                defaultValue=-1,
241
            )
242
            calculation_step_input_param.setMetadata(
1✔
243
                {
244
                    "widget_wrapper": {"class": ThreediResultTimeSliderWidgetWrapper},
245
                    "parentParameterName": NETCDF_INPUT,
246
                    "shortHelpString": "The time step in the simulation for which you want to generate a raster."
247
                }
248
            )
249
            result.insert(
1✔
250
                -1,
251
                calculation_step_input_param
252
            )
253
        elif self.time_step_type == MULTIPLE:
1✔
254
            calculation_step_start_input_param = QgsProcessingParameterNumber(
1✔
255
                    name=CALCULATION_STEP_START_INPUT,
256
                    description="First time step",
257
                    defaultValue=0,
258
                )
259
            calculation_step_start_input_param.setMetadata(
1✔
260
                {
261
                    "widget_wrapper": {"class": ThreediResultTimeSliderWidgetWrapper},
262
                    "parentParameterName": NETCDF_INPUT,
263
                    "shortHelpString": (
264
                        "The start of the time step range for which you want to generate rasters."
265
                    )
266
                }
267
            )
268
            result.insert(
1✔
269
                -1,
270
                calculation_step_start_input_param
271
            )
272
            calculation_step_end_input_param = QgsProcessingParameterNumber(
1✔
273
                    name=CALCULATION_STEP_END_INPUT,
274
                    description="Last time step",
275
                    defaultValue=-1,
276
                )
277
            calculation_step_end_input_param.setMetadata(
1✔
278
                {
279
                    "widget_wrapper": {"class": ThreediResultTimeSliderWidgetWrapper},
280
                    "parentParameterName": NETCDF_INPUT,
281
                    "shortHelpString": (
282
                        "The end of the time step range for which you want to generate rasters."
283
                    )
284
                }
285
            )
286
            result.insert(
1✔
287
                -1,
288
                calculation_step_end_input_param
289
            )
290
        if self.data_type == WATER_QUANTITY:
1✔
291
            dem_param = QgsProcessingParameterRasterLayer(DEM_INPUT, "DEM")
1✔
292
            dem_param.setMetadata(
1✔
293
                {"shortHelpString": (
294
                    "Digital elevation model (.tif) that was used for the simulation. "
295
                    "Using a different DEM in this tool than in the simulation may give unexpected results."
296
                )}
297
            )
298
            result.insert(2, dem_param)
1✔
299
        elif self.data_type == WATER_QUALITY:
1✔
300
            water_depth_input_param = QgsProcessingParameterRasterLayer(
1✔
301
                    WATER_DEPTH_INPUT,
302
                    "Water depth (mask layer)",
303
                    optional=True
304
                )
305
            if self.time_step_type == SINGLE:
1✔
306
                short_help_string = (
1✔
307
                    "Water depth raster to be used as a mask layer for the output. "
308
                    "All 'no data' pixels in the water depth raster will be set to 'no data' in the output raster."
309
                )
310
            elif self.time_step_type == MULTIPLE:
1✔
311
                short_help_string = (
1✔
312
                    "Multiband water depth raster to be used as a mask layer for the output. "
313
                    "All 'no data' pixels in the water depth raster will be set to 'no data' in the output raster."
314
                    "The water depth mask layer must have a band for each time step for which a concentration raster "
315
                    "is generated. Generate this multiband water depth raster with the processing algorithm 'Calculate "
316
                    "water depth/level raster (multiple time steps)'"
317
                )
318
            elif self.time_step_type == MAXIMUM:
1✔
319
                short_help_string = (
1✔
320
                    "Water depth raster to be used as a mask layer for the output. "
321
                    "All 'no data' pixels in the water depth raster will be set to 'no data' in the output raster."
322
                    "</p><p>⚠ Note that the maximum water depth and the maximum concentration are not likely to occur "
323
                    "at the same time. Masking a maximum concentration raster may therefore not always be meaningful."
324
                )
325
            water_depth_input_param.setMetadata({"shortHelpString": short_help_string})
1✔
326
            result.insert(
1✔
327
                2,
328
                water_depth_input_param
329
            )
330
            substance_param = QgsProcessingParameterString(
1✔
331
                SUBSTANCE_INPUT,
332
                "Substance",
333
            )
334
            substance_param.setMetadata(
1✔
335
                {
336
                    "widget_wrapper": {"class": SubstanceWidgetWrapper},
337
                    "parentParameterName": NETCDF_INPUT,
338
                    "shortHelpString": "Name of the substance for which to generate a raster."
339
                }
340
            )
341
            result.insert(3, substance_param)
1✔
342
            color_param = QgsProcessingParameterColor(
1✔
343
                    COLOR_INPUT,
344
                    "Color",
345
                    defaultValue=QColor("brown")
346
                )
347
            color_param.setMetadata(
1✔
348
                {
349
                    "shortHelpString": (
350
                        "Color to be used when styling the output. The transparency of the output layer will be scaled "
351
                        "with the range of concentrations found in the water quality results NetCDF."
352
                    )
353
                }
354
            )
355
            result.insert(
1✔
356
                4,
357
                color_param
358
            )
359
        return result
1✔
360

361
    def output_file(self, parameters, context) -> Path:
1✔
362
        return Path(self.parameterAsFileOutput(parameters, OUTPUT_FILENAME, context))
1✔
363

364
    @property
1✔
365
    def threedidepth_method(self):
1✔
366
        if self.data_type == WATER_QUANTITY:
1✔
367
            return calculate_waterdepth
1✔
368
        elif self.data_type == WATER_QUALITY:
1✔
369
            return calculate_water_quality
1✔
370

371
    @property
1✔
372
    def netcdf_path(self) -> Path:
1✔
373
        return Path(
1✔
374
            self.threedidepth_args.get("results_3di_path") or
375
            self.threedidepth_args.get("water_quality_results_3di_path")
376
        )
377

378
    def get_substance_id(self, parameters, context):
1✔
379
        netcdf_input = self.parameterAsFile(parameters, NETCDF_INPUT, context)
1✔
380
        substance_id = self.parameterAsString(parameters, SUBSTANCE_INPUT, context)
1✔
381
        substances = substances_from_netcdf(netcdf_input)
1✔
382
        if substance_id not in substances:
1✔
383
            # Perhaps the substance name was given as input instead of the substance id
384
            for substance_id, substance_name in substances.items():
1✔
385
                if substance_name == substance_id:
1✔
NEW
386
                    substance_id = substance_id
×
NEW
387
                    break
×
388
        if substance_id not in substances:
1✔
NEW
389
            raise QgsProcessingException(f"Substance {substance_id} not found in file {netcdf_input}")
×
390
        return substance_id
1✔
391

392
    def get_threedidepth_args(self, parameters, context, feedback) -> Dict:
1✔
393
        args = {
1✔
394
                "gridadmin_path": self.parameterAsFile(parameters, GRIDADMIN_INPUT, context),
395
                "calculation_steps": self.calculation_steps(parameters, feedback),
396
                "mode": self.output_mode.name,
397
                "progress_func": Progress(feedback),
398
            }
399
        if self.data_type == WATER_QUANTITY:
1✔
400
            args.update(
1✔
401
                {
402
                    "results_3di_path": self.parameterAsFile(parameters, NETCDF_INPUT, context),
403
                    "dem_path": self.parameterAsRasterLayer(parameters, DEM_INPUT, context).source(),
404
                    "waterdepth_path": str(self.output_file(parameters, context)),
405
                }
406
            )
407
        elif self.data_type == WATER_QUALITY:
1✔
408
            netcdf_input = self.parameterAsFile(parameters, NETCDF_INPUT, context)
1✔
409
            gwq = GridH5WaterQualityResultAdmin(parameters[GRIDADMIN_INPUT], netcdf_input)
1✔
410
            variable = self.get_substance_id(parameters, context)
1✔
411
            mask_layer = self.parameterAsRasterLayer(parameters, WATER_DEPTH_INPUT, context)
1✔
412
            if mask_layer:
1✔
413
                extent = mask_layer.extent()  # QgsRectangle
1✔
414
                output_extent = (extent.xMinimum(), extent.yMinimum(), extent.xMaximum(), extent.yMaximum())
1✔
415
            else:
416
                output_extent = gwq.get_model_extent()
1✔
417
            args.update(
1✔
418
                {
419
                    "water_quality_results_3di_path": self.parameterAsFile(parameters, NETCDF_INPUT, context),
420
                    "variable": variable,
421
                    "output_extent": output_extent,  # TODO make this separate input?
422
                    "output_path": str(self.output_file(parameters, context)),
423
                }
424
            )
425
        if self.time_step_type == MAXIMUM:
1✔
426
            if self.data_type == WATER_QUANTITY:
1✔
427
                args.update({"calculate_maximum_waterlevel": True})
1✔
428
            elif self.data_type == WATER_QUALITY:
1✔
429
                args.update({"calculate_maximum_concentration": True})
1✔
430
        return args
1✔
431

432
    @property
1✔
433
    def results_reader(self):
1✔
434
        """Get the correct threedigrid ...ResultAdmin"""
435
        reader_classes = {
1✔
436
            "water_quality_results_3di.nc": GridH5WaterQualityResultAdmin,
437
            "results_3di.nc": GridH5ResultAdmin,
438
            "aggregate_results_3di.nc": GridH5AggregateResultAdmin,
439
            "customized_results_3di.nc": CustomizedResultAdmin,
440
            "customized_water_quality_results_3di.nc": CustomizedWaterQualityResultAdmin,
441
        }
442
        reader_class = reader_classes[self.netcdf_path.name]
1✔
443
        reader = reader_class(str(self.threedidepth_args["gridadmin_path"]), str(self.netcdf_path))
1✔
444
        return reader
1✔
445

446
    def get_formatted_datetime_timestamps(self, formatting: str = '%Y-%m-%d %H:%M:%S') -> List[str]:
1✔
NEW
447
        indices = self.threedidepth_args["calculation_steps"]
×
NEW
448
        dt_timestamps = np.array(self.results_reader.nodes.dt_timestamps)[indices]
×
NEW
449
        result = []
×
NEW
450
        for dt_timestamp in dt_timestamps:
×
NEW
451
            dt = datetime.fromisoformat(dt_timestamp)
×
NEW
452
            formatted = dt.strftime(formatting)
×
NEW
453
            result.append(formatted)
×
NEW
454
        return result
×
455

456
    def set_timestamps_as_band_descriptions(
1✔
457
            self,
458
            raster: str | Path,
459
            formatting: str = '%Y-%m-%d %H:%M:%S'
460
    ):
461
        ds = gdal.Open(str(raster), gdal.GA_Update)
1✔
462
        if self.time_step_type == MAXIMUM:
1✔
463
            ds.GetRasterBand(1).SetDescription("Maximum")
1✔
464
        else:
465
            indices = self.threedidepth_args["calculation_steps"]
1✔
466
            reader = self.results_reader
1✔
467
            if isinstance(reader, (GridH5WaterQualityResultAdmin, CustomizedWaterQualityResultAdmin)):
1✔
468
                # all substances have the same timestamps and there is always a substance1
469
                dt_timestamps = np.array(reader.substance1.dt_timestamps)[indices]
1✔
470
            else:
471
                dt_timestamps = np.array(reader.nodes.dt_timestamps)[indices]
1✔
472
            for i in range(ds.RasterCount):
1✔
473
                dt = datetime.fromisoformat(str(dt_timestamps[i]))
1✔
474
                formatted = dt.strftime(formatting)
1✔
475
                ds.GetRasterBand(i + 1).SetDescription(formatted)
1✔
476

477
    @property
1✔
478
    def timestamps_seconds(self) -> List[int | None]:
1✔
479
        """
480
        Get the timestamps in seconds since start of simulation for the requested output time steps
481
        """
482
        if self.time_step_type == MAXIMUM:
1✔
483
            return [None]
1✔
484
        indices = self.threedidepth_args["calculation_steps"]
1✔
485
        reader = self.results_reader
1✔
486
        if self.data_type == WATER_QUALITY:
1✔
487
            # all substances have the same timestamps and there is always a substance1
488
            return reader.substance1.timestamps[indices]
1✔
489
        elif self.data_type == WATER_QUANTITY:
1✔
490
            return reader.nodes.timestamps[indices]
1✔
491

492
    def output_layer_name_from_parameters(self, parameters, context):
1✔
493
        mode_index = self.parameterAsEnum(parameters, MODE_INPUT, context)
1✔
494
        output_layer_name = self.output_modes[mode_index].description
1✔
495
        if self.data_type == WATER_QUALITY:
1✔
496
            gwq = GridH5WaterQualityResultAdmin(parameters[GRIDADMIN_INPUT], parameters[NETCDF_INPUT])
1✔
497
            substance_id = self.get_substance_id(parameters, context)
1✔
498
            substance_name = getattr(gwq, substance_id).name
1✔
499
            output_layer_name = f"{substance_name}: {output_layer_name}"
1✔
500
        if self.time_step_type == MAXIMUM:
1✔
501
            output_layer_name += " (Maximum)"
1✔
502
        return output_layer_name
1✔
503

504
    def apply_style(self, layer):
1✔
505
        if self.data_type == WATER_QUALITY:
1✔
506
            min_value, max_value = multiband_raster_min_max(layer)
1✔
507
            apply_transparency_gradient(
1✔
508
                layer=layer,
509
                color=self.color,
510
                min_value=min_value,
511
                max_value=max_value,
512
            )
513

514
        elif self.data_type == WATER_QUANTITY:
1✔
515
            if self.output_mode.name in [
1✔
516
                MODE_CONSTANT_VAR, MODE_LIZARD_VAR
517
            ]:
518
                # Water level styling
NEW
519
                min_value, max_value = multiband_raster_min_max(layer)
×
NEW
520
                color_ramp = color_ramp_from_data(COLOR_RAMP_OCEAN_HALINE)
×
NEW
521
                apply_gradient_ramp(
×
522
                    layer=layer,
523
                    color_ramp=color_ramp,
524
                    min_value=min_value,
525
                    max_value=max_value,
526
                    band=1
527
                )
528
            elif self.output_mode.name in [
1✔
529
                MODE_CONSTANT, MODE_LIZARD
530
            ]:
531
                # Water depth styling
532
                layer.loadNamedStyle(str(STYLE_DIR / "water_depth.qml"))
1✔
533

534
    def group(self):
1✔
535
        """Returns the name of the group this algorithm belongs to"""
NEW
536
        return "Post-process results"
×
537

538
    def groupId(self):
1✔
539
        """Returns the unique ID of the group this algorithm belongs to"""
NEW
540
        return "postprocessing"
×
541

542
    def initAlgorithm(self, config=None):
1✔
543
        """Add parameters that apply to all subclasses"""
544
        self.output_layer_name = "Output raster"
1✔
545
        for param in self.parameters:
1✔
546
            self.addParameter(param)
1✔
547

548
    def processAlgorithm(self, parameters, context, feedback):
1✔
549
        """
550
        Create the water depth raster with the provided user inputs
551
        """
552
        # Water quality part (1/2)
553
        if self.data_type == WATER_QUALITY:
1✔
554
            mask_layer = self.parameterAsRasterLayer(parameters, WATER_DEPTH_INPUT, context)
1✔
555
            masked_result_file_name = self.output_file(parameters, context)
1✔
556
            if mask_layer:
1✔
557
                output_file_generic_part = QgsProcessingUtils.generateTempFilename("non_masked.tif")
1✔
558
            else:
559
                output_file_generic_part = self.output_file(parameters, context)
1✔
560
        elif self.data_type == WATER_QUANTITY:
1✔
561
            output_file_generic_part = self.output_file(parameters, context)
1✔
562

563
        # Generic part
564
        mode_index = self.parameterAsEnum(parameters, MODE_INPUT, context)
1✔
565
        self.output_mode = self.output_modes[mode_index]
1✔
566
        self.threedidepth_args = self.get_threedidepth_args(parameters=parameters, context=context, feedback=feedback)
1✔
567
        if Path(output_file_generic_part).is_file():
1✔
NEW
568
            Path(output_file_generic_part).unlink()
×
569
        if self.data_type == WATER_QUALITY:
1✔
570
            self.threedidepth_args["output_path"] = str(output_file_generic_part)
1✔
571
        elif self.data_type == WATER_QUANTITY:
1✔
572
            self.threedidepth_args["waterdepth_path"] = str(output_file_generic_part)
1✔
573
        try:
1✔
574
            self.threedidepth_method(**self.threedidepth_args)
1✔
UNCOV
575
        except CancelError:
×
576
            # When the process is cancelled, we just show the intermediate product
577
            pass
×
578
        except KeyError as e:
×
NEW
579
            if Path(self.netcdf_path).name == "aggregate_results_3di.nc" and e.args[0] == "s1_max":
×
580
                raise QgsProcessingException(
×
581
                    "Input aggregation NetCDF does not contain maximum water level aggregation (s1_max)."
582
                )
583

584
        # Water quality part (2/2)
585
        if self.data_type == WATER_QUALITY:
1✔
586
            self.color = self.parameterAsColor(parameters, COLOR_INPUT, context)
1✔
587
            if mask_layer:
1✔
588
                if Path(masked_result_file_name).is_file():
1✔
NEW
589
                    Path(masked_result_file_name).unlink()
×
590
                mask(source=str(output_file_generic_part), mask=mask_layer.source(), output=masked_result_file_name)
1✔
591
                final_output = masked_result_file_name
1✔
592
            else:
593
                final_output = output_file_generic_part
1✔
594
        elif self.data_type == WATER_QUANTITY:
1✔
595
            final_output = output_file_generic_part
1✔
596

597
        try:
1✔
598
            self.set_timestamps_as_band_descriptions(
1✔
599
                raster=str(final_output)
600
            )
601
        except ValueError:
1✔
602
            # occurs when water quality results have missing time units, known issue (2025-10-02)
603
            pass
1✔
604

605
        # Save data to be used in postProcessAlgorithm
606
        self.output_layer_name = self.output_layer_name_from_parameters(parameters, context)
1✔
607
        self._results = {OUTPUT_FILENAME: str(final_output)}
1✔
608
        return self._results
1✔
609

610
    def postProcessAlgorithm(self, context, feedback):
1✔
611
        output_file = self._results[OUTPUT_FILENAME]
1✔
612
        output_layers = []
1✔
613
        timestamps_seconds = self.timestamps_seconds
1✔
614
        threedidepth_calculation_steps = self.threedidepth_args.get("calculation_steps") or [None]
1✔
615
        for i, time_step in enumerate(threedidepth_calculation_steps):
1✔
616
            if self.time_step_type in [SINGLE, MULTIPLE]:
1✔
617
                layer_name_suffix = f" ({str(timedelta(seconds=int(timestamps_seconds[i])))})"
1✔
618
            else:
619
                layer_name_suffix = ""
1✔
620
            layer_name = f"{self.output_layer_name}{layer_name_suffix}"
1✔
621
            output_layers.append(QgsRasterLayer(output_file, layer_name, "gdal"))
1✔
622
            self.apply_style(output_layers[i])
1✔
623
            if hasattr(output_layers[i].renderer(), "setBand"):
1✔
624
                output_layers[i].renderer().setBand(i+1)
1✔
625
            output_layers[i].setName(layer_name)
1✔
626
            context.project().addMapLayer(output_layers[i])
1✔
UNCOV
627
        return {}
×
628

629
    def short_help_string_leader(self) -> str:
1✔
630
        """Title and leader of the documentation shortHelpString"""
631
        # Data type
NEW
632
        if self.data_type == WATER_QUANTITY:
×
NEW
633
            title = "Calculate water depth or level raster"
×
NEW
634
            leader_paragraphs = [
×
635
                "The 3Di simulation result contains a single water level for each cell, for each time step. However, "
636
                "the water <i>depth</i> is different for each pixel within the cell. "
637
                "To calculate water depths from water levels, the DEM needs to be subtracted from the water level. "
638
                "This results in a raster with a water depth value for each pixel.",
639

640
                "For some applications, it is useful to have water <i>levels</i> as a raster file. "
641
                "For example, to use them as <i>initial water levels</i> in the next simulation.",
642

643
                "It is often preferable to spatially interpolate the water levels. This is recommended to use if the "
644
                "water level gradients are large, e.g. in sloping areas.",
645
            ]
NEW
646
        elif self.data_type == WATER_QUALITY:
×
NEW
647
            title = "Calculate concentration/fraction raster"
×
NEW
648
            leader_paragraphs = [
×
649
                "The 3Di water quality simulation result contains a single concentration (or fraction) for each cell, "
650
                "for each time step, for each substance. This tool allows you to make a raster of this data.",
651

652
                "To get a smoother result, use the option to spatially interpolate the concentrations.",
653

654
                "To show concentrations or fractions only where there is water on the surface instead of in the whole "
655
                "cell, use the water depth layer as mask layer.",
656
            ]
657

658
        # Time step type
NEW
659
        if self.time_step_type == SINGLE:
×
NEW
660
            title += " for specified time step"
×
NEW
661
        elif self.time_step_type == MULTIPLE:
×
NEW
662
            title += " for specified range of time steps"
×
NEW
663
        elif self.time_step_type == MAXIMUM:
×
NEW
664
            title += " for the maximum value that occurs over time per cell"
×
NEW
665
            leader_paragraphs.append(
×
666
                "⚠ Note that the maximum may occur at different time steps in different cells. "
667
                "Therefore, the map that that this tool calculates may never have occurred as "
668
                "such during the simulation."
669
            )
670
        # Formatting
NEW
671
        title = f"<h3>{title}</h3>"
×
NEW
672
        leader_paragraphs = [f"<p>{paragraph}</p>" for paragraph in leader_paragraphs]
×
NEW
673
        leader = "\n".join(leader_paragraphs)
×
NEW
674
        return title + leader
×
675

676
    def shortHelpString(self):
1✔
677
        """Documentation as shown in the help panel"""
NEW
678
        result = self.short_help_string_leader()
×
NEW
679
        for parameter in self.parameters:
×
NEW
680
            heading = f"<h4>{parameter.description()}</h4>"
×
NEW
681
            paragraph = f"<p>{parameter.metadata().get('shortHelpString') or ''}</p>"
×
NEW
682
            result += heading
×
NEW
683
            result += paragraph
×
NEW
684
        return result
×
685

686

687
class WaterDepthOrLevelSingleTimeStepAlgorithm(BaseThreediDepthAlgorithm):
1✔
688
    """
689
    Calculates water depth or water level from 3Di result NetCDF for a single time step
690
    """
691

692
    @property
1✔
693
    def data_type(self) -> str:
1✔
694
        """
695
        WATER_QUANITTY or WATER_QUALITY
696
        """
697
        return WATER_QUANTITY
1✔
698

699
    @property
1✔
700
    def time_step_type(self) -> str:
1✔
701
        return SINGLE
1✔
702

703
    def createInstance(self):
1✔
704
        return WaterDepthOrLevelSingleTimeStepAlgorithm()
1✔
705

706
    def name(self):
1✔
707
        """Returns the algorithm name, used for identifying the algorithm"""
NEW
708
        return "waterdepthorlevelsingletimestep"
×
709

710
    def displayName(self):
1✔
711
        """
712
        Returns the translated algorithm name, which should be used for any
713
        user-visible display of the algorithm name.
714
        """
NEW
715
        return "Water depth/level raster (single time step)"
×
716

717

718
class WaterDepthOrLevelMaximumAlgorithm(BaseThreediDepthAlgorithm):
1✔
719
    """
720
    Calculates maximum water depth or water level from 3Di result NetCDF
721
    """
722
    @property
1✔
723
    def data_type(self) -> str:
1✔
724
        """
725
        WATER_QUANTITY or WATER_QUALITY
726
        """
727
        return WATER_QUANTITY
1✔
728

729
    @property
1✔
730
    def time_step_type(self) -> str:
1✔
731
        return MAXIMUM
1✔
732

733
    def createInstance(self):
1✔
734
        return WaterDepthOrLevelMaximumAlgorithm()
1✔
735

736
    def name(self):
1✔
737
        """Returns the algorithm name, used for identifying the algorithm"""
NEW
738
        return "waterdepthorlevelmaximum"
×
739

740
    def displayName(self):
1✔
741
        """
742
        Returns the translated algorithm name, which should be used for any
743
        user-visible display of the algorithm name.
744
        """
NEW
745
        return "Water depth/level raster (maximum)"
×
746

747

748
class WaterDepthOrLevelMultipleTimeStepAlgorithm(BaseThreediDepthAlgorithm):
1✔
749
    """
750
    Calculates water depth or water level from 3Di result NetCDF for multiple time steps
751
    """
752
    @property
1✔
753
    def data_type(self) -> str:
1✔
754
        """
755
        WATER_QUANITTY or WATER_QUALITY
756
        """
757
        return WATER_QUANTITY
1✔
758

759
    @property
1✔
760
    def time_step_type(self) -> str:
1✔
761
        return MULTIPLE
1✔
762

763
    def createInstance(self):
1✔
764
        return WaterDepthOrLevelMultipleTimeStepAlgorithm()
1✔
765

766
    def name(self):
1✔
767
        """Returns the algorithm name, used for identifying the algorithm"""
NEW
768
        return "waterdepthorlevelmultipletimestep"
×
769

770
    def displayName(self):
1✔
771
        """
772
        Returns the translated algorithm name, which should be used for any
773
        user-visible display of the algorithm name.
774
        """
NEW
775
        return "Water depth/level raster (multiple time steps)"
×
776

777

778
class ConcentrationSingleTimeStepAlgorithm(BaseThreediDepthAlgorithm):
1✔
779
    """
780
    Calculates concentration from 3Di result NetCDF for a single time step
781
    """
782
    @property
1✔
783
    def data_type(self) -> str:
1✔
784
        """
785
        WATER_QUANITTY or WATER_QUALITY
786
        """
787
        return WATER_QUALITY
1✔
788

789
    @property
1✔
790
    def time_step_type(self) -> str:
1✔
791
        return SINGLE
1✔
792

793
    def createInstance(self):
1✔
794
        return ConcentrationSingleTimeStepAlgorithm()
1✔
795

796
    def name(self):
1✔
797
        """Returns the algorithm name, used for identifying the algorithm"""
NEW
798
        return "concentrationsingletimestep"
×
799

800
    def displayName(self):
1✔
801
        """
802
        Returns the translated algorithm name, which should be used for any
803
        user-visible display of the algorithm name.
804
        """
NEW
805
        return "Concentration raster (single time step)"
×
806

807

808
class ConcentrationMultipleTimeStepAlgorithm(BaseThreediDepthAlgorithm):
1✔
809
    """
810
    Calculates concentration rasters from 3Di result NetCDF for multiple time steps
811
    """
812
    @property
1✔
813
    def data_type(self) -> str:
1✔
814
        """
815
        WATER_QUANTITY or WATER_QUALITY
816
        """
817
        return WATER_QUALITY
1✔
818

819
    @property
1✔
820
    def time_step_type(self) -> str:
1✔
821
        return MULTIPLE
1✔
822

823
    def createInstance(self):
1✔
824
        return ConcentrationMultipleTimeStepAlgorithm()
1✔
825

826
    def name(self):
1✔
827
        """Returns the algorithm name, used for identifying the algorithm"""
NEW
828
        return "concentrationmultipletimestep"
×
829

830
    def displayName(self):
1✔
831
        """
832
        Returns the translated algorithm name, which should be used for any
833
        user-visible display of the algorithm name.
834
        """
NEW
835
        return "Concentration raster (multiple time steps)"
×
836

837

838
class ConcentrationMaximumAlgorithm(BaseThreediDepthAlgorithm):
1✔
839
    """
840
    Calculates maximum concentration raster from 3Di result NetCDF
841
    """
842
    @property
1✔
843
    def data_type(self) -> str:
1✔
844
        """
845
        WATER_QUANITTY or WATER_QUALITY
846
        """
847
        return WATER_QUALITY
1✔
848

849
    @property
1✔
850
    def time_step_type(self) -> str:
1✔
851
        return MAXIMUM
1✔
852

853
    def createInstance(self):
1✔
854
        return ConcentrationMaximumAlgorithm()
1✔
855

856
    def name(self):
1✔
857
        """Returns the algorithm name, used for identifying the algorithm"""
NEW
858
        return "concentrationmaximum"
×
859

860
    def displayName(self):
1✔
861
        """
862
        Returns the translated algorithm name, which should be used for any
863
        user-visible display of the algorithm name.
864
        """
NEW
865
        return "Concentration raster (maximum)"
×
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc