feat: write complex numbers, incl. HDF5 2.0 native complex (class 11)

- Datatype::Complex serializes class 11 version 5 byte-identically to
  libhdf5 2.2.0; containers holding it are written as version 5.
- DatasetBuilder::with_complex_f32/f64_data (h5py's {r, i} compound,
  default) and with_native_complex_f32/f64_data (class 11, opt-in);
  make_(native_)complex_f32/f64_type for attributes.
- Dataset::read_complex_f64/f32 read either form.
- Python create_dataset accepts complex64/complex128 (compound form).
- Parsing unchanged: class 11 still surfaces as {r, i}.
- Tests vs h5py 3.16 / libhdf5 2.0.0 and h5dump 2.2.0; docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-28 21:25:03 -05:00
co-authored by Claude Opus 5.5
parent bce07e9cb9
commit d0d5347cd9
21 changed files with 790 additions and 24 deletions
+5 -2
View File
@@ -100,8 +100,11 @@ f.remote_stats # {'requests': ..., 'bytes_fetched': ..., 'hits': ..., ...}
`clawhdf5.File(path, "w")` with `create_dataset(name, data=array,
chunks=..., compression="gzip")`, `create_group` and `attrs[...] = ...`
writes `float64`, `float32`, `int64`, `int32` and `uint8` arrays; the file is
written on `close()`.
writes `float64`, `float32`, `int64`, `int32`, `uint8`, `complex64` and
`complex128` arrays; the file is written on `close()`. Complex arrays are
stored as h5py stores them, a compound `{r, i}` that every libhdf5 reads
(not HDF5 2.0's native complex type, which only libhdf5 2.0+ reads; the
Rust API writes that on request).
## Editing a file in place
+3
View File
@@ -313,6 +313,9 @@ fn np_dtype_with_metadata<'py>(
/// read as they are.
pub(crate) fn fixed_dtype<'py>(py: Python<'py>, dt: &Datatype) -> PyResult<Bound<'py, PyAny>> {
match dt {
Datatype::Complex { size, base_type } => {
fixed_dtype(py, &Datatype::complex_as_compound(*size, base_type))
}
Datatype::FixedPoint { .. } => np_dtype(py, int_format(dt)?),
Datatype::FloatingPoint { .. } => np_dtype(py, float_format(dt)?),
Datatype::String { size, charset, .. } => {
+4 -1
View File
@@ -47,6 +47,7 @@ fn not_implemented(what: impl std::fmt::Display) -> PyErr {
/// `edit_helpers._convert_array`), or why they cannot be written.
pub(crate) fn category(dt: &Datatype) -> PyResult<&'static str> {
match dt {
Datatype::Complex { .. } => Ok("complex"),
Datatype::FixedPoint { .. } => Ok("int"),
Datatype::FloatingPoint { .. } if crate::convert::is_ieee_float(dt) => Ok("float"),
// Read as a wider IEEE float; writing would need the reverse
@@ -107,7 +108,9 @@ fn check_exact(dt: &Datatype) -> PyResult<()> {
Datatype::Compound { members, .. } => {
members.iter().try_for_each(|m| check_exact(&m.datatype))
}
Datatype::Array { base_type, .. } => check_exact(base_type),
Datatype::Array { base_type, .. } | Datatype::Complex { base_type, .. } => {
check_exact(base_type)
}
Datatype::String {
padding: StringPadding::NullPad,
..
+23 -1
View File
@@ -161,6 +161,10 @@ pub(crate) enum DatasetData {
I64(Vec<i64>),
I32(Vec<i32>),
U8(Vec<u8>),
/// numpy `complex64`, `[re, im]` pairs, written as h5py does.
C64(Vec<[f32; 2]>),
/// numpy `complex128`, `[re, im]` pairs, written as h5py does.
C128(Vec<[f64; 2]>),
}
/// Specification for a dataset to be written.
@@ -281,6 +285,14 @@ pub(crate) fn apply_dataset_spec(
DatasetData::U8(v) => {
db.with_u8_data(v);
}
// h5py's compound `{r, i}`, not HDF5 2.0's native complex type: it
// is what h5py writes (3.16 included) and every libhdf5 can read it.
DatasetData::C64(v) => {
db.with_complex_f32_data(v);
}
DatasetData::C128(v) => {
db.with_complex_f64_data(v);
}
}
if !spec.shape.is_empty() {
db.with_shape(&spec.shape);
@@ -314,9 +326,19 @@ pub(crate) fn extract_numpy_data(
"int64" => DatasetData::I64(flat.extract::<Vec<i64>>()?),
"int32" => DatasetData::I32(flat.extract::<Vec<i32>>()?),
"uint8" => DatasetData::U8(flat.extract::<Vec<u8>>()?),
// A 1-D complex array viewed as floats is its (re, im) parts in order.
"complex64" => {
let parts: Vec<f32> = flat.call_method1("view", ("<f4",))?.extract()?;
DatasetData::C64(parts.as_chunks::<2>().0.to_vec())
}
"complex128" => {
let parts: Vec<f64> = flat.call_method1("view", ("<f8",))?.extract()?;
DatasetData::C128(parts.as_chunks::<2>().0.to_vec())
}
_ => {
return Err(PyErr::new::<pyo3::exceptions::PyTypeError, _>(format!(
"unsupported numpy dtype: {dtype_str}; expected float64, float32, int64, int32, or uint8"
"unsupported numpy dtype: {dtype_str}; expected float64, float32, int64, int32, \
uint8, complex64 or complex128"
)));
}
};
@@ -247,6 +247,51 @@ def test_roundtrip_uint8(tmp_h5):
assert result.dtype == np.uint8
@pytest.mark.parametrize("dtype", [np.complex64, np.complex128])
def test_roundtrip_complex(tmp_h5, dtype):
"""Complex arrays are written as h5py writes them (a compound {r, i});
h5py and clawhdf5 both read them back as the same numpy complex dtype."""
import h5py
original = (np.arange(12).reshape(3, 4) * (1.5 - 0.25j)).astype(dtype)
with clawhdf5.File(tmp_h5, "w") as f:
f.create_dataset("z", data=original)
f.create_dataset("zc", data=original, chunks=(2, 2), compression="gzip")
with clawhdf5.File(tmp_h5, "r") as f:
for name in ["z", "zc"]:
result = f[name][:]
assert result.dtype == dtype
np.testing.assert_array_equal(result, original)
with h5py.File(tmp_h5, "r") as f:
for name in ["z", "zc"]:
assert f[name].dtype == dtype
assert f[name].id.get_type().get_class() == h5py.h5t.COMPOUND
np.testing.assert_array_equal(f[name][:], original)
def test_read_native_complex_from_h5py(tmp_h5):
"""HDF5 2.0's native complex type (class 11), written through h5py's
low-level API, reads as numpy complex."""
import h5py
from h5py import h5s, h5t
if not getattr(h5py.get_config(), "has_native_complex", False):
pytest.skip("h5py's libhdf5 predates 2.0")
original = np.array([1 + 2j, -3.5 + 0j, 0 - 1e-3j])
with h5py.File(tmp_h5, "w") as f:
for name, t, dt in [
(b"n64", h5t.COMPLEX_IEEE_F32LE, np.complex64),
(b"n128", h5t.COMPLEX_IEEE_F64LE, np.complex128),
]:
d = h5py.h5d.create(f.id, name, t, h5s.create_simple((3,)))
d.write(h5s.ALL, h5s.ALL, original.astype(dt), mtype=t)
with clawhdf5.File(tmp_h5, "r") as f:
for name, dt in [("n64", np.complex64), ("n128", np.complex128)]:
result = f[name][:]
assert result.dtype == dt
np.testing.assert_array_equal(result, original.astype(dt))
# ---------------------------------------------------------------------------
# Test: chunked + compressed datasets
# ---------------------------------------------------------------------------