Repository: ecmwf/earthkit-data (python)

Issue:
GRIB or BUFR files are recognised as unknown type
### What happened?

It happens when a GRIB or BUFR file does not start with the "GRIB" or "BUFR" bytes, but they contain some padding or data transmission headers. Then `from_source` assigns the `unknown` type to them.

### What are the steps to reproduce the bug?

Use 

```
 wget https://github.com/ecmwf/pdbufr/raw/master/tests/sample-data/M02-HIRS-HIRxxx1B-NA-1.0-20181122114854.000000000Z-20181122132602-1304602.bufr --output-document=hirs.bufr
```
then

```python
import earthkit.data
ds  = earthkit.data.from_source("file", "hirs.bufr")
```


### Version

any

### Platform (OS and architecture)

any

### Relevant log output

_No response_

### Accompanying data

_No response_

### Organisation

_No response_

Pull request description:
Add control for number of bytes used for type checking in reader
This PR fixes #123 and allows to correctly identify GRIB, BUFR and ODB data when they do not start with the "magic" bytes but contain some extra bytes at the beginning. As for NetCDF, this fix will not work because it must start with the four-byte “magic number” otherwise xarray, which is used to handle NetCDF in earthkit-data, cannot read it.

As a solution, the "reader-type-check-bytes" setting was added to control how many bytes the reader uses from the beginning of a source to determine its type. The default is 64 and the allowed range of values is 8 <= x < 4096. To make it work the concept of a Validator was introduced to Settings and the Interval object was implemented too.

We can use it like this when the default 64 bytes are not enough to identify the file type:

