Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: Add validation for NaN values in datablocks #460

Merged
merged 15 commits into from
Mar 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 28 additions & 3 deletions hydrolib/core/dflowfm/ini/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging
from abc import ABC
from enum import Enum
from math import isnan
from typing import Any, Callable, List, Literal, Optional, Set, Type, Union

from pydantic import Extra, Field, root_validator
Expand Down Expand Up @@ -167,15 +168,17 @@ def _to_section(self, config: INISerializerConfig) -> Section:
return Section(header=self._header, content=props)


Datablock = List[List[Union[float, str]]]


class DataBlockINIBasedModel(INIBasedModel):
"""DataBlockINIBasedModel defines the base model for ini models with datablocks.

Attributes:
datablock (List[List[Union[float, str]]]): (class attribute) the actual data
columns.
datablock (Datablock): (class attribute) the actual data columns.
"""

datablock: List[List[Union[float, str]]] = []
datablock: Datablock = []

_make_lists = make_list_validator("datablock")

Expand Down Expand Up @@ -204,6 +207,28 @@ def convert_value(

return value

@validator("datablock")
def _validate_no_nans_are_present(cls, datablock: Datablock) -> Datablock:
"""Validate that the datablock does not have any NaN values.

Args:
datablock (Datablock): The datablock to verify.

Raises:
ValueError: When a NaN is present in the datablock.

Returns:
Datablock: The validated datablock.
"""
if any(cls._is_float_and_nan(value) for list in datablock for value in list):
raise ValueError("NaN is not supported in datablocks.")

return datablock

@staticmethod
def _is_float_and_nan(value: float) -> bool:
return isinstance(value, float) and isnan(value)


class INIGeneral(INIBasedModel):
_header: Literal["General"] = "General"
Expand Down
41 changes: 41 additions & 0 deletions tests/dflowfm/ini/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from math import nan

import pytest
from pydantic.error_wrappers import ValidationError

from hydrolib.core.dflowfm.ini.models import DataBlockINIBasedModel

from ...utils import error_occurs_only_once


class TestDataBlockINIBasedModel:
def test_datablock_with_nan_values_should_raise_error(self):
model = DataBlockINIBasedModel()

with pytest.raises(ValidationError) as error:
model.datablock = [[nan, 0], [-1, 2]]

expected_message = "NaN is not supported in datablocks."
assert expected_message in str(error.value)

def test_updating_datablock_with_nan_values_should_raise_error(self):
model = DataBlockINIBasedModel()

valid_datablock = [[0, 1], [2, 3]]
model.datablock = valid_datablock

invalid_datablock = [[nan, 1], [2, 3]]
with pytest.raises(ValidationError) as error:
model.datablock = invalid_datablock

expected_message = "NaN is not supported in datablocks."
assert expected_message in str(error.value)

def test_datablock_with_multiple_nans_should_only_give_error_once(self):
model = DataBlockINIBasedModel()

with pytest.raises(ValidationError) as error:
model.datablock = [[nan, nan], [nan, nan]]

expected_message = "NaN is not supported in datablocks."
assert error_occurs_only_once(expected_message, str(error.value))
21 changes: 21 additions & 0 deletions tests/dflowfm/test_bc.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from pathlib import Path
from typing import Dict, List, Literal

import numpy as np
import pytest
from pydantic.error_wrappers import ValidationError

Expand Down Expand Up @@ -357,6 +358,26 @@ def test_forcing_model_correct_default_serializer_config(self):
assert model.serializer_config.comment_delimiter == "#"
assert model.serializer_config.skip_empty_properties == True

def test_forcing_model_with_datablock_that_has_nan_values_should_raise_error(self):
datablock = np.random.uniform(low=-40, high=130.3, size=(4, 2)) * np.nan
datablock_list = datablock.tolist()

with pytest.raises(ValidationError) as error:
TimeSeries(
name="east2_0001",
quantityunitpair=[
QuantityUnitPair(
quantity="time", unit="seconds since 2022-01-01 00:00:00 +00:00"
),
QuantityUnitPair(quantity="waterlevel", unit="m"),
],
timeInterpolation=TimeInterpolation.linear,
datablock=datablock_list,
)

expected_message = "NaN is not supported in datablocks."
assert expected_message in str(error.value)


class TestVectorForcingBase:
class VectorForcingTest(VectorForcingBase):
Expand Down
19 changes: 17 additions & 2 deletions tests/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
from pathlib import Path
from typing import Generic, Optional, TypeVar

import pytest
from numpy import array
from pydantic.generics import GenericModel

TWrapper = TypeVar("TWrapper")
Expand Down Expand Up @@ -89,3 +87,20 @@ def assert_file_is_same_binary(
assert filecmp.cmp(input_path, reference_path)
else:
assert not input_path.exists()


def error_occurs_only_once(error_message: str, full_error: str) -> bool:
"""Check if the given error message occurs exactly once in the full error string.

Args:
error_message (str): The error to check for.
full_error (str): The full error as a string.

Returns:
bool: Return True if the error message occurs exactly once in the full error.
Returns False otherwise.
"""
if error_message is None or full_error is None:
return False

return full_error.count(error_message) == 1