diff --git a/docs/reference/extold.md b/docs/reference/extold.md new file mode 100644 index 000000000..265914810 --- /dev/null +++ b/docs/reference/extold.md @@ -0,0 +1,7 @@ +# External forcings file +The external forcing .ext file contains the forcing data for a [D-Flow FM](glossary.md#d-flow-fm) model. +This includes open boundaries, lateral discharges and meteorological forcings. +The documentation below only concerns the 'old' format (`ExtForceFile` in the MDU file). + +## Model +::: hydrolib.core.dflowfm.extold.models \ No newline at end of file diff --git a/hydrolib/core/dflowfm/__init__.py b/hydrolib/core/dflowfm/__init__.py index 45181cbd2..3120661ca 100644 --- a/hydrolib/core/dflowfm/__init__.py +++ b/hydrolib/core/dflowfm/__init__.py @@ -2,6 +2,7 @@ from .common import * from .crosssection import * from .ext import * +from .extold import * from .friction import * from .gui import * from .inifield import * diff --git a/hydrolib/core/dflowfm/common/__init__.py b/hydrolib/core/dflowfm/common/__init__.py index ef7f97c8f..1ccbd7aba 100644 --- a/hydrolib/core/dflowfm/common/__init__.py +++ b/hydrolib/core/dflowfm/common/__init__.py @@ -1,3 +1,3 @@ -from .models import LocationType +from .models import LocationType, Operand -__all__ = ["LocationType"] +__all__ = ["LocationType", "Operand"] diff --git a/hydrolib/core/dflowfm/common/models.py b/hydrolib/core/dflowfm/common/models.py index 29681f059..040d50912 100644 --- a/hydrolib/core/dflowfm/common/models.py +++ b/hydrolib/core/dflowfm/common/models.py @@ -15,3 +15,23 @@ class LocationType(str, Enum): all = "all" """str: Denotes that both 1D and 2D locations may be selected.""" + + +class Operand(str, Enum): + """ + Enum class containing the valid values for the operand + attribute in several subclasses of AbstractIniField and ExtOldForcing. + """ + + override = "O" + """Existing values are overwritten with the provided values.""" + append = "A" + """Provided values are used where existing values are missing.""" + add = "+" + """Existing values are summed with the provided values.""" + mult = "*" + """Existing values are multiplied with the provided values.""" + max = "X" + """The maximum values of the existing values and provided values are used.""" + min = "N" + """The minimum values of the existing values and provided values are used.""" diff --git a/hydrolib/core/dflowfm/extold/__init__.py b/hydrolib/core/dflowfm/extold/__init__.py new file mode 100644 index 000000000..0f28bbf71 --- /dev/null +++ b/hydrolib/core/dflowfm/extold/__init__.py @@ -0,0 +1,19 @@ +from .models import ( + ExtOldExtrapolationMethod, + ExtOldFileType, + ExtOldForcing, + ExtOldMethod, + ExtOldModel, + ExtOldQuantity, + ExtOldTracerQuantity, +) + +__all__ = [ + "ExtOldExtrapolationMethod", + "ExtOldForcing", + "ExtOldModel", + "ExtOldQuantity", + "ExtOldFileType", + "ExtOldMethod", + "ExtOldTracerQuantity", +] diff --git a/hydrolib/core/dflowfm/extold/common_io.py b/hydrolib/core/dflowfm/extold/common_io.py new file mode 100644 index 000000000..d6a1b679a --- /dev/null +++ b/hydrolib/core/dflowfm/extold/common_io.py @@ -0,0 +1,23 @@ +from typing import List + +ORDERED_FORCING_FIELDS: List[str] = [ + "QUANTITY", + "FILENAME", + "VARNAME", + "SOURCEMASK", + "FILETYPE", + "METHOD", + "EXTRAPOLATION_METHOD", + "MAXSEARCHRADIUS", + "OPERAND", + "VALUE", + "FACTOR", + "IFRCTYP", + "AVERAGINGTYPE", + "RELATIVESEARCHCELLSIZE", + "EXTRAPOLTOL", + "PERCENTILEMINMAX", + "AREA", + "NUMMIN", +] +"""List[str]: List of the ordered fields names in a forcing block.""" diff --git a/hydrolib/core/dflowfm/extold/models.py b/hydrolib/core/dflowfm/extold/models.py new file mode 100644 index 000000000..76469d474 --- /dev/null +++ b/hydrolib/core/dflowfm/extold/models.py @@ -0,0 +1,573 @@ +from enum import Enum, IntEnum +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional, Union + +from pydantic import Field, root_validator, validator + +from hydrolib.core.basemodel import ( + BaseModel, + DiskOnlyFileModel, + ModelSaveSettings, + ParsableFileModel, + SerializerConfig, +) +from hydrolib.core.dflowfm.common.models import Operand +from hydrolib.core.dflowfm.extold.parser import Parser +from hydrolib.core.dflowfm.extold.serializer import Serializer +from hydrolib.core.dflowfm.polyfile.models import PolyFile +from hydrolib.core.dflowfm.tim.models import TimModel + +HEADER = """ + QUANTITY : waterlevelbnd, velocitybnd, dischargebnd, tangentialvelocitybnd, normalvelocitybnd filetype=9 method=2,3 + : outflowbnd, neumannbnd, qhbnd, uxuyadvectionvelocitybnd filetype=9 method=2,3 + : salinitybnd filetype=9 method=2,3 + : gateloweredgelevel, damlevel, pump filetype=9 method=2,3 + : frictioncoefficient, horizontaleddyviscositycoefficient, advectiontype filetype=4,7,10 method=4 + : bedlevel, ibotlevtype filetype=4,7,10 method=4..9 + : initialwaterlevel filetype=4,7,10,12 method=4..9 + : initialtemperature filetype=4,7,10,12 method=4..9 + : initialvelocityx, initialvelocityy filetype=4,7,10,12 method=4..9 + : initialvelocity filetype=12 method=4..9 + : initialsalinity, initialsalinitytop: use initialsalinity for depth-uniform, or + : as bed level value in combination with initialsalinitytop filetype=4,7,10 method=4 + : initialverticaltemperatureprofile filetype=9,10 method= + : initialverticalsalinityprofile filetype=9,10 method= + : windx, windy, windxy, rainfall, atmosphericpressure filetype=1,2,4,6,7,8 method=1,2,3 + : shiptxy, movingstationtxy filetype=1 method=1 + : discharge_salinity_temperature_sorsin filetype=9 method=1 + : windstresscoefficient filetype=4,7,10 method=4 + + kx = Vectormax = Nr of variables specified on the same time/space frame. Eg. Wind magnitude,direction: kx = 2 + FILETYPE=1 : uniform kx = 1 value 1 dim array uni + FILETYPE=2 : unimagdir kx = 2 values 1 dim array, uni mag/dir transf to u,v, in index 1,2 + FILETYPE=3 : svwp kx = 3 fields u,v,p 3 dim array nointerpolation + FILETYPE=4 : arcinfo kx = 1 field 2 dim array bilin/direct + FILETYPE=5 : spiderweb kx = 3 fields 3 dim array bilin/spw + FILETYPE=6 : curvi kx = ? bilin/findnm + FILETYPE=7 : triangulation kx = 1 field 1 dim array triangulation + FILETYPE=8 : triangulation_magdir kx = 2 fields consisting of Filetype=2 triangulation in (wind) stations + + FILETYPE=9 : polyline kx = 1 For polyline points i= 1 through N specify boundary signals, either as + timeseries or Fourier components or tidal constituents + Timeseries are in files *_000i.tim, two columns: time (min) values + Fourier components and or tidal constituents are in files *_000i.cmp, three columns + period (min) or constituent name (e.g. M2), amplitude and phase (deg) + If no file is specified for a node, its value will be interpolated from surrounding nodes + If only one signal file is specified, the boundary gets a uniform signal + For a dischargebnd, only one signal file must be specified + + FILETYPE=10 : inside_polygon kx = 1 field uniform value inside polygon for INITIAL fields + FILETYPE=11 : ncgrid kx = 1 field 2 dim array triangulation (should have proper standard_name in var, e.g., 'precipitation') + FILETYPE=12 : ncflow (map file) kx = 1 or 2 field 1 dim array triangulation + FILETYPE=14 : ncwave (com file) kx = 1 field 1 dim array triangulation + + METHOD =0 : provider just updates, another provider that pointers to this one does the actual interpolation + =1 : intp space and time (getval) keep 2 meteofields in memory + =2 : first intp space (update), next intp. time (getval) keep 2 flowfields in memory + =3 : save weightfactors, intp space and time (getval), keep 2 pointer- and weight sets in memory. + =4 : only spatial, inside polygon + =5 : only spatial, triangulation, (if samples from *.asc file then bilinear) + =6 : only spatial, averaging + =7 : only spatial, index triangulation + =8 : only spatial, smoothing + =9 : only spatial, internal diffusion + =10 : only initial vertical profiles + + OPERAND =O : Override at all points + =+ : Add to previously specified value + =* : Multiply with previously specified value + =A : Apply only if no value specified previously (For Initial fields, similar to Quickin preserving best data specified first) + =X : MAX with prev. spec. + =N : MIN with prev. spec. + + EXTRAPOLATION_METHOD (ONLY WHEN METHOD=3) + = 0 : No spatial extrapolation. + = 1 : Do spatial extrapolation outside of source data bounding box. + + MAXSEARCHRADIUS (ONLY WHEN EXTRAPOLATION_METHOD=1) + = search radius (in m) for model grid points that lie outside of the source data bounding box. + + AVERAGINGTYPE (ONLY WHEN METHOD=6) + =1 : SIMPLE AVERAGING + =2 : NEAREST NEIGHBOUR + =3 : MAX (HIGHEST) + =4 : MIN (LOWEST) + =5 : INVERSE WEIGHTED DISTANCE-AVERAGE + =6 : MINABS + =7 : KDTREE (LIKE 1, BUT FAST AVERAGING) + + RELATIVESEARCHCELLSIZE : For METHOD=6, the relative search cell size for samples inside cell (default: 1.01) + + PERCENTILEMINMAX : (ONLY WHEN AVERAGINGTYPE=3 or 4) Changes the min/max operator to an average of the + highest/lowest data points. The value sets the percentage of the total set that is to be included. + + NUMMIN = : For METHOD=6, minimum required number of source data points in each target cell. + + VALUE = : Offset value for this provider + + FACTOR = : Conversion factor for this provider + +************************************************************************************************************* +""" + + +class ExtOldTracerQuantity(str, Enum): + """Enum class containing the valid values for the boundary conditions category + of the external forcings that are specific to tracers. + """ + + TracerBnd = "tracerbnd" + """User-defined tracer""" + InitialTracer = "initialtracer" + """Initial tracer""" + + +class ExtOldQuantity(str, Enum): + """Enum class containing the valid values for the boundary conditions category + of the external forcings. + """ + + # Boundary conditions + WaterLevelBnd = "waterlevelbnd" + """Water level""" + NeumannBnd = "neumannbnd" + """Water level gradient""" + RiemannBnd = "riemannbnd" + """Riemann invariant""" + OutflowBnd = "outflowbnd" + """Outflow""" + VelocityBnd = "velocitybnd" + """Velocity""" + DischargeBnd = "dischargebnd" + """Discharge""" + RiemannVelocityBnd = "riemann_velocitybnd" + """Riemann invariant velocity""" + SalinityBnd = "salinitybnd" + """Salinity""" + TemperatureBnd = "temperaturebnd" + """Temperature""" + SedimentBnd = "sedimentbnd" + """Suspended sediment""" + UXUYAdvectionVelocityBnd = "uxuyadvectionvelocitybnd" + """ux-uy advection velocity""" + NormalVelocityBnd = "normalvelocitybnd" + """Normal velocity""" + TangentialVelocityBnd = "tangentialvelocitybnd" + """Tangentional velocity""" + QhBnd = "qhbnd" + """Discharge-water level dependency""" + + # Meteorological fields + WindX = "windx" + """Wind x component""" + WindY = "windy" + """Wind y component""" + WindXY = "windxy" + """Wind vector""" + AirPressureWindXWindY = "airpressure_windx_windy" + """Atmospheric pressure and wind components""" + AirPressureWindXWindYCharnock = "airpressure_windx_windy_charnock" + "Atmospheric pressure and wind components Charnock" + AtmosphericPressure = "atmosphericpressure" + """Atmospheric pressure""" + Rainfall = "rainfall" + """Precipitation""" + RainfallRate = "rainfall_rate" + """Precipitation""" + HumidityAirTemperatureCloudiness = "humidity_airtemperature_cloudiness" + """Combined heat flux terms""" + HumidityAirTemperatureCloudinessSolarRadiation = ( + "humidity_airtemperature_cloudiness_solarradiation" + ) + """Combined heat flux terms""" + DewPointAirTemperatureCloudiness = "dewpoint_airtemperature_cloudiness" + """Dew point air temperature cloudiness""" + LongWaveRadiation = "longwaveradiation" + """Long wave radiation""" + SolarRadiation = "solarradiation" + """Solar radiation""" + DischargeSalinityTemperatureSorSin = "discharge_salinity_temperature_sorsin" + """Discharge, salinity temperature source-sinks""" + NudgeSalinityTemperature = "nudge_salinity_temperature" + """Nudging salinity and temperature""" + + # Structure parameters + Pump = "pump" + """Pump capacity""" + DamLevel = "damlevel" + """Dam level""" + GateLowerEdgeLevel = "gateloweredgelevel" + """Gate lower edge level""" + GeneralStructure = "generalstructure" + """General structure""" + + # Initial fields + InitialWaterLevel = "initialwaterlevel" + """Initial water level""" + InitialSalinity = "initialsalinity" + """Initial salinity""" + InitialSalinityTop = "initialsalinitytop" + """Initial salinity top layer""" + InitialTemperature = "initialtemperature" + """Initial temperature""" + InitialVerticalTemperatureProfile = "initialverticaltemperatureprofile" + """Initial vertical temperature profile""" + InitialVerticalSalinityProfile = "initialverticalsalinityprofile" + """Initial vertical salinity profile""" + BedLevel = "bedlevel" + """Bed level""" + + # Spatial physical properties + FrictionCoefficient = "frictioncoefficient" + """Friction coefficient""" + HorizontalEddyViscosityCoefficient = "horizontaleddyviscositycoefficient" + """Horizontal eddy viscosity coefficient""" + InternalTidesFrictionCoefficient = "internaltidesfrictioncoefficient" + """Internal tides friction coefficient""" + HorizontalEddyDiffusivityCoefficient = "horizontaleddydiffusivitycoefficient" + """Horizontal eddy diffusivity coefficient""" + AdvectionType = "advectiontype" + """Type of advection scheme""" + IBotLevType = "ibotlevtype" + """Type of bed-level handling""" + + # Miscellaneous + ShiptXY = "shiptxy" + """shiptxy""" + MovingStationXY = "movingstationxy" + """Moving observation point for output (time, x, y)""" + WaveSignificantHeight = "wavesignificantheight" + """Wave significant height""" + WavePeriod = "waveperiod" + """Wave period""" + + +class ExtOldFileType(IntEnum): + """Enum class containing the valid values for the `filetype` attribute + in the [ExtForcing][hydrolib.core.dflowfm.extold.models.ExtForcing] class. + """ + + TimeSeries = 1 + """1. Time series""" + TimeSeriesMagnitudeAndDirection = 2 + """2. Time series magnitude and direction""" + SpatiallyVaryingWindPressure = 3 + """3. Spatially varying wind and pressure""" + ArcInfo = 4 + """4. ArcInfo""" + SpiderWebData = 5 + """5. Spiderweb data (cyclones)""" + CurvilinearData = 6 + """6. Space-time data on curvilinear grid""" + Samples = 7 + """7. Samples""" + TriangulationMagnitudeAndDirection = 8 + """8. Triangulation magnitude and direction""" + Polyline = 9 + """9. Polyline (<*.pli>-file)""" + NetCDFGridData = 11 + """11. NetCDF grid data (e.g. meteo fields)""" + NetCDFWaveData = 14 + """14. NetCDF wave data""" + + +class ExtOldMethod(IntEnum): + """Enum class containing the valid values for the `method` attribute + in the [ExtForcing][hydrolib.core.dflowfm.extold.models.ExtForcing] class. + """ + + PassThrough = 1 + """1. Pass through (no interpolation)""" + InterpolateTimeAndSpace = 2 + """2. Interpolate time and space""" + InterpolateTimeAndSpaceSaveWeights = 3 + """3. Interpolate time and space, save weights""" + InterpolateSpace = 4 + """4. Interpolate space""" + InterpolateTime = 5 + """5. Interpolate time""" + AveragingSpace = 6 + """6. Averaging in space""" + InterpolateExtrapolateTime = 7 + """7. Interpolate/Extrapolate time""" + + +class ExtOldExtrapolationMethod(IntEnum): + """Enum class containing the valid values for the `extrapolation_method` attribute + in the [ExtForcing][hydrolib.core.dflowfm.extold.models.ExtForcing] class. + """ + + NoSpatialExtrapolation = 0 + """0. No spatial extrapolation.""" + SpatialExtrapolationOutsideOfSourceDataBoundingBox = 1 + """1. Do spatial extrapolation outside of source data bounding box.""" + + +class ExtOldForcing(BaseModel): + """Class holding the external forcing values.""" + + quantity: Union[ExtOldQuantity, str] = Field(alias="QUANTITY") + """Union[Quantity, str]: The name of the quantity.""" + + filename: Union[PolyFile, TimModel, DiskOnlyFileModel] = Field( + None, alias="FILENAME" + ) + """Union[PolyFile, TimModel, DiskOnlyFileModel]: The file associated to this forcing.""" + + varname: Optional[str] = Field(None, alias="VARNAME") + """Optional[str]: The variable name used in `filename` associated with this forcing; some input files may contain multiple variables.""" + + sourcemask: DiskOnlyFileModel = Field( + default_factory=lambda: DiskOnlyFileModel(None), alias="SOURCEMASK" + ) + """DiskOnlyFileModel: The file containing a mask.""" + + filetype: ExtOldFileType = Field(alias="FILETYPE") + """FileType: Indication of the file type. + + Options: + 1. Time series + 2. Time series magnitude and direction + 3. Spatially varying weather + 4. ArcInfo + 5. Spiderweb data (cyclones) + 6. Curvilinear data + 7. Samples (C.3) + 8. Triangulation magnitude and direction + 9. Polyline (<*.pli>-file, C.2) + 11. NetCDF grid data (e.g. meteo fields) + 14. NetCDF wave data + """ + + method: ExtOldMethod = Field(alias="METHOD") + """ExtOldMethod: The method of interpolation. + + Options: + 1. Pass through (no interpolation) + 2. Interpolate time and space + 3. Interpolate time and space, save weights + 4. Interpolate space + 5. Interpolate time + 6. Averaging space + 7. Interpolate/Extrapolate time + """ + + extrapolation_method: Optional[ExtOldExtrapolationMethod] = Field( + None, alias="EXTRAPOLATION_METHOD" + ) + """Optional[ExtOldExtrapolationMethod]: The extrapolation method. + + Options: + 0. No spatial extrapolation. + 1. Do spatial extrapolation outside of source data bounding box. + """ + + maxsearchradius: Optional[float] = Field(None, alias="MAXSEARCHRADIUS") + """Optional[float]: Search radius (in m) for model grid points that lie outside of the source data bounding box.""" + + operand: Operand = Field(alias="OPERAND") + """Operand: The operand to use for adding the provided values. + + Options: + 'O' Existing values are overwritten with the provided values. + 'A' Provided values are used where existing values are missing. + '+' Existing values are summed with the provided values. + '*' Existing values are multiplied with the provided values. + 'X' The maximum values of the existing values and provided values are used. + 'N' The minimum values of the existing values and provided values are used. + """ + + value: Optional[float] = Field(None, alias="VALUE") + """Optional[float]: Custom coefficients for transformation.""" + + factor: Optional[float] = Field(None, alias="FACTOR") + """Optional[float]: The conversion factor.""" + + ifrctyp: Optional[float] = Field(None, alias="IFRCTYP") + """Optional[float]: The friction type.""" + + averagingtype: Optional[float] = Field(None, alias="AVERAGINGTYPE") + """Optional[float]: The averaging type.""" + + relativesearchcellsize: Optional[float] = Field( + None, alias="RELATIVESEARCHCELLSIZE" + ) + """Optional[float]: The relative search cell size for samples inside a cell.""" + + extrapoltol: Optional[float] = Field(None, alias="EXTRAPOLTOL") + """Optional[float]: The extrapolation tolerance.""" + + percentileminmax: Optional[float] = Field(None, alias="PERCENTILEMINMAX") + """Optional[float]: Changes the min/max operator to an average of the highest/lowest data points. The value sets the percentage of the total set that is to be included..""" + + area: Optional[float] = Field(None, alias="AREA") + """Optional[float]: The area for sources and sinks.""" + + nummin: Optional[int] = Field(None, alias="NUMMIN") + """Optional[int]: The minimum required number of source data points in each target cell.""" + + @validator("quantity", pre=True) + def validate_quantity(cls, value): + if isinstance(value, ExtOldQuantity): + return value + + def raise_error_tracer_name(quantity: ExtOldTracerQuantity): + raise ValueError( + f"QUANTITY '{quantity}' should be appended with a tracer name." + ) + + if isinstance(value, ExtOldTracerQuantity): + raise_error_tracer_name(value) + + value_str = str(value) + lower_value = value_str.lower() + + for tracer_quantity in ExtOldTracerQuantity: + if lower_value.startswith(tracer_quantity): + n = len(tracer_quantity) + if n == len(value_str): + raise_error_tracer_name(tracer_quantity) + return tracer_quantity + value_str[n:] + + if lower_value in list(ExtOldQuantity): + return ExtOldQuantity(lower_value) + + supported_value_str = ", ".join(([x.value for x in ExtOldQuantity])) + raise ValueError( + f"QUANTITY '{value_str}' not supported. Supported values: {supported_value_str}" + ) + + @validator("operand", pre=True) + def validate_operand(cls, value): + if isinstance(value, Operand): + return value + + if isinstance(value, str): + + for operand in Operand: + if value.lower() == operand.value.lower(): + return operand + + supported_value_str = ", ".join(([x.value for x in Operand])) + raise ValueError( + f"OPERAND '{value}' not supported. Supported values: {supported_value_str}" + ) + + return value + + @root_validator(skip_on_failure=True) + def validate_forcing(cls, values): + class _Field: + def __init__(self, key: str) -> None: + self.alias = cls.__fields__[key].alias + self.value = values[key] + + def raise_error_only_allowed_when( + field: _Field, dependency: _Field, valid_dependency_value: str + ): + error = f"{field.alias} only allowed when {dependency.alias} is {valid_dependency_value}" + raise ValueError(error) + + def only_allowed_when( + field: _Field, dependency: _Field, valid_dependency_value: Any + ): + """This function checks if a particular field is allowed to have a value only when a dependency field has a specific value.""" + + if field.value is None or dependency.value == valid_dependency_value: + return + + raise_error_only_allowed_when(field, dependency, valid_dependency_value) + + quantity = _Field("quantity") + varname = _Field("varname") + sourcemask = _Field("sourcemask") + filetype = _Field("filetype") + method = _Field("method") + extrapolation_method = _Field("extrapolation_method") + maxsearchradius = _Field("maxsearchradius") + value = _Field("value") + factor = _Field("factor") + ifrctype = _Field("ifrctyp") + averagingtype = _Field("averagingtype") + relativesearchcellsize = _Field("relativesearchcellsize") + extrapoltol = _Field("extrapoltol") + percentileminmax = _Field("percentileminmax") + area = _Field("area") + nummin = _Field("nummin") + + only_allowed_when(varname, filetype, ExtOldFileType.NetCDFGridData) + + if sourcemask.value.filepath is not None and filetype.value not in [ + ExtOldFileType.ArcInfo, + ExtOldFileType.CurvilinearData, + ]: + raise_error_only_allowed_when( + sourcemask, filetype, valid_dependency_value="4 or 6" + ) + + if ( + extrapolation_method.value + == ExtOldExtrapolationMethod.SpatialExtrapolationOutsideOfSourceDataBoundingBox + and method.value != ExtOldMethod.InterpolateTimeAndSpaceSaveWeights + ): + error = f"{extrapolation_method.alias} only allowed to be 1 when {method.alias} is 3" + raise ValueError(error) + + only_allowed_when( + maxsearchradius, + extrapolation_method, + ExtOldExtrapolationMethod.SpatialExtrapolationOutsideOfSourceDataBoundingBox, + ) + only_allowed_when(value, method, ExtOldMethod.InterpolateSpace) + + if factor.value is not None and not quantity.value.startswith( + ExtOldTracerQuantity.InitialTracer + ): + error = f"{factor.alias} only allowed when {quantity.alias} starts with {ExtOldTracerQuantity.InitialTracer}" + raise ValueError(error) + + only_allowed_when(ifrctype, quantity, ExtOldQuantity.FrictionCoefficient) + only_allowed_when(averagingtype, method, ExtOldMethod.AveragingSpace) + only_allowed_when(relativesearchcellsize, method, ExtOldMethod.AveragingSpace) + only_allowed_when(extrapoltol, method, ExtOldMethod.InterpolateTime) + only_allowed_when(percentileminmax, method, ExtOldMethod.AveragingSpace) + only_allowed_when( + area, quantity, ExtOldQuantity.DischargeSalinityTemperatureSorSin + ) + only_allowed_when(nummin, method, ExtOldMethod.AveragingSpace) + + return values + + +class ExtOldModel(ParsableFileModel): + """ + The overall external forcings model that contains the contents of one external forcings file (old format). + + This model is typically referenced under a [FMModel][hydrolib.core.dflowfm.mdu.models.FMModel]`.external_forcing.extforcefile`. + """ + + comment: List[str] = HEADER.splitlines()[1:] + """List[str]: The comments in the header of the external forcing file.""" + forcing: List[ExtOldForcing] = [] + """List[ExtForcing]: The external forcing blocks in the external forcing file.""" + + @classmethod + def _ext(cls) -> str: + return ".ext" + + @classmethod + def _filename(cls) -> str: + return "externalforcings" + + def dict(self, *args, **kwargs): + return dict(comment=self.comment, forcing=[dict(f) for f in self.forcing]) + + @classmethod + def _get_serializer( + cls, + ) -> Callable[[Path, Dict, SerializerConfig, ModelSaveSettings], None]: + return Serializer.serialize + + @classmethod + def _get_parser(cls) -> Callable[[Path], Dict]: + return Parser.parse diff --git a/hydrolib/core/dflowfm/extold/parser.py b/hydrolib/core/dflowfm/extold/parser.py new file mode 100644 index 000000000..ff03c58a8 --- /dev/null +++ b/hydrolib/core/dflowfm/extold/parser.py @@ -0,0 +1,115 @@ +from pathlib import Path +from typing import Dict, List + +from hydrolib.core.dflowfm.extold.common_io import ORDERED_FORCING_FIELDS + + +class Parser: + """Parser class for parsing the forcing data of the old external forcings file to a dictionary to construct the `ExtOldModel` with.""" + + @staticmethod + def parse(filepath: Path) -> Dict: + """Parses the file at the specified path to the forcing data. + + If a line starts with an asterisk (*) it is considered a comment. + Comments are only allowed at the header of the file. Elsewhere, they will be skipped. + + Args: + path (Path): The path to parse the data from. + + Returns: + Dict[str, List[Any]]: A dictionary with the parsed data, with two keys: + - 'comment' (List[str]): A list of the parsed comments. + - 'forcing' (List[Dict[str, str]]): A list of the parsed forcing data as dictionaries. Each dictionary represents a forcing block from the file, with the parsed key value pairs. + + Raises: + ValueError: Thrown when the order of a forcing block is not correct. Fields should be in the following order: + "QUANTITY", "FILENAME", "VARNAME", "SOURCEMASK", "FILETYPE", "METHOD", "OPERAND", "VALUE", "FACTOR", "IFRCTYP", + "AVERAGINGTYPE", "RELATIVESEARCHCELLSIZE", "EXTRAPOLTOL", "PERCENTILEMINMAX", "AREA", "NUMMIN" + """ + + with filepath.open() as file: + lines = file.readlines() + + comments, start_data_index = Parser._parse_header(lines) + forcings = Parser._parse_data(lines, start_data_index) + + return dict(comment=comments, forcing=forcings) + + @staticmethod + def _parse_header(lines: List[str]): + comments: List[str] = [] + + start_data_index = 0 + for line_index in range(len(lines)): + + line = lines[line_index].strip() + + if len(line) == 0: + comments.append(line) + continue + + if line.startswith("*"): + comments.append(line[1:]) + continue + + start_data_index = line_index + break + + return comments, start_data_index + + @staticmethod + def _parse_data(lines: List[str], start_index: int): + forcings: List[Dict[str, str]] = [] + current_forcing: Dict[str, str] = {} + + for line_index in range(start_index, len(lines)): + + line = lines[line_index].strip() + + if line.startswith("*"): + continue + + if len(line) == 0: + if len(current_forcing) != 0: + Parser._validate_order(current_forcing, line_index) + forcings.append(current_forcing) + current_forcing = {} + continue + + key, value = line.split("=", 1) + current_forcing[key.strip()] = value.strip() + + if len(current_forcing) != 0: + Parser._validate_order(current_forcing, line_index) + forcings.append(current_forcing) + + return forcings + + @staticmethod + def _validate_order(forcing: Dict[str, str], line_number: int): + """Validates the order of the forcing fields given in the forcing block. + + - The fields are compared case insensitive. + - For each KNOWN field that was parsed the order is checked. + """ + + parsed_fields_upper = [f.upper() for f in forcing.keys()] + model_fields_upper = [f.upper() for f in ORDERED_FORCING_FIELDS] + + # Get the ordered KNOWN parsed fields, by filtering out unknown fields + parsed_fields_ordered = [ + f for f in model_fields_upper if f in parsed_fields_upper + ] + + # Get the unorderd KNOWN parsed fields, by filtering out unknown fields + parsed_fields_unordered = [ + f for f in parsed_fields_upper if f in model_fields_upper + ] + + if parsed_fields_unordered != parsed_fields_ordered: + line_number_start = line_number + 1 - len(parsed_fields_upper) + parsed_fields_ordered_str = ", ".join(parsed_fields_ordered) + raise ValueError( + f"Line {line_number_start}: Properties should be in the following order: {parsed_fields_ordered_str}" + ) diff --git a/hydrolib/core/dflowfm/extold/serializer.py b/hydrolib/core/dflowfm/extold/serializer.py new file mode 100644 index 000000000..abd236627 --- /dev/null +++ b/hydrolib/core/dflowfm/extold/serializer.py @@ -0,0 +1,107 @@ +from pathlib import Path +from typing import Any, Dict, List + +from hydrolib.core.basemodel import ( + DiskOnlyFileModel, + FileModel, + ModelSaveSettings, + SerializerConfig, +) +from hydrolib.core.dflowfm.extold.common_io import ORDERED_FORCING_FIELDS +from hydrolib.core.utils import FilePathStyleConverter + + +class Serializer: + """Serializer class for serializing the forcing data of the `ExtOldModel` to file.""" + + _file_path_style_converter = FilePathStyleConverter() + + @staticmethod + def serialize( + path: Path, + data: Dict[str, List[Any]], + config: SerializerConfig, + save_settings: ModelSaveSettings, + ) -> None: + + """ + Serialize the given data and write it to a file at the given path. + + This function may create a new file at the given path, or overwrite an existing file. + + Args: + path (Path): The path to write the serialized data to. + data (Dict[str, List[Any]]): The data to be serialized. The data should contain two keys: + - 'comment' (List[str]): a list of the comments + - 'forcing' (List[Dict[str, Any]]): a list of the external forcing data + config (SerializerConfig): Configuration settings for the serializer. + save_settings (ModelSaveSettings): Settings for how the model should be saved. + """ + + path.parent.mkdir(parents=True, exist_ok=True) + + serialized_comments: List[str] = [] + serialized_blocks: List[str] = [] + + for comment in data["comment"]: + serialized_comment = Serializer._serialize_comment(comment) + serialized_comments.append(serialized_comment) + + for forcing in data["forcing"]: + serialized_block = Serializer._serialize_forcing( + forcing, config, save_settings + ) + serialized_blocks.append(serialized_block) + + file_content: str = ( + "\n".join(serialized_comments) + "\n" + "\n\n".join(serialized_blocks) + ) + + with path.open("w") as f: + f.write(file_content) + + @staticmethod + def _serialize_comment(comment: str): + return f"*{comment}" + + @staticmethod + def _serialize_forcing( + forcing: Dict[str, Any], + config: SerializerConfig, + save_settings: ModelSaveSettings, + ) -> str: + + serialized_rows = [] + + for key in ORDERED_FORCING_FIELDS: + value = forcing.get(key.lower()) + + if Serializer._skip_field_serialization(value): + continue + + value = Serializer._convert_value(value, config, save_settings) + + serialized_row = f"{key}={value}" + serialized_rows.append(serialized_row) + + serialized_block = "\n".join(serialized_rows) + return serialized_block + + @classmethod + def _convert_value( + cls, value: Any, config: SerializerConfig, save_settings: ModelSaveSettings + ) -> str: + if isinstance(value, float): + return f"{value:{config.float_format}}" + if isinstance(value, FileModel): + return Serializer._file_path_style_converter.convert_from_os_style( + value.filepath, save_settings.path_style + ) + + return str(value) + + @classmethod + def _skip_field_serialization(cls, value: Any) -> str: + return value is None or ( + isinstance(value, DiskOnlyFileModel) and value.filepath is None + ) diff --git a/hydrolib/core/dflowfm/inifield/__init__.py b/hydrolib/core/dflowfm/inifield/__init__.py index 1eea7fd0c..7e0c43dd8 100644 --- a/hydrolib/core/dflowfm/inifield/__init__.py +++ b/hydrolib/core/dflowfm/inifield/__init__.py @@ -5,14 +5,12 @@ IniFieldModel, InitialField, InterpolationMethod, - Operand, ParameterField, ) __all__ = [ "DataFileType", "InterpolationMethod", - "Operand", "AveragingType", "IniFieldGeneral", "InitialField", diff --git a/hydrolib/core/dflowfm/inifield/models.py b/hydrolib/core/dflowfm/inifield/models.py index d006b939a..2196e371b 100644 --- a/hydrolib/core/dflowfm/inifield/models.py +++ b/hydrolib/core/dflowfm/inifield/models.py @@ -9,6 +9,7 @@ from hydrolib.core.basemodel import DiskOnlyFileModel from hydrolib.core.dflowfm.common import LocationType +from hydrolib.core.dflowfm.common.models import Operand from hydrolib.core.dflowfm.ini.models import INIBasedModel, INIGeneral, INIModel from hydrolib.core.dflowfm.ini.util import ( get_enum_validator, @@ -47,22 +48,6 @@ class InterpolationMethod(str, Enum): allowedvaluestext = "Possible values: constant, triangulation, averaging." -class Operand(str, Enum): - """ - Enum class containing the valid values for the operand - attribute in several subclasses of AbstractIniField. - """ - - override = "O" # override any previous data. - append = "A" # append, sets only where data is still missing. - add = "+" # adds the provided values to the existing values. - mult = "*" # multiplies the existing values by the provided values. - max = "X" # takes the maximum of the existing values and the provided values. - min = "N" # takes the minimum of the existing values and the provided values. - - allowedvaluestext = "Possible values: O, A, +, *, X, N." - - class AveragingType(str, Enum): """ Enum class containing the valid values for the averagingType diff --git a/hydrolib/core/dflowfm/mdu/models.py b/hydrolib/core/dflowfm/mdu/models.py index 62e2648ff..7c9b54e96 100644 --- a/hydrolib/core/dflowfm/mdu/models.py +++ b/hydrolib/core/dflowfm/mdu/models.py @@ -12,6 +12,7 @@ ) from hydrolib.core.dflowfm.crosssection.models import CrossDefModel, CrossLocModel from hydrolib.core.dflowfm.ext.models import ExtModel +from hydrolib.core.dflowfm.extold.models import ExtOldModel from hydrolib.core.dflowfm.friction.models import FrictionModel from hydrolib.core.dflowfm.ini.models import INIBasedModel, INIGeneral, INIModel from hydrolib.core.dflowfm.ini.serializer import INISerializerConfig @@ -762,9 +763,7 @@ class Comments(INIBasedModel.Comments): ) _header: Literal["External Forcing"] = "External Forcing" - extforcefile: DiskOnlyFileModel = Field( - default_factory=lambda: DiskOnlyFileModel(None), alias="extForceFile" - ) + extforcefile: Optional[ExtOldModel] = Field(None, alias="extForceFile") extforcefilenew: Optional[ExtModel] = Field(None, alias="extForceFileNew") rainfall: Optional[bool] = Field(None, alias="rainfall") qext: Optional[bool] = Field(None, alias="qExt") diff --git a/tests/dflowfm/test_extold.py b/tests/dflowfm/test_extold.py new file mode 100644 index 000000000..d8265c011 --- /dev/null +++ b/tests/dflowfm/test_extold.py @@ -0,0 +1,1000 @@ +from pathlib import Path + +import pytest + +from hydrolib.core.basemodel import ( + DiskOnlyFileModel, + ModelSaveSettings, + SerializerConfig, +) +from hydrolib.core.dflowfm.common.models import Operand +from hydrolib.core.dflowfm.extold.models import ( + HEADER, + ExtOldExtrapolationMethod, + ExtOldFileType, + ExtOldForcing, + ExtOldMethod, + ExtOldModel, + ExtOldQuantity, + ExtOldTracerQuantity, +) +from hydrolib.core.dflowfm.extold.parser import Parser +from hydrolib.core.dflowfm.extold.serializer import Serializer +from hydrolib.core.dflowfm.polyfile.models import PolyFile +from hydrolib.core.dflowfm.tim.models import TimModel + +from ..utils import ( + assert_files_equal, + create_temp_file_from_lines, + get_temp_file, + test_input_dir, +) + +EXP_HEADER = """ + QUANTITY : waterlevelbnd, velocitybnd, dischargebnd, tangentialvelocitybnd, normalvelocitybnd filetype=9 method=2,3 + : outflowbnd, neumannbnd, qhbnd, uxuyadvectionvelocitybnd filetype=9 method=2,3 + : salinitybnd filetype=9 method=2,3 + : gateloweredgelevel, damlevel, pump filetype=9 method=2,3 + : frictioncoefficient, horizontaleddyviscositycoefficient, advectiontype filetype=4,7,10 method=4 + : bedlevel, ibotlevtype filetype=4,7,10 method=4..9 + : initialwaterlevel filetype=4,7,10,12 method=4..9 + : initialtemperature filetype=4,7,10,12 method=4..9 + : initialvelocityx, initialvelocityy filetype=4,7,10,12 method=4..9 + : initialvelocity filetype=12 method=4..9 + : initialsalinity, initialsalinitytop: use initialsalinity for depth-uniform, or + : as bed level value in combination with initialsalinitytop filetype=4,7,10 method=4 + : initialverticaltemperatureprofile filetype=9,10 method= + : initialverticalsalinityprofile filetype=9,10 method= + : windx, windy, windxy, rainfall, atmosphericpressure filetype=1,2,4,6,7,8 method=1,2,3 + : shiptxy, movingstationtxy filetype=1 method=1 + : discharge_salinity_temperature_sorsin filetype=9 method=1 + : windstresscoefficient filetype=4,7,10 method=4 + + kx = Vectormax = Nr of variables specified on the same time/space frame. Eg. Wind magnitude,direction: kx = 2 + FILETYPE=1 : uniform kx = 1 value 1 dim array uni + FILETYPE=2 : unimagdir kx = 2 values 1 dim array, uni mag/dir transf to u,v, in index 1,2 + FILETYPE=3 : svwp kx = 3 fields u,v,p 3 dim array nointerpolation + FILETYPE=4 : arcinfo kx = 1 field 2 dim array bilin/direct + FILETYPE=5 : spiderweb kx = 3 fields 3 dim array bilin/spw + FILETYPE=6 : curvi kx = ? bilin/findnm + FILETYPE=7 : triangulation kx = 1 field 1 dim array triangulation + FILETYPE=8 : triangulation_magdir kx = 2 fields consisting of Filetype=2 triangulation in (wind) stations + + FILETYPE=9 : polyline kx = 1 For polyline points i= 1 through N specify boundary signals, either as + timeseries or Fourier components or tidal constituents + Timeseries are in files *_000i.tim, two columns: time (min) values + Fourier components and or tidal constituents are in files *_000i.cmp, three columns + period (min) or constituent name (e.g. M2), amplitude and phase (deg) + If no file is specified for a node, its value will be interpolated from surrounding nodes + If only one signal file is specified, the boundary gets a uniform signal + For a dischargebnd, only one signal file must be specified + + FILETYPE=10 : inside_polygon kx = 1 field uniform value inside polygon for INITIAL fields + FILETYPE=11 : ncgrid kx = 1 field 2 dim array triangulation (should have proper standard_name in var, e.g., 'precipitation') + FILETYPE=12 : ncflow (map file) kx = 1 or 2 field 1 dim array triangulation + FILETYPE=14 : ncwave (com file) kx = 1 field 1 dim array triangulation + + METHOD =0 : provider just updates, another provider that pointers to this one does the actual interpolation + =1 : intp space and time (getval) keep 2 meteofields in memory + =2 : first intp space (update), next intp. time (getval) keep 2 flowfields in memory + =3 : save weightfactors, intp space and time (getval), keep 2 pointer- and weight sets in memory. + =4 : only spatial, inside polygon + =5 : only spatial, triangulation, (if samples from *.asc file then bilinear) + =6 : only spatial, averaging + =7 : only spatial, index triangulation + =8 : only spatial, smoothing + =9 : only spatial, internal diffusion + =10 : only initial vertical profiles + + OPERAND =O : Override at all points + =+ : Add to previously specified value + =* : Multiply with previously specified value + =A : Apply only if no value specified previously (For Initial fields, similar to Quickin preserving best data specified first) + =X : MAX with prev. spec. + =N : MIN with prev. spec. + + EXTRAPOLATION_METHOD (ONLY WHEN METHOD=3) + = 0 : No spatial extrapolation. + = 1 : Do spatial extrapolation outside of source data bounding box. + + MAXSEARCHRADIUS (ONLY WHEN EXTRAPOLATION_METHOD=1) + = search radius (in m) for model grid points that lie outside of the source data bounding box. + + AVERAGINGTYPE (ONLY WHEN METHOD=6) + =1 : SIMPLE AVERAGING + =2 : NEAREST NEIGHBOUR + =3 : MAX (HIGHEST) + =4 : MIN (LOWEST) + =5 : INVERSE WEIGHTED DISTANCE-AVERAGE + =6 : MINABS + =7 : KDTREE (LIKE 1, BUT FAST AVERAGING) + + RELATIVESEARCHCELLSIZE : For METHOD=6, the relative search cell size for samples inside cell (default: 1.01) + + PERCENTILEMINMAX : (ONLY WHEN AVERAGINGTYPE=3 or 4) Changes the min/max operator to an average of the + highest/lowest data points. The value sets the percentage of the total set that is to be included. + + NUMMIN = : For METHOD=6, minimum required number of source data points in each target cell. + + VALUE = : Offset value for this provider + + FACTOR = : Conversion factor for this provider + +************************************************************************************************************* +""" + + +class TestExtForcing: + def test_initialize_with_timfile_initializes_timmodel(self): + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename=test_input_dir / "tim" / "triple_data_for_timeseries.tim", + filetype=ExtOldFileType.TimeSeries, + method=ExtOldMethod.InterpolateTimeAndSpaceSaveWeights, + operand=Operand.override, + ) + + assert isinstance(forcing.filename, TimModel) + + def test_initialize_with_polyfile_initializes_polyfile(self): + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename=test_input_dir / "dflowfm_individual_files" / "test.pli", + filetype=ExtOldFileType.Polyline, + method=ExtOldMethod.InterpolateTimeAndSpaceSaveWeights, + operand=Operand.override, + ) + + assert isinstance(forcing.filename, PolyFile) + + def test_initialize_with_unrecognized_file_initializes_diskonlyfilemodel(self): + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename=Path(test_input_dir / "file_load_test" / "FlowFM_net.nc"), + filetype=ExtOldFileType.NetCDFGridData, + method=ExtOldMethod.InterpolateTimeAndSpaceSaveWeights, + operand=Operand.override, + ) + + assert isinstance(forcing.filename, DiskOnlyFileModel) + + class TestValidateQuantity: + @pytest.mark.parametrize("quantity", ExtOldQuantity) + def test_with_valid_quantity_string_equal_casing(self, quantity): + quantity_str = quantity.value + forcing = ExtOldForcing( + quantity=quantity_str, filename="", filetype=9, method=1, operand="O" + ) + assert forcing.quantity == quantity + + @pytest.mark.parametrize("quantity", ExtOldQuantity) + def test_with_valid_quantity_string_different_casing(self, quantity): + quantity_str = quantity.value.upper() + forcing = ExtOldForcing( + quantity=quantity_str, filename="", filetype=9, method=1, operand="O" + ) + assert forcing.quantity == quantity + + @pytest.mark.parametrize("quantity", ExtOldQuantity) + def test_with_valid_quantity_enum(self, quantity): + forcing = ExtOldForcing( + quantity=quantity, filename="", filetype=9, method=1, operand="O" + ) + assert forcing.quantity == quantity + + @pytest.mark.parametrize("quantity", ExtOldTracerQuantity) + def test_with_tracerquantity_appended_with_tracer_name(self, quantity): + quantity_str = quantity + "Some_Tracer_Name" + forcing = ExtOldForcing( + quantity=quantity_str, filename="", filetype=9, method=1, operand="O" + ) + assert forcing.quantity == quantity_str + + @pytest.mark.parametrize("quantity", ExtOldTracerQuantity) + def test_with_just_a_tracerquantity_raises_error(self, quantity): + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=quantity, filename="", filetype=9, method=1, operand="O" + ) + + exp_error = ( + f"QUANTITY '{quantity.value}' should be appended with a tracer name." + ) + assert exp_error in str(error.value) + + @pytest.mark.parametrize("quantity", ExtOldTracerQuantity) + def test_with_tracerquantity_string_without_tracer_name_raises_error( + self, quantity + ): + quantity_str = quantity.value + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=quantity_str, + filename="", + filetype=9, + method=1, + operand="O", + ) + + assert ( + f"QUANTITY '{quantity_str}' should be appended with a tracer name." + in str(error.value) + ) + + def test_with_invalid_quantity_string_raises_value_error( + self, + ): + quantity_str = "invalid" + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=quantity_str, + filename="", + filetype=9, + method=1, + operand="O", + ) + + supported_values_str = ", ".join(([x.value for x in ExtOldQuantity])) + assert ( + f"QUANTITY 'invalid' not supported. Supported values: {supported_values_str}" + in str(error.value) + ) + + class TestValidateOperand: + @pytest.mark.parametrize("operand", Operand) + def test_with_valid_operand_string_equal_casing(self, operand): + operand_str = operand.value + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=1, + operand=operand_str, + ) + assert forcing.operand == operand + + @pytest.mark.parametrize("operand", Operand) + def test_with_valid_operand_string_different_casing(self, operand): + operand_str = operand.value.lower() + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=1, + operand=operand_str, + ) + assert forcing.operand == operand + + @pytest.mark.parametrize("operand", Operand) + def test_with_valid_operand_enum(self, operand): + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=1, + operand=operand, + ) + assert forcing.operand == operand + + def test_with_invalid_operand_string_raises_value_error( + self, + ): + operand_str = "invalid" + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=1, + operand=operand_str, + ) + + supported_values_str = ", ".join(([x.value for x in Operand])) + assert ( + f"OPERAND 'invalid' not supported. Supported values: {supported_values_str}" + in str(error.value) + ) + + class TestValidateVarName: + def test_validate_varname_with_valid_filetype_11(self): + filetype = 11 + varname = "some_varname" + + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + varname=varname, + filetype=filetype, + method=1, + operand="O", + ) + + assert forcing.varname == varname + + def test_validate_varname_with_invalid_filetype(self): + filetype = 9 + varname = "some_varname" + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + varname=varname, + filetype=filetype, + method=1, + operand="O", + ) + + exp_msg = "VARNAME only allowed when FILETYPE is 11" + assert exp_msg in str(error.value) + + class TestValidateSourceMask: + @pytest.mark.parametrize("filetype", [4, 6]) + def test_validate_sourcemask_with_valid_filetype_4_or_6(self, filetype): + sourcemask = "sourcemask.file" + + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + sourcemask=sourcemask, + filetype=filetype, + method=1, + operand="O", + ) + + assert forcing.sourcemask.filepath.name == sourcemask + + def test_validate_sourcemask_with_invalid_filetype(self): + filetype = 9 + sourcemask = "sourcemask.file" + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + sourcemask=sourcemask, + filetype=filetype, + method=1, + operand="O", + ) + + exp_msg = "SOURCEMASK only allowed when FILETYPE is 4 or 6" + assert exp_msg in str(error.value) + + class TestValidateExtrapolationMethod: + def test_validate_extrapolation_method_with_valid_method_3(self): + method = 3 + extrapolation_method = ( + ExtOldExtrapolationMethod.SpatialExtrapolationOutsideOfSourceDataBoundingBox + ) + + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + extrapolation_method=extrapolation_method, + operand="O", + ) + + assert forcing.extrapolation_method == extrapolation_method + + def test_validate_extrapolation_method_with_invalid_method(self): + method = 1 + extrapolation_method = ( + ExtOldExtrapolationMethod.SpatialExtrapolationOutsideOfSourceDataBoundingBox + ) + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + extrapolation_method=extrapolation_method, + operand="O", + ) + + exp_msg = "EXTRAPOLATION_METHOD only allowed to be 1 when METHOD is 3" + assert exp_msg in str(error.value) + + class TestValidateMaxSearchRadius: + def test_validate_maxsearchradius_method_with_valid_extrapolation_method_1( + self, + ): + extrapolation_method = 1 + maxsearchradius = 1.23 + + forcing = ExtOldForcing( + quantity=ExtOldQuantity.AirPressureWindXWindY, + filename="", + filetype=3, + method=3, + extrapolation_method=extrapolation_method, + maxsearchradius=maxsearchradius, + operand="O", + ) + + assert forcing.extrapolation_method == extrapolation_method + + def test_validate_maxsearchradius_method_with_invalid_extrapolation_method( + self, + ): + extrapolation_method = 0 + maxsearchradius = 1.23 + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=ExtOldQuantity.AirPressureWindXWindY, + filename="", + filetype=3, + method=3, + extrapolation_method=extrapolation_method, + maxsearchradius=maxsearchradius, + operand="O", + ) + + exp_msg = "MAXSEARCHRADIUS only allowed when EXTRAPOLATION_METHOD is 1" + assert exp_msg in str(error.value) + + class TestValidateValue: + def test_validate_value_with_valid_method_4(self): + method = 4 + value = 1.23 + + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + value=value, + ) + + assert forcing.value == value + + def test_validate_sourcemask_with_invalid_method(self): + method = 1 + value = 1.23 + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + value=value, + ) + + exp_msg = "VALUE only allowed when METHOD is 4" + assert exp_msg in str(error.value) + + class TestValidateFactor: + def test_validate_factor_with_valid_quantity_initialtracer(self): + quantity = ExtOldTracerQuantity.InitialTracer + "Some_Tracer_Name" + factor = 1.23 + + forcing = ExtOldForcing( + quantity=quantity, + filename="", + filetype=9, + method=1, + operand="O", + factor=factor, + ) + + assert forcing.factor == factor + + def test_validate_factor_with_invalid_quantity(self): + quantity = ExtOldQuantity.WaterLevelBnd + factor = 1.23 + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=quantity, + filename="", + filetype=9, + method=1, + operand="O", + factor=factor, + ) + + exp_msg = "FACTOR only allowed when QUANTITY starts with initialtracer" + assert exp_msg in str(error.value) + + class TestValidateIFrcTyp: + def test_validate_ifrctyp_with_valid_quantity_frictioncoefficient(self): + quantity = ExtOldQuantity.FrictionCoefficient + ifrctyp = 1.23 + + forcing = ExtOldForcing( + quantity=quantity, + filename="", + filetype=9, + method=1, + operand="O", + ifrctyp=ifrctyp, + ) + + assert forcing.ifrctyp == ifrctyp + + def test_validate_ifrctyp_with_invalid_quantity(self): + quantity = ExtOldQuantity.WaterLevelBnd + ifrctyp = 1.23 + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=quantity, + filename="", + filetype=9, + method=1, + operand="O", + ifrctyp=ifrctyp, + ) + + exp_msg = "IFRCTYP only allowed when QUANTITY is frictioncoefficient" + assert exp_msg in str(error.value) + + class TestValidateAveragingType: + def test_validate_averagingtype_with_valid_method_6(self): + method = 6 + averagingtype = 1.23 + + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + averagingtype=averagingtype, + ) + + assert forcing.averagingtype == averagingtype + + def test_validate_averagingtype_with_invalid_method(self): + method = 1 + averagingtype = 1.23 + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + averagingtype=averagingtype, + ) + + exp_msg = "AVERAGINGTYPE only allowed when METHOD is 6" + assert exp_msg in str(error.value) + + class TestValidateRelativeSearchCellSize: + def test_validate_relativesearchcellsize_with_valid_method_6(self): + method = 6 + relativesearchcellsize = 1.23 + + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + relativesearchcellsize=relativesearchcellsize, + ) + + assert forcing.relativesearchcellsize == relativesearchcellsize + + def test_validate_relativesearchcellsize_with_invalid_method(self): + method = 1 + relativesearchcellsize = 1.23 + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + relativesearchcellsize=relativesearchcellsize, + ) + + exp_msg = "RELATIVESEARCHCELLSIZE only allowed when METHOD is 6" + assert exp_msg in str(error.value) + + class TestValidateExtrapolTol: + def test_validate_extrapoltol_with_valid_method_5(self): + method = 5 + extrapoltol = 1.23 + + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + extrapoltol=extrapoltol, + ) + + assert forcing.extrapoltol == extrapoltol + + def test_validate_extrapoltol_with_invalid_method(self): + method = 1 + extrapoltol = 1.23 + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + extrapoltol=extrapoltol, + ) + + exp_msg = "EXTRAPOLTOL only allowed when METHOD is 5" + assert exp_msg in str(error.value) + + class TestValidatePercentileMinMax: + def test_validate_percentileminmax_with_valid_method_6(self): + method = 6 + percentileminmax = 1.23 + + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + percentileminmax=percentileminmax, + ) + + assert forcing.percentileminmax == percentileminmax + + def test_validate_percentileminmax_with_invalid_method(self): + method = 1 + percentileminmax = 1.23 + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + percentileminmax=percentileminmax, + ) + + exp_msg = "PERCENTILEMINMAX only allowed when METHOD is 6" + assert exp_msg in str(error.value) + + class TestValidateArea: + def test_validate_area_with_valid_quantity_discharge_salinity_temperature_sorsin( + self, + ): + quantity = ExtOldQuantity.DischargeSalinityTemperatureSorSin + area = 1.23 + + forcing = ExtOldForcing( + quantity=quantity, + filename="", + filetype=9, + method=1, + operand="O", + area=area, + ) + + assert forcing.area == area + + def test_validate_area_with_invalid_quantity(self): + quantity = ExtOldQuantity.WaterLevelBnd + area = 1.23 + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=quantity, + filename="", + filetype=9, + method=1, + operand="O", + area=area, + ) + + exp_msg = "AREA only allowed when QUANTITY is discharge_salinity_temperature_sorsin" + assert exp_msg in str(error.value) + + class TestValidateNumMin: + def test_validate_nummin_with_valid_method_6(self): + method = 6 + nummin = 123 + + forcing = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + nummin=nummin, + ) + + assert forcing.nummin == nummin + + def test_validate_nummin_with_invalid_method(self): + method = 1 + nummin = 123 + + with pytest.raises(ValueError) as error: + _ = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename="", + filetype=9, + method=method, + operand="O", + nummin=nummin, + ) + + exp_msg = "NUMMIN only allowed when METHOD is 6" + assert exp_msg in str(error.value) + + +class TestExtOldModel: + def test_header(self): + assert HEADER == EXP_HEADER + + def test_initialization(self): + model = ExtOldModel() + + assert model.comment == HEADER.splitlines()[1:] + assert len(model.forcing) == 0 + + def test_load_model(self): + file_content = [ + "* This is a comment", + "* This is a comment", + "", + "QUANTITY=internaltidesfrictioncoefficient", + "FILENAME=surroundingDomain.pol", + "FILETYPE=11", + "METHOD=4", + "OPERAND=+", + "VALUE=0.0125", + "", + "* This is a comment", + "", + "QUANTITY=waterlevelbnd", + "FILENAME=OB_001_orgsize.pli", + "FILETYPE=9", + "METHOD=3", + "* This is a comment", + "OPERAND=O", + "* This is a comment", + ] + + with create_temp_file_from_lines( + file_content, "test_load_model_two_blocks.ext" + ) as temp_file: + model = ExtOldModel(filepath=temp_file) + + # Assert correct comments + assert len(model.comment) == 3 + exp_comments = [" This is a comment", " This is a comment", ""] + assert model.comment == exp_comments + + # Assert correct forcings + assert len(model.forcing) == 2 + + forcing_1 = model.forcing[0] + assert forcing_1.quantity == ExtOldQuantity.InternalTidesFrictionCoefficient + assert forcing_1.filename.filepath == Path("surroundingDomain.pol") + assert forcing_1.varname == None + assert forcing_1.sourcemask.filepath == None + assert forcing_1.filetype == ExtOldFileType.NetCDFGridData + assert forcing_1.method == ExtOldMethod.InterpolateSpace + assert forcing_1.operand == Operand.add + assert forcing_1.value == 0.0125 + assert forcing_1.factor == None + assert forcing_1.ifrctyp == None + assert forcing_1.averagingtype == None + assert forcing_1.relativesearchcellsize == None + assert forcing_1.extrapoltol == None + assert forcing_1.percentileminmax == None + assert forcing_1.area == None + assert forcing_1.nummin == None + + forcing_2 = model.forcing[1] + assert forcing_2.quantity == ExtOldQuantity.WaterLevelBnd + assert forcing_2.filename.filepath == Path("OB_001_orgsize.pli") + assert forcing_2.varname == None + assert forcing_2.sourcemask.filepath == None + assert forcing_2.filetype == ExtOldFileType.Polyline + assert forcing_2.method == ExtOldMethod.InterpolateTimeAndSpaceSaveWeights + assert forcing_2.operand == Operand.override + assert forcing_2.value == None + assert forcing_2.factor == None + assert forcing_2.ifrctyp == None + assert forcing_2.averagingtype == None + assert forcing_2.relativesearchcellsize == None + assert forcing_2.extrapoltol == None + assert forcing_2.percentileminmax == None + assert forcing_2.area == None + assert forcing_2.nummin == None + + def test_save_model(self): + + exp_file_content = [ + "*This is a comment", + "*This is a comment", + "*", + "QUANTITY=internaltidesfrictioncoefficient", + "FILENAME=surroundingDomain.pol", + "FILETYPE=11", + "METHOD=4", + "OPERAND=+", + "VALUE=0.012500", + "", + "QUANTITY=waterlevelbnd", + "FILENAME=OB_001_orgsize.pli", + "FILETYPE=9", + "METHOD=3", + "OPERAND=O", + ] + + comments = ["This is a comment", "This is a comment", ""] + + forcing_1 = ExtOldForcing( + quantity=ExtOldQuantity.InternalTidesFrictionCoefficient, + filename=Path("surroundingDomain.pol"), + filetype=ExtOldFileType.NetCDFGridData, + method=ExtOldMethod.InterpolateSpace, + operand=Operand.add, + value=0.0125, + ) + + forcing_2 = ExtOldForcing( + quantity=ExtOldQuantity.WaterLevelBnd, + filename=Path("OB_001_orgsize.pli"), + filetype=ExtOldFileType.Polyline, + method=ExtOldMethod.InterpolateTimeAndSpaceSaveWeights, + operand=Operand.override, + ) + + model = ExtOldModel(comment=comments, forcing=[forcing_1, forcing_2]) + + model.serializer_config.float_format = "f" + + with get_temp_file("test_save_model.ext") as file: + model.save(filepath=file) + + with create_temp_file_from_lines( + exp_file_content, "test_save_model_expected.ext" + ) as exp_file: + assert_files_equal(file, exp_file) + + +class TestParser: + def test_parse_two_blocks_parses_to_the_correct_dictionaries(self): + file_content = [ + "* This is a comment", + "* This is a comment", + "", + "QUANTITY=internaltidesfrictioncoefficient", + "FILENAME=surroundingDomain.pol", + "FILETYPE=11", + "METHOD=4", + "OPERAND=+", + "VALUE=0.0125", + "", + "* This is a comment", + "", + "QUANTITY=waterlevelbnd", + "FILENAME=OB_001_orgsize.pli", + "FILETYPE=9", + "METHOD=3", + "* This is a comment", + "OPERAND=O", + "* This is a comment", + ] + + parser = Parser() + + with create_temp_file_from_lines(file_content, "two_blocks.ext") as temp_file: + data = parser.parse(filepath=temp_file) + + exp_data = { + "comment": [" This is a comment", " This is a comment", ""], + "forcing": [ + { + "QUANTITY": "internaltidesfrictioncoefficient", + "FILENAME": "surroundingDomain.pol", + "FILETYPE": "11", + "METHOD": "4", + "OPERAND": "+", + "VALUE": "0.0125", + }, + { + "QUANTITY": "waterlevelbnd", + "FILENAME": "OB_001_orgsize.pli", + "FILETYPE": "9", + "METHOD": "3", + "OPERAND": "O", + }, + ], + } + + assert data == exp_data + + def test_parse_block_with_incorrect_order_raises_error(self): + file_lines = [ + "FILENAME=surroundingDomain.pol", + "QUANTITY=internaltidesfrictioncoefficient", + "FILETYPE=11", + "OPERAND=+", + "VALUE=0.0125", + "QUANTITY=internaltidesfrictioncoefficient", + ] + + parser = Parser() + + with create_temp_file_from_lines( + file_lines, "incorrect_order.ext" + ) as temp_file: + with pytest.raises(ValueError) as error: + parser.parse(filepath=temp_file) + + exp_error = "Line 1: Properties should be in the following order: QUANTITY, FILENAME, FILETYPE, OPERAND, VALUE" + assert str(error.value) == exp_error + + +class TestSerializer: + def test_serialize(self): + exp_file_content = [ + "*This is a comment", + "*This is a comment", + "*", + "QUANTITY=internaltidesfrictioncoefficient", + "FILENAME=surroundingDomain.pol", + "FILETYPE=11", + "METHOD=4", + "OPERAND=+", + "VALUE=0.012500", + "", + "QUANTITY=waterlevelbnd", + "FILENAME=OB_001_orgsize.pli", + "FILETYPE=9", + "METHOD=3", + "OPERAND=O", + ] + + comments = ["This is a comment", "This is a comment", ""] + + forcing_1 = { + "quantity": "internaltidesfrictioncoefficient", + "filename": DiskOnlyFileModel(Path("surroundingDomain.pol")), + "filetype": 11, + "method": 4, + "operand": "+", + "value": 0.0125, + } + + forcing_2 = { + "quantity": "waterlevelbnd", + "filename": DiskOnlyFileModel(Path("OB_001_orgsize.pli")), + "filetype": 9, + "method": 3, + "operand": "O", + } + + forcing_data = {"comment": comments, "forcing": [forcing_1, forcing_2]} + + serializer_config = SerializerConfig(float_format="f") + save_settings = ModelSaveSettings() + + with get_temp_file("test_serialize.ext") as file: + Serializer.serialize(file, forcing_data, serializer_config, save_settings) + + with create_temp_file_from_lines( + exp_file_content, "test_serialize_expected.ext" + ) as exp_file: + assert_files_equal(file, exp_file) diff --git a/tests/dflowfm/test_inifield.py b/tests/dflowfm/test_inifield.py index 32f713257..1ff782c15 100644 --- a/tests/dflowfm/test_inifield.py +++ b/tests/dflowfm/test_inifield.py @@ -1,29 +1,23 @@ import inspect -from contextlib import nullcontext as does_not_raise from pathlib import Path -from typing import Any, List, Union import pytest from pydantic.error_wrappers import ValidationError -from pydantic.types import FilePath +from hydrolib.core.dflowfm.common.models import Operand from hydrolib.core.dflowfm.ini.parser import Parser, ParserConfig from hydrolib.core.dflowfm.inifield.models import ( - AveragingType, DataFileType, - IniFieldGeneral, IniFieldModel, InitialField, InterpolationMethod, LocationType, - Operand, ParameterField, ) from ..utils import ( WrapperTest, assert_files_equal, - invalid_test_data_dir, test_data_dir, test_output_dir, test_reference_dir, diff --git a/tests/dflowfm/test_xyn.py b/tests/dflowfm/test_xyn.py index 07fbb1ec2..b405d3364 100644 --- a/tests/dflowfm/test_xyn.py +++ b/tests/dflowfm/test_xyn.py @@ -4,6 +4,7 @@ from hydrolib.core.dflowfm.xyn.models import XYNModel, XYNPoint from hydrolib.core.dflowfm.xyn.parser import XYNParser from hydrolib.core.dflowfm.xyn.serializer import XYNSerializer +from tests.utils import create_temp_file, get_temp_file from ..utils import ( assert_files_equal, diff --git a/tests/test_model.py b/tests/test_model.py index 4412f15df..81338fd81 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -384,12 +384,6 @@ def _create_boundary(data: Dict) -> Boundary: @pytest.mark.parametrize( "input_field, create_model, retrieve_field", [ - pytest.param( - "extforcefile", - lambda d: ExternalForcing(**d), - lambda m: m.extforcefile, - id="extforcefile", - ), pytest.param( "restartfile", lambda d: Restart(**d),