```python
from eathkit.data import from_source, settings

# set the number of bytes to 200. It will be saved into the setting file
settings.set("reader-type-check-bytes", 200):
ds = from_source("file", my_grib_with_extra_bytes_1)

# set the number of bytes to 256 temporarily
with settings.temporary("reader-type-check-bytes", 256):
    ds = from_source("file", my_grib_with_extra_bytes_2)



Patch:
diff --git a/earthkit/data/core/settings.py b/earthkit/data/core/settings.py
index 7220c3e..64c0e26 100644
--- a/earthkit/data/core/settings.py
+++ b/earthkit/data/core/settings.py
@@ -19,13 +19,38 @@ import yaml
 
 from earthkit.data import __version__ as VERSION
 from earthkit.data.utils.html import css
-from earthkit.data.utils.humanize import as_bytes, as_percent, as_seconds
+from earthkit.data.utils.humanize import (
+    as_bytes,
+    as_percent,
+    as_seconds,
+    interval_to_human,
+)
+from earthkit.data.utils.interval import Interval
 
 LOG = logging.getLogger(__name__)
 
 DOT_EARTHKIT_DATA = os.path.expanduser("~/.earthkit_data")
 
 
+class Validator:
+    def check(self, value):
+        raise NotImplementedError()
+
+    def explain(self):
+        return str()
+
+
+class IntervalValidator(Validator):
+    def __init__(self, interval):
+        self.interval = interval
+
+    def check(self, value):
+        return value in self.interval
+
+    def explain(self):
+        return f"Valid when {interval_to_human(self.interval)}."
+
+
 class Setting:
     def __init__(
         self,
@@ -35,6 +60,7 @@ class Setting:
         none_ok=False,
         kind=None,
         docs_default=None,
+        validator=None,
     ):
         self.default = default
         self.description = description
@@ -42,6 +68,7 @@ class Setting:
         self.none_ok = none_ok
         self.kind = kind if kind is not None else type(default)
         self.docs_default = docs_default if docs_default is not None else self.default
+        self.validator = validator
 
     def kind(self):
         return type(self.default)
@@ -58,6 +85,15 @@ class Setting:
             print(file=f)
             yaml.dump({name: value}, f, default_flow_style=False)
 
+    @property
+    def docs_description(self):
+        d = self.description
+        if self.validator:
+            t = self.validator.explain()
+            if t:
+                return d + " " + t
+        return d
+
 
 _ = Setting
 
@@ -118,6 +154,11 @@ SETTINGS_AND_HELP = {
         True,
         "Use the standalone mars client when available instead of using the web API.",
     ),
+    "reader-type-check-bytes": _(
+        64,
+        "Number of bytes read from the beginning of a source to identify its type.",
+        validator=IntervalValidator(Interval(8, 4096)),
+    ),
 }
 
 
@@ -258,6 +299,13 @@ class Settings:
             if not isinstance(value, klass):
                 raise TypeError("Setting '%s' must be of type '%s'" % (name, klass))
 
+        validator = SETTINGS_AND_HELP[name].validator
+        if validator is not None:
+            if not validator.check(value):
+                raise ValueError(
+                    f"Settings {name} cannot be set to {value}. {validator.explain()}"
+                )
+
         self._settings[name] = value
         self._changed()
 
diff --git a/earthkit/data/readers/__init__.py b/earthkit/data/readers/__init__.py
index 0c516ba..30fa545 100644
--- a/earthkit/data/readers/__init__.py
+++ b/earthkit/data/readers/__init__.py
@@ -13,6 +13,7 @@ import weakref
 from importlib import import_module
 
 from earthkit.data.core import Base
+from earthkit.data.core.settings import SETTINGS
 from earthkit.data.decorators import locked
 
 LOG = logging.getLogger(__name__)
@@ -154,8 +155,9 @@ def reader(source, path):
         return DirectoryReader(source, path).mutate()
     LOG.debug("Reader for %s", path)
 
+    n_bytes = SETTINGS.get("reader-type-check-bytes")
     with open(path, "rb") as f:
-        magic = f.read(8)
+        magic = f.read(n_bytes)
 
     LOG.debug("Looking for a reader for %s (%s)", path, magic)
 
@@ -165,7 +167,8 @@ def reader(source, path):
 def memory_reader(source, buf):
     """Create a reader for data held in a memory buffer"""
     assert isinstance(buf, (bytes, bytearray)), source
-    magic = buf[:8] if len(buf) > 8 else None
+    n_bytes = SETTINGS.get("reader-type-check-bytes")
+    magic = buf[: min(n_bytes, len(buf) - 1)]
     return _find_reader("memory_reader", source, buf, magic)
 
 
@@ -174,9 +177,10 @@ def stream_reader(source, stream):
     magic = None
     if hasattr(stream, "peek") and callable(stream.peek):
         try:
-            magic = stream.peek(8)
-            if len(magic) > 8:
-                magic = magic[:8]
+            n_bytes = SETTINGS.get("reader-type-check-bytes")
+            magic = stream.peek(n_bytes)
+            if len(magic) > n_bytes:
+                magic = magic[:n_bytes]
         except Exception:
             pass
 
diff --git a/earthkit/data/readers/bufr.py b/earthkit/data/readers/bufr.py
index d305568..97cfad0 100644
--- a/earthkit/data/readers/bufr.py
+++ b/earthkit/data/readers/bufr.py
@@ -101,6 +101,16 @@ class BUFRReader(Reader):
         return ls(_proc, BUFR_LS_KEYS, *args, **kwargs)
 
 
+def _match_magic(magic, deeper_check):
+    if magic is not None:
+        type_id = b"BUFR"
+        if not deeper_check:
+            return len(magic) >= 4 and magic[:4] == type_id
+        else:
+            return type_id in magic
+    return False
+
+
 def reader(source, path, magic=None, deeper_check=False):
-    if magic is None or magic[:4] == b"BUFR":
+    if _match_magic(magic, deeper_check):
         return BUFRReader(source, path)
diff --git a/earthkit/data/readers/grib/__init__.py b/earthkit/data/readers/grib/__init__.py
index 35c560f..3e55667 100644
--- a/earthkit/data/readers/grib/__init__.py
+++ b/earthkit/data/readers/grib/__init__.py
@@ -11,26 +11,32 @@ import logging
 LOG = logging.getLogger(__name__)
 
 
-def _match_magic(magic):
-    return magic is None or (len(magic) >= 4 and magic[:4] == b"GRIB")
+def _match_magic(magic, deeper_check):
+    if magic is not None:
+        type_id = b"GRIB"
+        if not deeper_check:
+            return len(magic) >= 4 and magic[:4] == type_id
+        else:
+            return type_id in magic
+    return False
 
 
 def reader(source, path, magic=None, deeper_check=False):
-    if _match_magic(magic):
+    if _match_magic(magic, deeper_check):
         from .reader import GRIBReader
 
         return GRIBReader(source, path)
 
 
 def memory_reader(source, buf, magic=None, deeper_check=False):
-    if _match_magic(magic):
+    if _match_magic(magic, deeper_check):
         from .memory import FieldListInMemory, GribMessageMemoryReader
 
         return FieldListInMemory(source, GribMessageMemoryReader(buf))
 
 
 def stream_reader(source, stream, magic=None, deeper_check=False):
-    if _match_magic(magic):
+    if _match_magic(magic, deeper_check):
         from .memory import FieldListInMemory, GribStreamReader
 
         r = GribStreamReader(stream)
diff --git a/earthkit/data/readers/netcdf.py b/earthkit/data/readers/netcdf.py
index 6c28621..b5d69cb 100644
--- a/earthkit/data/readers/netcdf.py
+++ b/earthkit/data/readers/netcdf.py
@@ -400,6 +400,13 @@ class NetCDFReader(Reader):
         return [s.bounding_box() for s in self.get_fields()]
 
 
+def _match_magic(magic, deeper_check):
+    if magic is not None:
+        type_id = (b"\x89HDF", b"CDF\x01", b"CDF\x02")
+        return len(magic) >= 4 and magic[:4] in type_id
+    return False
+
+
 def reader(source, path, magic=None, deeper_check=False):
-    if magic is None or magic[:4] in (b"\x89HDF", b"CDF\x01", b"CDF\x02"):
+    if _match_magic(magic, deeper_check):
         return NetCDFReader(source, path)
diff --git a/earthkit/data/readers/odb.py b/earthkit/data/readers/odb.py
index 42bbb38..3fd07e1 100644
--- a/earthkit/data/readers/odb.py
+++ b/earthkit/data/readers/odb.py
@@ -27,6 +27,16 @@ class ODBReader(Reader):
         return odc.read_odb(self.path, single=True, **odc_read_odb_kwargs)
 
 
+def _match_magic(magic, deeper_check):
+    if magic is not None:
+        type_id = b"\xff\xffODA"
+        if not deeper_check:
+            return len(magic) >= 5 and magic[:5] == type_id
+        else:
+            return type_id in magic
+    return False
+
+
 def reader(source, path, magic=None, deeper_check=False):
-    if magic is None or magic[:5] == b"\xff\xffODA":
+    if _match_magic(magic, deeper_check):
         return ODBReader(source, path)
diff --git a/earthkit/data/sphinxext/generate_settings_rst.py b/earthkit/data/sphinxext/generate_settings_rst.py
index 243caa9..8b30232 100644
--- a/earthkit/data/sphinxext/generate_settings_rst.py
+++ b/earthkit/data/sphinxext/generate_settings_rst.py
@@ -55,7 +55,7 @@ def execute(*args):
 
         print("   * - ", k.replace("-", "\u2011"))  # Non-breaking hyphen
         print("     - ", repr(tidy(v.docs_default)).replace("-", "\u2011"))
-        print("     - ", v.description)
+        print("     - ", v.docs_description)
     print()
 
 
diff --git a/earthkit/data/utils/humanize.py b/earthkit/data/utils/humanize.py
index a1c6b50..8fc1847 100644
--- a/earthkit/data/utils/humanize.py
+++ b/earthkit/data/utils/humanize.py
@@ -345,3 +345,19 @@ def rounded_datetime(d):
         d = d + datetime.timedelta(seconds=1)
     d = d.replace(microsecond=0)
     return d
+
+
+def interval_to_human(v, value="x"):
+    if v.bounded:
+        if not v.bounded_left:
+            right_oper = "<=" if v.closed_right else "<"
+            return f"{value} {right_oper} {v.right}"
+        elif not v.bounded_right:
+            left_oper = ">=" if v.closed_left else ">"
+            return f"{value} {left_oper} {v.left}"
+        else:
+            left_oper = "<=" if v.closed_left else "<"
+            right_oper = "<=" if v.closed_right else "<"
+            return f"{v.left} {left_oper} {value} {right_oper} {v.right}"
+    else:
+        return ""
diff --git a/earthkit/data/utils/interval.py b/earthkit/data/utils/interval.py
new file mode 100644
index 0000000..583bf59
--- /dev/null
+++ b/earthkit/data/utils/interval.py
@@ -0,0 +1,75 @@
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+import operator
+
+
+class Interval:
+    def __init__(self, left, right, closed="both"):
+        if left is not None and right is not None and left >= right:
+            raise ValueError(f"Invalid bounds, left={left} >= right={right}")
+        if closed not in ("both", "left", "right", "none"):
+            raise ValueError(f"Invalid closed={closed}")
+
+        self._left = left
+        self._right = right
+        self._closed = closed
+        self._left_oper = operator.lt if closed in ["both", "left"] else operator.le
+        self._right_oper = operator.gt if closed in ["both", "right"] else operator.ge
+
+    def __contains__(self, value):
+        if self.bounded:
+            if self._left is not None and self._left_oper(value, self._left):
+                return False
+            elif self._right is not None and self._right_oper(value, self._right):
+                return False
+        return True
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}(left={self.left}, right={self.right}, closed={self.closed})"
+
+    @property
+    def left(self):
+        return self._left
+
+    @property
+    def right(self):
+        return self._right
+
+    @property
+    def closed(self):
+        return self._closed
+
+    @property
+    def closed_left(self):
+        return self._closed in ["both", "left"]
+
+    @property
+    def closed_right(self):
+        return self._closed in ["both", "right"]
+
+    @property
+    def open_left(self):
+        return not self.closed_left
+
+    @property
+    def open_right(self):
+        return not self.closed_right
+
+    @property
+    def bounded_left(self):
+        return self.left is not None
+
+    @property
+    def bounded_right(self):
+        return self.right is not None
+
+    @property
+    def bounded(self):
+        return self.bounded_left or self.bounded_right


Test patch:
diff --git a/tests/core/test_settings.py b/tests/core/test_settings.py
new file mode 100644
index 0000000..4f6e143
--- /dev/null
+++ b/tests/core/test_settings.py
@@ -0,0 +1,108 @@
+#!/usr/bin/env python3
+
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+import pytest
+
+from earthkit.data import settings
+from earthkit.data.core.temporary import temp_directory
+
+
+@pytest.mark.parametrize(
+    "param,default_value,new_value",
+    [
+        ("number-of-download-threads", 5, 2),
+    ],
+)
+def test_settings_params_set_reset(param, default_value, new_value):
+    ori_value = settings.get(param)
+
+    with settings.temporary():
+        settings.reset()
+
+        assert settings.get(param) == default_value
+        settings.set(param, new_value)
+        assert settings.get(param) == new_value
+        settings.reset()
+        assert settings.get(param) == default_value
+
+    assert settings.get(param) == ori_value
+
+
+def test_settings_invalid():
+    # invalid param
+    with pytest.raises(KeyError):
+        settings.get("_invalid_")
+
+    with pytest.raises(KeyError):
+        settings.set("_invalid_", 1)
+
+    # invalid value
+    with pytest.raises(ValueError):
+        settings.set("number-of-download-threads", "A")
+
+
+@pytest.mark.parametrize(
+    "param,set_value,stored_value,raise_error",
+    [
+        ("number-of-download-threads", "A", "A", ValueError),
+        ("url-download-timeout", 30, 30, None),
+        ("url-download-timeout", "30", 30, None),
+        ("url-download-timeout", "2m", 120, None),
+        ("url-download-timeout", "10h", 36000, None),
+        ("url-download-timeout", "7d", 7 * 24 * 3600, None),
+        ("url-download-timeout", "1x", None, ValueError),
+        ("url-download-timeout", "1M", 60, ValueError),
+        ("reader-type-check-bytes", 8, 8, None),
+        ("reader-type-check-bytes", 1, 1, ValueError),
+        ("reader-type-check-bytes", 4097, 4097, ValueError),
+    ],
+)
+def test_settings_set_numbers(param, set_value, stored_value, raise_error):
+    with settings.temporary():
+        if raise_error is None:
+            settings.set(param, set_value)
+            assert settings.get(param) == stored_value
+        else:
+            with pytest.raises(raise_error):
+                settings.set(param, set_value)
+
+
+def test_settings_set_cache_numbers():
+    with temp_directory() as tmpdir:
+        with settings.temporary("cache-directory", tmpdir):
+            data = [
+                ("maximum-cache-size", "1", 1, None),
+                ("maximum-cache-size", "1k", 1024, None),
+                ("maximum-cache-size", "1kb", 1024, None),
+                ("maximum-cache-size", "1k", 1024, None),
+                ("maximum-cache-size", "1kb", 1024, None),
+                ("maximum-cache-size", "1K", 1024, None),
+                ("maximum-cache-size", "1M", 1024 * 1024, None),
+                ("maximum-cache-size", "1G", 1024 * 1024 * 1024, None),
+                ("maximum-cache-size", "1T", 1024 * 1024 * 1024 * 1024, None),
+                ("maximum-cache-size", "1P", 1024 * 1024 * 1024 * 1024 * 1024, None),
+                ("maximum-cache-size", None, None, None),
+                ("maximum-cache-disk-usage", "2%", 2, None),
+            ]
+
+            for param, set_value, stored_value, raise_error in data:
+                if raise_error is None:
+                    settings.set(param, set_value)
+                    assert settings.get(param) == stored_value
+                else:
+                    with pytest.raises(raise_error):
+                        settings.set(param, set_value)
+
+
+if __name__ == "__main__":
+    from earthkit.data.testing import main
+
+    main(__file__)
diff --git a/tests/readers/test_reader_padding_bytes.py b/tests/readers/test_reader_padding_bytes.py
new file mode 100644
index 0000000..52a9f91
--- /dev/null
+++ b/tests/readers/test_reader_padding_bytes.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+import pytest
+
+from earthkit.data import from_source, settings
+from earthkit.data.core.temporary import temp_file
+from earthkit.data.testing import earthkit_examples_file
+
+
+@pytest.mark.parametrize(
+    "file_path,expected_type,expected_len",
+    [
+        ("test.grib", "grib", 2),
+        ("temp_10.bufr", "bufr", 10),
+        ("test.odb", "odb", None),
+    ],
+)
+def test_reader_padding_bytes(file_path, expected_type, expected_len):
+    path = earthkit_examples_file(file_path)
+    tmp = temp_file()
+
+    # note: "reader-type-check-bytes" is 64 by default
+
+    # prepend padding
+    with open(tmp.path, "wb") as f_tmp, open(path, "rb") as f:
+        b = bytes([0 for _ in range(20)])
+        f_tmp.write(b)
+        f_tmp.write(f.read())
+
+    ds = from_source("file", tmp.path)
+    if hasattr(ds, "_reader"):
+        assert expected_type in str(type(ds._reader)).lower()
+    else:
+        assert expected_type in str(type(ds)).lower()
+    if expected_len is not None:
+        assert len(ds) == expected_len
+
+    # prepend padding
+    tmp = temp_file()
+    with open(tmp.path, "wb") as f_tmp, open(path, "rb") as f:
+        b = bytes([0 for _ in range(80)])
+        f_tmp.write(b)
+        f_tmp.write(f.read())
+
+    ds = from_source("file", tmp.path)
+    assert "file.File" in str(type(ds))
+    assert "unknown" in str(type(ds._reader)).lower()
+
+    with settings.temporary("reader-type-check-bytes", 100):
+        ds = from_source("file", tmp.path)
+        if hasattr(ds, "_reader"):
+            assert expected_type in str(type(ds._reader)).lower()
+        else:
+            assert expected_type in str(type(ds)).lower()
+        if expected_len is not None:
+            assert len(ds) == expected_len
+
+
+if __name__ == "__main__":
+    from earthkit.data.testing import main
+
+    main()
diff --git a/tests/utils/test_interval.py b/tests/utils/test_interval.py
new file mode 100644
index 0000000..7bf57bc
--- /dev/null
+++ b/tests/utils/test_interval.py
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+
+# (C) Copyright 2020 ECMWF.
+#
+# This software is licensed under the terms of the Apache Licence Version 2.0
+# which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
+# In applying this licence, ECMWF does not waive the privileges and immunities
+# granted to it by virtue of its status as an intergovernmental organisation
+# nor does it submit to any jurisdiction.
+#
+
+
+import pytest
+
+from earthkit.data.utils.humanize import interval_to_human
+from earthkit.data.utils.interval import Interval
+
+
+@pytest.mark.parametrize("_kwargs", [{}, {"closed": "both"}])
+def test_interval(_kwargs):
+    v = Interval(1, 2, **_kwargs)
+    assert 0 not in v
+    assert 1 in v
+    assert 1.5 in v
+    assert 2 in v
+    assert 3 not in v
+
+
+def test_interval_closed_left():
+    v = Interval(1, 2, closed="left")
+    assert 0 not in v
+    assert 1 in v
+    assert 1.5 in v
+    assert 2 not in v
+    assert 3 not in v
+
+
+def test_interval_closed_right():
+    v = Interval(1, 2, closed="right")
+    assert 0 not in v
+    assert 1 not in v
+    assert 1.5 in v
+    assert 2 in v
+    assert 3 not in v
+
+
+def test_interval_closed_none():
+    v = Interval(1, 2, closed="none")
+    assert 0 not in v
+    assert 1 not in v
+    assert 1.5 in v
+    assert 2 not in v
+    assert 3 not in v
+
+
+def test_interval_bounded_left():
+    v = Interval(1, None)
+    assert 0 not in v
+    assert 1 in v
+    assert 2 in v
+
+
+def test_interval_bounded_right():
+    v = Interval(None, 2)
+    assert 1 in v
+    assert 2 in v
+    assert 3 not in v
+
+
+def test_interval_unbounded():
+    v = Interval(None, None)
+    assert 1 in v
+    assert 2 in v
+    assert "a" in v
+
+
+def test_interval_invalid():
+    with pytest.raises(ValueError):
+        Interval(2, 1)
+
+    with pytest.raises(ValueError):
+        Interval(1, 1)
+
+    with pytest.raises(ValueError):
+        Interval(2, 1, closed="all")
+
+    with pytest.raises(TypeError):
+        Interval("b", 2)
+
+
+def test_interval_humanised():
+    v = Interval(1, 2)
+    assert interval_to_human(v) == "1 <= x <= 2"
+
+    v = Interval(1, 2, closed="both")
+    assert interval_to_human(v) == "1 <= x <= 2"
+
+    v = Interval(1, 2, closed="left")
+    assert interval_to_human(v) == "1 <= x < 2"
+
+    v = Interval(1, 2, closed="right")
+    assert interval_to_human(v) == "1 < x <= 2"
+
+    v = Interval(1, 2, closed="none")
+    assert interval_to_human(v) == "1 < x < 2"
+
+    v = Interval(None, 2, closed="both")
+    assert interval_to_human(v) == "x <= 2"
+
+    v = Interval(None, 2, closed="left")
+    assert interval_to_human(v) == "x < 2"
+
+    v = Interval(None, 2, closed="right")
+    assert interval_to_human(v) == "x <= 2"
+
+    v = Interval(None, 2, closed="none")
+    assert interval_to_human(v) == "x < 2"
+
+    v = Interval(1, None, closed="both")
+    assert interval_to_human(v) == "x >= 1"
+
+    v = Interval(1, None, closed="left")
+    assert interval_to_human(v) == "x >= 1"
+
+    v = Interval(1, None, closed="right")
+    assert interval_to_human(v) == "x > 1"
+
+    v = Interval(1, None, closed="none")
+    assert interval_to_human(v) == "x > 1"
+
+    v = Interval(None, None)
+    assert interval_to_human(v) == ""
+
+
+if __name__ == "__main__":
+    from earthkit.data.testing import main
+
+    main(__file__)