Skip to content
Open
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
6 changes: 6 additions & 0 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@

## Unreleased

### Bug fixes

* Preserve multidimensional array shapes in `FixedScaleOffset` and preserve logical
coordinates when copying equally shaped arrays across different memory orders.
By {user}`shixi-li <shixi-li>`, {issue}`852`

### Maintenance

* **Migrate build system from setuptools/setup.py to meson-python.** This replaces the
Expand Down
11 changes: 8 additions & 3 deletions src/numcodecs/compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,18 @@ def ndarray_copy(src, dst) -> NDArrayLike:
src = ensure_ndarray_like(src)
dst = ensure_ndarray_like(dst)

# flatten source array
src = src.reshape(-1, order="A")

# ensure same data type
if dst.dtype != object:
src = src.view(dst.dtype)

# preserve logical coordinates when equally shaped arrays use different memory orders
if src.shape == dst.shape:
np.copyto(dst, src)
return dst

# flatten source array
src = src.reshape(-1, order="A")

# reshape source to match destination
if src.shape != dst.shape:
if dst.flags.f_contiguous:
Expand Down
10 changes: 6 additions & 4 deletions src/numcodecs/fixedscaleoffset.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,9 @@ def encode(self, buf):
# normalise input
arr = ensure_ndarray(buf).view(self.dtype)

# flatten to simplify implementation
arr = arr.reshape(-1, order='A')
# preserve the historical one-element shape for scalar inputs
if arr.ndim == 0:
arr = arr.reshape(-1)

# compute scale offset
enc = (arr - self.offset) * self.scale
Expand All @@ -100,8 +101,9 @@ def decode(self, buf, out=None):
# interpret buffer as numpy array
enc = ensure_ndarray(buf).view(self.astype)

# flatten to simplify implementation
enc = enc.reshape(-1, order='A')
# preserve the historical one-element shape for scalar inputs
if enc.ndim == 0:
enc = enc.reshape(-1)

# decode scale offset
dec = (enc / self.scale) + self.offset
Expand Down
19 changes: 18 additions & 1 deletion tests/test_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import numpy as np
import pytest

from numcodecs.compat import ensure_bytes, ensure_contiguous_ndarray, ensure_text
from numcodecs.compat import ensure_bytes, ensure_contiguous_ndarray, ensure_text, ndarray_copy


def test_ensure_text():
Expand Down Expand Up @@ -109,3 +109,20 @@ def test_ensure_contiguous_ndarray_max_buffer_size():
for buf in buffers:
with pytest.raises(ValueError):
ensure_contiguous_ndarray(buf, max_buffer_size=max_buffer_size)


@pytest.mark.parametrize(("source_order", "destination_order"), [("C", "F"), ("F", "C")])
@pytest.mark.parametrize("destination_type", ["ndarray", "memoryview"])
def test_ndarray_copy_same_shape_preserves_logical_coordinates(
source_order, destination_order, destination_type
):
shape = (2, 3, 4)
source = np.arange(np.prod(shape), dtype="<f4").reshape(shape, order=source_order)
destination_array = np.empty(shape, dtype="<f4", order=destination_order)
destination = (
memoryview(destination_array) if destination_type == "memoryview" else destination_array
)

ndarray_copy(source, destination)

np.testing.assert_array_equal(destination_array, source)
74 changes: 73 additions & 1 deletion tests/test_fixedscaleoffset.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import itertools
from typing import Literal

import numpy as np
import pytest
from numpy.testing import assert_array_equal
from numpy.testing import assert_array_almost_equal, assert_array_equal

from numcodecs.fixedscaleoffset import FixedScaleOffset
from tests.common import (
Expand Down Expand Up @@ -55,9 +56,80 @@ def test_encode(offset: float, scale: float, expected: list[int]):
assert np.dtype(astype) == actual.dtype


@pytest.mark.parametrize("order", ["C", "F"])
def test_preserves_dimensions(order: Literal["C", "F"]):
shape = (8, 4, 5)
arr = 200.0 + np.arange(np.prod(shape), dtype="<f4").reshape(shape, order=order) * 0.001
codec = FixedScaleOffset(
offset=200.0,
scale=1000.0,
dtype="<f4",
astype="<u2",
)

encoded = codec.encode(arr)
decoded = codec.decode(encoded)

assert encoded.shape == shape
assert decoded.shape == shape
assert_array_almost_equal(arr, decoded, decimal=3)

serialized = encoded.tobytes(order="A")
decoded_serialized = codec.decode(serialized)
assert decoded_serialized.shape == (arr.size,)
assert_array_almost_equal(arr.reshape(-1, order="A"), decoded_serialized, decimal=3)

out = np.empty_like(arr)
decoded_out = codec.decode(serialized, out=out)
assert decoded_out is out
assert_array_almost_equal(arr, decoded_out, decimal=3)

legacy_encoded = np.around((arr.reshape(-1, order="A") - codec.offset) * codec.scale).astype(
codec.astype,
copy=False,
)
assert encoded.tobytes(order="A") == legacy_encoded.tobytes(order="A")


@pytest.mark.parametrize(("input_order", "out_order"), [("C", "F"), ("F", "C")])
def test_decode_out_preserves_logical_coordinates(
input_order: Literal["C", "F"], out_order: Literal["C", "F"]
):
shape = (2, 3, 4)
arr = np.arange(np.prod(shape), dtype="<f4").reshape(shape, order=input_order)
codec = FixedScaleOffset(
offset=0,
scale=1,
dtype="<f4",
astype="<i4",
)
encoded = codec.encode(arr)
out = np.empty(shape, dtype="<f4", order=out_order)

decoded = codec.decode(encoded, out=out)

assert decoded is out
assert decoded.shape == shape
assert decoded.dtype == arr.dtype
assert_array_equal(decoded, arr)


def test_scalar_preserves_legacy_shape():
arr = np.array(3.5, dtype="<f4")
codec = FixedScaleOffset(offset=0, scale=10, dtype="<f4")

encoded = codec.encode(arr)
decoded = codec.decode(encoded)

assert encoded.shape == (1,)
assert decoded.shape == (1,)
assert_array_equal(decoded, arr.reshape(-1))


def test_config():
codec = FixedScaleOffset(dtype='<f8', astype='<i4', scale=10, offset=100)
check_config(codec)
assert "keep_dimensions" not in codec.get_config()


def test_repr():
Expand Down