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

nens / ThreeDiToolbox / #2610

21 Oct 2025 08:45AM UTC coverage: 34.548% (-0.3%) from 34.798%
#2610

push

coveralls-python

web-flow
Merge 55e1ecf69 into 7ff7d80d5

255 of 753 new or added lines in 7 files covered. (33.86%)

2 existing lines in 1 file now uncovered.

4997 of 14464 relevant lines covered (34.55%)

0.35 hits per line

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

34.03
/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: tests
88
# TODO: shortHelpStrings
89
# Replace two sliders with qgsrangeslider
90

91

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

95

96
class Progress:
1✔
97
    def __init__(self, feedback: QgsFeedback):
1✔
NEW
98
        self.feedback = feedback
×
99

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

105

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

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

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

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

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

171
    @property
1✔
172
    def parameters(self) -> List:
1✔
NEW
173
        result = []
×
174

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

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

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

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

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

363
    def output_file(self, parameters, context) -> Path:
1✔
NEW
364
        return Path(self.parameterAsFileOutput(parameters, OUTPUT_FILENAME, context))
×
365

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

607
        # Save data to be used in postProcessAlgorithm
NEW
608
        self.output_layer_name = self.output_layer_name_from_parameters(parameters, context)
×
NEW
609
        self._results = {OUTPUT_FILENAME: str(final_output)}
×
NEW
610
        return self._results
×
611

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

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

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

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

654
                "To get a smoother result, use the option to spatially interpolate the concentrations.",
655

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

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

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

688

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

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

701
    @property
1✔
702
    def time_step_type(self) -> str:
1✔
NEW
703
        return SINGLE
×
704

705
    def createInstance(self):
1✔
NEW
706
        return WaterDepthOrLevelSingleTimeStepAlgorithm()
×
707

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

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

719

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

731
    @property
1✔
732
    def time_step_type(self) -> str:
1✔
NEW
733
        return MAXIMUM
×
734

735
    def createInstance(self):
1✔
NEW
736
        return WaterDepthOrLevelMaximumAlgorithm()
×
737

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

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

749

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

761
    @property
1✔
762
    def time_step_type(self) -> str:
1✔
NEW
763
        return MULTIPLE
×
764

765
    def createInstance(self):
1✔
NEW
766
        return WaterDepthOrLevelMultipleTimeStepAlgorithm()
×
767

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

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

779

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

791
    @property
1✔
792
    def time_step_type(self) -> str:
1✔
NEW
793
        return SINGLE
×
794

795
    def createInstance(self):
1✔
NEW
796
        return ConcentrationSingleTimeStepAlgorithm()
×
797

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

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

809

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

821
    @property
1✔
822
    def time_step_type(self) -> str:
1✔
NEW
823
        return MULTIPLE
×
824

825
    def createInstance(self):
1✔
NEW
826
        return ConcentrationMultipleTimeStepAlgorithm()
×
827

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

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

839

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

851
    @property
1✔
852
    def time_step_type(self) -> str:
1✔
NEW
853
        return MAXIMUM
×
854

855
    def createInstance(self):
1✔
NEW
856
        return ConcentrationMaximumAlgorithm()
×
857

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

862
    def displayName(self):
1✔
863
        """
864
        Returns the translated algorithm name, which should be used for any
865
        user-visible display of the algorithm name.
866
        """
NEW
867
        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