Skip to content

Commit 6155c47

Browse files
authored
Add complex dtype support to FillValueCoder for Zarr backend (#11151)
Add support for encoding/decoding complex fill values (complex64, complex128) in the Zarr backend's FillValueCoder, which previously raised ValueError for complex dtypes. This enables converting/virtualizing NetCDF files with these fill values to Zarr.
1 parent 986f48e commit 6155c47

4 files changed

Lines changed: 109 additions & 6 deletions

File tree

doc/whats-new.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ New Features
1717
- Added ``inherit='all_coords'`` option to :py:meth:`DataTree.to_dataset` to inherit
1818
all parent coordinates, not just indexed ones (:issue:`10812`, :pull:`11230`).
1919
By `Alfonso Ladino <https://github.com/aladinor>`_.
20+
- Added complex dtype support to FillValueCoder for the Zarr backend. (:pull:`11151`)
21+
By `Max Jones <https://github.com/maxrjones>`_.
2022

2123
Breaking Changes
2224
~~~~~~~~~~~~~~~~

properties/test_encode_decode.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import hypothesis.extra.numpy as npst
1616
import numpy as np
1717
from hypothesis import given
18+
from hypothesis import strategies as st
1819

1920
import xarray as xr
2021
from xarray.coding.times import _parse_iso8601
@@ -48,6 +49,22 @@ def test_CFScaleOffset_coder_roundtrip(original) -> None:
4849
xr.testing.assert_identical(original, roundtripped)
4950

5051

52+
@given(
53+
real=st.floats(allow_nan=True, allow_infinity=True),
54+
imag=st.floats(allow_nan=True, allow_infinity=True),
55+
dtype=st.sampled_from([np.complex64, np.complex128]),
56+
)
57+
def test_FillValueCoder_complex_roundtrip(real, imag, dtype) -> None:
58+
from xarray.backends.zarr import FillValueCoder
59+
60+
value = dtype(complex(real, imag))
61+
encoded = FillValueCoder.encode(value, np.dtype(dtype))
62+
decoded = FillValueCoder.decode(encoded, np.dtype(dtype))
63+
np.testing.assert_equal(
64+
np.array(decoded, dtype=dtype), np.array(value, dtype=dtype)
65+
)
66+
67+
5168
@given(dt=datetimes())
5269
def test_iso8601_decode(dt):
5370
iso = dt.isoformat()

xarray/backends/zarr.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -122,40 +122,89 @@ class FillValueCoder:
122122
"""
123123

124124
@classmethod
125-
def encode(cls, value: int | float | str | bytes, dtype: np.dtype[Any]) -> Any:
125+
def encode(
126+
cls, value: int | float | complex | str | bytes, dtype: np.dtype[Any]
127+
) -> Any:
126128
if dtype.kind == "S":
127129
# byte string, this implies that 'value' must also be `bytes` dtype.
128-
assert isinstance(value, bytes)
130+
if not isinstance(value, bytes):
131+
raise TypeError(
132+
f"Failed to encode fill_value: expected bytes for dtype {dtype}, got {type(value).__name__}"
133+
)
129134
return base64.standard_b64encode(value).decode()
130135
elif dtype.kind == "b":
131136
# boolean
132137
return bool(value)
133138
elif dtype.kind in "iu":
134-
# todo: do we want to check for decimals?
139+
if not isinstance(value, int | float | np.integer | np.floating):
140+
raise TypeError(
141+
f"Failed to encode fill_value: expected int or float for dtype {dtype}, got {type(value).__name__}"
142+
)
135143
return int(value)
136144
elif dtype.kind == "f":
145+
if not isinstance(value, int | float | np.integer | np.floating):
146+
raise TypeError(
147+
f"Failed to encode fill_value: expected int or float for dtype {dtype}, got {type(value).__name__}"
148+
)
137149
return base64.standard_b64encode(struct.pack("<d", float(value))).decode()
150+
elif dtype.kind == "c":
151+
# complex - encode each component as base64, matching float encoding
152+
if not isinstance(value, complex) and not np.issubdtype(
153+
type(value), np.complexfloating
154+
):
155+
raise TypeError(
156+
f"Failed to encode fill_value: expected complex for dtype {dtype}, got {type(value).__name__}"
157+
)
158+
return [
159+
base64.standard_b64encode(
160+
struct.pack("<d", float(value.real)) # type: ignore[union-attr]
161+
).decode(),
162+
base64.standard_b64encode(
163+
struct.pack("<d", float(value.imag)) # type: ignore[union-attr]
164+
).decode(),
165+
]
138166
elif dtype.kind == "U":
139167
return str(value)
140168
else:
141169
raise ValueError(f"Failed to encode fill_value. Unsupported dtype {dtype}")
142170

143171
@classmethod
144-
def decode(cls, value: int | float | str | bytes, dtype: str | np.dtype[Any]):
172+
def decode(
173+
cls, value: int | float | str | bytes | list, dtype: str | np.dtype[Any]
174+
):
145175
if dtype == "string":
146176
# zarr V3 string type
147177
return str(value)
148178
elif dtype == "bytes":
149179
# zarr V3 bytes type
150-
assert isinstance(value, str | bytes)
180+
if not isinstance(value, str | bytes):
181+
raise TypeError(
182+
f"Failed to decode fill_value: expected str or bytes for dtype {dtype}, got {type(value).__name__}"
183+
)
151184
return base64.standard_b64decode(value)
152185
np_dtype = np.dtype(dtype)
153186
if np_dtype.kind == "f":
154-
assert isinstance(value, str | bytes)
187+
if not isinstance(value, str | bytes):
188+
raise TypeError(
189+
f"Failed to decode fill_value: expected str or bytes for dtype {np_dtype}, got {type(value).__name__}"
190+
)
155191
return struct.unpack("<d", base64.standard_b64decode(value))[0]
192+
elif np_dtype.kind == "c":
193+
# complex - decode each component from base64, matching float decoding
194+
if not (isinstance(value, list | tuple) and len(value) == 2):
195+
raise TypeError(
196+
f"Failed to decode fill_value: expected a 2-element list for dtype {np_dtype}, got {type(value).__name__}"
197+
)
198+
real = struct.unpack("<d", base64.standard_b64decode(value[0]))[0]
199+
imag = struct.unpack("<d", base64.standard_b64decode(value[1]))[0]
200+
return complex(real, imag)
156201
elif np_dtype.kind == "b":
157202
return bool(value)
158203
elif np_dtype.kind in "iu":
204+
if not isinstance(value, int | float | np.integer | np.floating):
205+
raise TypeError(
206+
f"Failed to decode fill_value: expected int or float for dtype {np_dtype}, got {type(value).__name__}"
207+
)
159208
return int(value)
160209
else:
161210
raise ValueError(f"Failed to decode fill_value. Unsupported dtype {dtype}")

xarray/tests/test_backends.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7241,6 +7241,41 @@ def test_encode_zarr_attr_value() -> None:
72417241
assert actual3 == expected3
72427242

72437243

7244+
@requires_zarr
7245+
@pytest.mark.parametrize("dtype", [complex, np.complex64, np.complex128])
7246+
def test_fill_value_coder_complex(dtype) -> None:
7247+
"""Test that FillValueCoder round-trips complex fill values."""
7248+
from xarray.backends.zarr import FillValueCoder
7249+
7250+
for value in [dtype(1 + 2j), dtype(-3.5 + 4.5j), dtype(complex("nan+nanj"))]:
7251+
encoded = FillValueCoder.encode(value, np.dtype(dtype))
7252+
decoded = FillValueCoder.decode(encoded, np.dtype(dtype))
7253+
np.testing.assert_equal(np.array(decoded, dtype=dtype), np.array(value))
7254+
7255+
7256+
@requires_zarr
7257+
@pytest.mark.parametrize(
7258+
"value,dtype",
7259+
[
7260+
(np.float32(np.inf), np.float32),
7261+
(np.float32(-np.inf), np.float32),
7262+
(np.float64(np.inf), np.float64),
7263+
(np.float64(-np.inf), np.float64),
7264+
(np.float32(np.nan), np.float32),
7265+
(np.float64(np.nan), np.float64),
7266+
],
7267+
)
7268+
def test_fill_value_coder_inf_nan(value, dtype) -> None:
7269+
"""Test that FillValueCoder round-trips inf and nan fill values."""
7270+
from xarray.backends.zarr import FillValueCoder
7271+
7272+
encoded = FillValueCoder.encode(value, np.dtype(dtype))
7273+
decoded = FillValueCoder.decode(encoded, np.dtype(dtype))
7274+
np.testing.assert_equal(
7275+
np.array(decoded, dtype=dtype), np.array(value, dtype=dtype)
7276+
)
7277+
7278+
72447279
@requires_zarr
72457280
def test_extract_zarr_variable_encoding() -> None:
72467281
var = xr.Variable("x", [1, 2])

0 commit comments

Comments
 (0)