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

nens / ThreeDiToolbox / #2616

22 Oct 2025 09:46AM UTC coverage: 36.554% (+1.8%) from 34.798%
#2616

push

coveralls-python

web-flow
Concentration raster algorithms & general threedidepth algorithms refactor (#1147)

Functional improvements:
- Water depth/level raster algorithm has been split into two algorithms: single and multiple time steps (#958)
- Appropriately style and name water depth/level algorithm outputs.
- New processing algorithm: Concentration raster (single time step)
- New processing algorithm: Concentration raster (multiple time steps)
- New processing algorithm: Concentration raster (maximum)
- Bugfix: Processing algorithm "Maximum water depth/level" fails when writing to a temporary result (#945)
- Bugfix: Water depth tool raises KeyError: 's1_max' when using aggregate_results_3di.nc without the correct aggregation variables (#874)

Code changes:
- All `threedidepth`-based processing algorithm now derive from the same base class
- Utilility functions moved to utils files
- Moved util functions from water depth difference algo to utils files, because they are now also used by the concentration raster algo's. Also moved the tests for these util functions to the appropriate file
- Added smoke integration test for all six threedidepth-processing algo's
- Removed TimeSliderCheckbox widget (is no longer needed because single and multiple time steps algo's have been separated

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 property")
×
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 property")
×
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