From 7f11eff9ec5ef78e6e5df6db9b5882e03804aaf2 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Fri, 24 Jul 2026 14:18:06 +0200 Subject: [PATCH 1/3] add cast_value --- .../zarr/zarrjava/v3/codec/CodecBuilder.java | 14 + .../zarr/zarrjava/v3/codec/CodecRegistry.java | 1 + .../v3/codec/core/CastValueCodec.java | 232 ++++++++ .../v3/codec/core/CastValueConverter.java | 503 ++++++++++++++++++ .../dev/zarr/zarrjava/CastValueCodecTest.java | 188 +++++++ 5 files changed, 938 insertions(+) create mode 100644 src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueCodec.java create mode 100644 src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java create mode 100644 src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java diff --git a/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java b/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java index 5c3487ce..7c12041d 100644 --- a/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/CodecBuilder.java @@ -64,6 +64,20 @@ public CodecBuilder withTranspose(int[] order) { return this; } + public CodecBuilder withCastValue(CastValueCodec.Configuration configuration) { + codecs.add(new CastValueCodec(configuration)); + return this; + } + + public CodecBuilder withCastValue(DataType dataType) { + return withCastValue(new CastValueCodec.Configuration(dataType, null, null, null)); + } + + public CodecBuilder withCastValue(DataType dataType, CastValueCodec.Rounding rounding, + CastValueCodec.OutOfRange outOfRange) { + return withCastValue(new CastValueCodec.Configuration(dataType, rounding, outOfRange, null)); + } + public CodecBuilder withBytes(Endian endian) { if (dataType.getByteCount() <= 1) codecs.add(new BytesCodec()); diff --git a/src/main/java/dev/zarr/zarrjava/v3/codec/CodecRegistry.java b/src/main/java/dev/zarr/zarrjava/v3/codec/CodecRegistry.java index ad249e08..ada09755 100644 --- a/src/main/java/dev/zarr/zarrjava/v3/codec/CodecRegistry.java +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/CodecRegistry.java @@ -12,6 +12,7 @@ public class CodecRegistry { static { addType("transpose", TransposeCodec.class); + addType("cast_value", CastValueCodec.class); addType("bytes", BytesCodec.class); addType("blosc", BloscCodec.class); addType("gzip", GzipCodec.class); diff --git a/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueCodec.java b/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueCodec.java new file mode 100644 index 00000000..c543ad6c --- /dev/null +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueCodec.java @@ -0,0 +1,232 @@ +package dev.zarr.zarrjava.v3.codec.core; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; +import dev.zarr.zarrjava.ZarrException; +import dev.zarr.zarrjava.core.ArrayMetadata.CoreArrayMetadata; +import dev.zarr.zarrjava.core.codec.ArrayArrayCodec; +import dev.zarr.zarrjava.v3.DataType; +import dev.zarr.zarrjava.v3.codec.Codec; +import dev.zarr.zarrjava.v3.codec.core.CastValueConverter.ScalarEntry; +import ucar.ma2.Array; + +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.List; + +/** + * The {@code cast_value} codec converts (casts) the numeric value of every array element to a + * different data type. It is an {@code array -> array} codec: it changes the stored data type while + * leaving every other array property intact, and it does not reinterpret binary representations. + * + *

On encode the values are cast from the array data type to {@code configuration.data_type}; on + * decode the same procedure runs with the input and output data types swapped. The actual numeric + * conversion lives in {@link CastValueConverter}; this class only handles the Zarr codec integration: + * configuration parsing, propagating the new data type to downstream codecs, and casting the fill + * value. + * + *

Supported data types are the real-number types this library models: {@code int8/16/32/64}, + * {@code uint8/16/32/64}, {@code float32} and {@code float64}. Other data types from the codec + * specification (e.g. {@code float8_*}, {@code bfloat16}, {@code int2}) are not modelled here. + */ +public class CastValueCodec extends ArrayArrayCodec implements Codec { + + @JsonIgnore + @Nonnull + public final String name = "cast_value"; + @Nonnull + public final Configuration configuration; + + // Set up in resolveArrayMetadata (once, before any encode/decode call), then only read. + @JsonIgnore + private CastValueConverter converter; + @JsonIgnore + private List encodeEntries = new ArrayList<>(); + @JsonIgnore + private List decodeEntries = new ArrayList<>(); + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public CastValueCodec( + @Nonnull @JsonProperty(value = "configuration", required = true) Configuration configuration + ) { + this.configuration = configuration; + } + + // ===== Codec pipeline integration ======================================================== + + @Override + public Array encode(Array chunkArray) throws ZarrException { + return converter.castArray(chunkArray, arrayDataType(), configuration.dataType, encodeEntries); + } + + @Override + public Array decode(Array chunkArray) throws ZarrException { + return converter.castArray(chunkArray, configuration.dataType, arrayDataType(), decodeEntries); + } + + @Override + public long computeEncodedSize(long inputByteLength, CoreArrayMetadata arrayMetadata) + throws ZarrException { + long numElements = inputByteLength / arrayMetadata.dataType.getByteCount(); + return numElements * configuration.dataType.getByteCount(); + } + + /** + * Runs once when the codec pipeline is built. It validates the configuration, prepares the + * converter and scalar_map lookups, casts the fill value to the target type, and reports the new + * data type (and cast fill value) to the codecs downstream of this one. + */ + @Override + public CoreArrayMetadata resolveArrayMetadata() throws ZarrException { + super.resolveArrayMetadata(); + DataType inputType = arrayDataType(); + DataType outputType = configuration.dataType; + CastValueConverter.requireSupported(inputType); + CastValueConverter.requireSupported(outputType); + if (configuration.outOfRange == OutOfRange.WRAP && CastValueConverter.isFloatTarget(outputType)) { + throw new ZarrException( + "The cast_value 'out_of_range' value 'wrap' is only permitted for integral target data types."); + } + + this.converter = new CastValueConverter(configuration.rounding, configuration.outOfRange); + this.encodeEntries = converter.buildEntries( + configuration.scalarMap == null ? null : configuration.scalarMap.encode, inputType, outputType); + this.decodeEntries = converter.buildEntries( + configuration.scalarMap == null ? null : configuration.scalarMap.decode, outputType, inputType); + + Object srcFillValue = arrayMetadata.parsedFillValue; + Object castFillValue = converter.castFillValue(srcFillValue, inputType, outputType, encodeEntries); + if (srcFillValue != null + && !converter.fillValueRoundTrips(castFillValue, srcFillValue, outputType, inputType, decodeEntries)) { + throw new ZarrException( + "The cast_value fill value '" + srcFillValue + "' does not survive a round-trip cast."); + } + + return new CoreArrayMetadata( + arrayMetadata.shape, arrayMetadata.chunkShape, outputType, castFillValue); + } + + private DataType arrayDataType() throws ZarrException { + if (!(arrayMetadata.dataType instanceof DataType)) { + throw new ZarrException("The cast_value codec requires a Zarr v3 data type."); + } + return (DataType) arrayMetadata.dataType; + } + + // ===== Configuration ===================================================================== + + /** How values are rounded when the target data type cannot exactly represent a value. */ + public enum Rounding { + NEAREST_EVEN("nearest-even", RoundingMode.HALF_EVEN), + TOWARDS_ZERO("towards-zero", RoundingMode.DOWN), + TOWARDS_POSITIVE("towards-positive", RoundingMode.CEILING), + TOWARDS_NEGATIVE("towards-negative", RoundingMode.FLOOR), + NEAREST_AWAY("nearest-away", RoundingMode.HALF_UP); + + private final String value; + final RoundingMode mode; + + Rounding(String value, RoundingMode mode) { + this.value = value; + this.mode = mode; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static Rounding fromValue(String value) { + for (Rounding rounding : values()) { + if (rounding.value.equals(value)) { + return rounding; + } + } + throw new IllegalArgumentException("Unknown cast_value rounding: '" + value + "'."); + } + } + + /** How values outside the representable range of the target data type are handled. */ + public enum OutOfRange { + CLAMP("clamp"), + WRAP("wrap"); + + private final String value; + + OutOfRange(String value) { + this.value = value; + } + + @JsonValue + public String getValue() { + return value; + } + + @JsonCreator + public static OutOfRange fromValue(String value) { + for (OutOfRange outOfRange : values()) { + if (outOfRange.value.equals(value)) { + return outOfRange; + } + } + throw new IllegalArgumentException("Unknown cast_value out_of_range: '" + value + "'."); + } + } + + /** Explicit input-to-output scalar mappings, evaluated before any other casting rule. */ + public static final class ScalarMap { + @Nullable + @JsonProperty("encode") + public final Object[][] encode; + @Nullable + @JsonProperty("decode") + public final Object[][] decode; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public ScalarMap( + @Nullable @JsonProperty("encode") Object[][] encode, + @Nullable @JsonProperty("decode") Object[][] decode) { + this.encode = encode; + this.decode = decode; + } + } + + public static final class Configuration { + + @Nonnull + @JsonProperty("data_type") + public final DataType dataType; + + @Nonnull + @JsonProperty("rounding") + public final Rounding rounding; + + @Nullable + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonProperty("out_of_range") + public final OutOfRange outOfRange; + + @Nullable + @JsonInclude(JsonInclude.Include.NON_NULL) + @JsonProperty("scalar_map") + public final ScalarMap scalarMap; + + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public Configuration( + @Nonnull @JsonProperty(value = "data_type", required = true) DataType dataType, + @Nullable @JsonProperty("rounding") Rounding rounding, + @Nullable @JsonProperty("out_of_range") OutOfRange outOfRange, + @Nullable @JsonProperty("scalar_map") ScalarMap scalarMap) { + this.dataType = dataType; + this.rounding = rounding == null ? Rounding.NEAREST_EVEN : rounding; + this.outOfRange = outOfRange; + this.scalarMap = scalarMap; + } + } +} diff --git a/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java b/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java new file mode 100644 index 00000000..741b3005 --- /dev/null +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java @@ -0,0 +1,503 @@ +package dev.zarr.zarrjava.v3.codec.core; + +import dev.zarr.zarrjava.ZarrException; +import dev.zarr.zarrjava.v3.ArrayMetadata; +import dev.zarr.zarrjava.v3.DataType; +import dev.zarr.zarrjava.v3.codec.core.CastValueCodec.OutOfRange; +import dev.zarr.zarrjava.v3.codec.core.CastValueCodec.Rounding; +import ucar.ma2.Array; +import ucar.ma2.IndexIterator; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.List; + +/** + * The pure value-casting math behind the {@link CastValueCodec}. This class knows nothing about the + * Zarr codec pipeline; it only converts numeric values from one {@link DataType} to another following + * the {@code cast_value} procedure (scalar_map, then exact representability, then rounding and + * out-of-range handling). Keeping it separate from the codec makes the arithmetic easy to read and + * test in isolation. + * + *

Values are carried through an exact {@link BigDecimal} / {@link BigInteger} intermediate so that + * 64-bit integer exactness and directed rounding stay correct. + */ +final class CastValueConverter { + + private final Rounding rounding; + private final OutOfRange outOfRange; + + CastValueConverter(Rounding rounding, OutOfRange outOfRange) { + this.rounding = rounding; + this.outOfRange = outOfRange; + } + + // ===== Public API used by the codec ====================================================== + + /** + * Casts every element of {@code input} (a chunk in {@code inputType}) into a new array of + * {@code outputType}. + */ + Array castArray(Array input, DataType inputType, DataType outputType, List entries) + throws ZarrException { + int[] shape = input.getShape(); + Array output = Array.factory(outputType.getMA2DataType(), shape); + IndexIterator inputIt = input.getIndexIterator(); + IndexIterator outputIt = output.getIndexIterator(); + while (inputIt.hasNext()) { + Scalar scalar = readScalar(inputIt, inputType); + Object result = castScalar(scalar, outputType, entries); + writeScalar(outputIt, outputType, result); + } + return output; + } + + /** Casts a single (boxed) fill value into the boxed primitive of {@code outputType}. */ + Object castFillValue(Object fillValue, DataType inputType, DataType outputType, + List entries) throws ZarrException { + if (fillValue == null) { + return null; + } + Object result = castScalar(scalarFromBoxed(fillValue, inputType), outputType, entries); + return toBoxedForType(result, outputType); + } + + /** + * Returns whether a cast fill value decodes back to the original fill value. Used to validate that + * the fill value survives a round-trip cast (a {@code cast_value} specification requirement). + */ + boolean fillValueRoundTrips(Object castFillValue, Object originalFillValue, DataType outputType, + DataType inputType, List decodeEntries) + throws ZarrException { + Object decoded = castScalar(scalarFromBoxed(castFillValue, outputType), inputType, decodeEntries); + Object roundTripped = toBoxedForType(decoded, inputType); + return scalarsEqual(scalarFromBoxed(roundTripped, inputType), + scalarFromBoxed(originalFillValue, inputType)); + } + + /** + * Turns a {@code scalar_map} JSON table into a list of parsed lookup entries. The input scalars are + * parsed using {@code inputType}'s fill-value encoding, the output scalars using {@code outputType}'s. + */ + List buildEntries(Object[][] pairs, DataType inputType, DataType outputType) + throws ZarrException { + List entries = new ArrayList<>(); + if (pairs == null) { + return entries; + } + for (Object[] pair : pairs) { + if (pair.length != 2) { + throw new ZarrException("Each cast_value scalar_map entry must be a length-2 array."); + } + Object keyBoxed = ArrayMetadata.parseFillValue(pair[0], inputType); + Object outputBoxed = ArrayMetadata.parseFillValue(pair[1], outputType); + entries.add(new ScalarEntry(scalarFromBoxed(keyBoxed, inputType), outputBoxed)); + } + return entries; + } + + static void requireSupported(DataType type) throws ZarrException { + if (!isFloat(type) && !isSigned(type) && !isUnsigned(type)) { + throw new ZarrException( + "The cast_value codec does not support the data type '" + type.getValue() + + "'. Supported types are the integral and floating-point real-number types."); + } + } + + static boolean isFloatTarget(DataType type) { + return isFloat(type); + } + + // ===== The ordered casting procedure (one scalar) ======================================== + + private Object castScalar(Scalar scalar, DataType outputType, List entries) + throws ZarrException { + // 1. explicit scalar_map mapping (first match wins) + for (ScalarEntry entry : entries) { + if (scalarsEqual(entry.key, scalar)) { + return entry.output; + } + } + // 2. + 3. exact representability, then rounding and out-of-range handling + if (isFloat(outputType)) { + return castToFloat(scalar, outputType); + } + return castToInteger(scalar, outputType); + } + + private Object castToInteger(Scalar scalar, DataType outputType) throws ZarrException { + if (scalar.isNaN || scalar.isPositiveInfinity || scalar.isNegativeInfinity) { + throw new ZarrException( + "Cannot cast a NaN or infinite value to the integer data type '" + outputType.getValue() + + "' without an explicit scalar_map mapping."); + } + BigInteger min = integerMin(outputType); + BigInteger max = integerMax(outputType); + BigDecimal value = scalar.value; + + BigInteger candidate; + if (value.stripTrailingZeros().scale() <= 0) { + candidate = value.toBigInteger(); // already an integer value; no rounding needed + } else { + candidate = value.setScale(0, rounding.mode).toBigInteger(); + } + + if (candidate.compareTo(min) >= 0 && candidate.compareTo(max) <= 0) { + return candidate; + } + return handleOutOfRange(candidate, outputType, min, max); + } + + private Object handleOutOfRange(BigInteger candidate, DataType outputType, BigInteger min, + BigInteger max) throws ZarrException { + if (outOfRange == null) { + throw new ZarrException( + "The value '" + candidate + "' is out of range for the target data type '" + + outputType.getValue() + "' and no 'out_of_range' rule is configured."); + } + if (outOfRange == OutOfRange.CLAMP) { + return candidate.compareTo(min) < 0 ? min : max; + } + // WRAP: map to the value congruent modulo 2^N inside the representable range. + int bits = integerBits(outputType); + BigInteger modulus = BigInteger.ONE.shiftLeft(bits); + BigInteger wrapped = candidate.mod(modulus); // in [0, 2^N - 1] + if (isSigned(outputType) && wrapped.compareTo(max) > 0) { + wrapped = wrapped.subtract(modulus); + } + return wrapped; + } + + private Object castToFloat(Scalar scalar, DataType outputType) throws ZarrException { + boolean isFloat32 = outputType == DataType.FLOAT32; + if (scalar.isNaN) { + return isFloat32 ? (Object) Float.NaN : (Object) Double.NaN; + } + if (scalar.isPositiveInfinity) { + return isFloat32 ? (Object) Float.POSITIVE_INFINITY : (Object) Double.POSITIVE_INFINITY; + } + if (scalar.isNegativeInfinity) { + return isFloat32 ? (Object) Float.NEGATIVE_INFINITY : (Object) Double.NEGATIVE_INFINITY; + } + BigDecimal value = scalar.value; + if (value.signum() == 0) { + if (scalar.isNegativeZero) { + return isFloat32 ? (Object) (-0.0f) : (Object) (-0.0d); + } + return isFloat32 ? (Object) 0.0f : (Object) 0.0d; + } + + if (isFloat32) { + float nearest = value.floatValue(); + if (!Float.isInfinite(nearest) && new BigDecimal((double) nearest).compareTo(value) == 0) { + return nearest; // exactly representable + } + float result = roundToFloat(value); + if (Float.isInfinite(result)) { + return handleFloatOverflow(result, outputType, value); + } + return result; + } else { + double nearest = value.doubleValue(); + if (!Double.isInfinite(nearest) && new BigDecimal(nearest).compareTo(value) == 0) { + return nearest; // exactly representable + } + double result = roundToDouble(value); + if (Double.isInfinite(result)) { + return handleFloatOverflow(result, outputType, value); + } + return result; + } + } + + private Object handleFloatOverflow(double infinite, DataType outputType, BigDecimal value) + throws ZarrException { + if (outOfRange == OutOfRange.CLAMP) { + // Data types with +-Infinity map out-of-finite-range values to +-Infinity. + return outputType == DataType.FLOAT32 ? (Object) (float) infinite : (Object) infinite; + } + throw new ZarrException( + "The value '" + value.toPlainString() + "' exceeds the finite range of the target data type '" + + outputType.getValue() + "' and no 'clamp' out_of_range rule is configured."); + } + + /** Rounds an exact value to the nearest float allowed by the configured rounding mode. */ + private float roundToFloat(BigDecimal value) { + float nearest = value.floatValue(); + if (Float.isInfinite(nearest)) { + return nearest; + } + float down; + float up; + if (new BigDecimal((double) nearest).compareTo(value) > 0) { + up = nearest; + down = Math.nextDown(nearest); + } else { + down = nearest; + up = Math.nextUp(nearest); + } + switch (rounding) { + case TOWARDS_ZERO: + return value.signum() > 0 ? down : up; + case TOWARDS_POSITIVE: + return up; + case TOWARDS_NEGATIVE: + return down; + default: // NEAREST_EVEN, NEAREST_AWAY + return nearest; + } + } + + private double roundToDouble(BigDecimal value) { + double nearest = value.doubleValue(); + if (Double.isInfinite(nearest)) { + return nearest; + } + double down; + double up; + if (new BigDecimal(nearest).compareTo(value) > 0) { + up = nearest; + down = Math.nextDown(nearest); + } else { + down = nearest; + up = Math.nextUp(nearest); + } + switch (rounding) { + case TOWARDS_ZERO: + return value.signum() > 0 ? down : up; + case TOWARDS_POSITIVE: + return up; + case TOWARDS_NEGATIVE: + return down; + default: // NEAREST_EVEN, NEAREST_AWAY + return nearest; + } + } + + // ===== Reading / writing array elements ================================================== + + private static Scalar readScalar(IndexIterator it, DataType type) { + switch (type) { + case FLOAT32: + return Scalar.ofFloat(it.getFloatNext()); + case FLOAT64: + return Scalar.ofDouble(it.getDoubleNext()); + case INT8: + return Scalar.ofInteger(BigInteger.valueOf(it.getByteNext())); + case UINT8: + return Scalar.ofInteger(BigInteger.valueOf(it.getByteNext() & 0xFFL)); + case INT16: + return Scalar.ofInteger(BigInteger.valueOf(it.getShortNext())); + case UINT16: + return Scalar.ofInteger(BigInteger.valueOf(it.getShortNext() & 0xFFFFL)); + case INT32: + return Scalar.ofInteger(BigInteger.valueOf(it.getIntNext())); + case UINT32: + return Scalar.ofInteger(BigInteger.valueOf(it.getIntNext() & 0xFFFFFFFFL)); + case INT64: + return Scalar.ofInteger(BigInteger.valueOf(it.getLongNext())); + case UINT64: + return Scalar.ofInteger(new BigInteger(Long.toUnsignedString(it.getLongNext()))); + default: + throw new IllegalStateException("Unsupported cast_value data type: " + type); + } + } + + private static void writeScalar(IndexIterator it, DataType type, Object value) { + Number number = (Number) value; + switch (type) { + case FLOAT32: + it.setFloatNext(number.floatValue()); + break; + case FLOAT64: + it.setDoubleNext(number.doubleValue()); + break; + case INT8: + case UINT8: + it.setByteNext(number.byteValue()); + break; + case INT16: + case UINT16: + it.setShortNext(number.shortValue()); + break; + case INT32: + case UINT32: + it.setIntNext(number.intValue()); + break; + case INT64: + case UINT64: + it.setLongNext(number.longValue()); + break; + default: + throw new IllegalStateException("Unsupported cast_value data type: " + type); + } + } + + /** Reconstructs a {@link Scalar} from a boxed value (a fill value or a scalar_map key). */ + private static Scalar scalarFromBoxed(Object value, DataType type) { + Number number = (Number) value; + switch (type) { + case FLOAT32: + return Scalar.ofFloat(number.floatValue()); + case FLOAT64: + return Scalar.ofDouble(number.doubleValue()); + case INT8: + return Scalar.ofInteger(BigInteger.valueOf(number.byteValue())); + case UINT8: + return Scalar.ofInteger(BigInteger.valueOf(number.longValue() & 0xFFL)); + case INT16: + return Scalar.ofInteger(BigInteger.valueOf(number.shortValue())); + case UINT16: + return Scalar.ofInteger(BigInteger.valueOf(number.longValue() & 0xFFFFL)); + case INT32: + return Scalar.ofInteger(BigInteger.valueOf(number.intValue())); + case UINT32: + return Scalar.ofInteger(BigInteger.valueOf(number.longValue() & 0xFFFFFFFFL)); + case INT64: + return Scalar.ofInteger(BigInteger.valueOf(number.longValue())); + case UINT64: + return Scalar.ofInteger(new BigInteger(Long.toUnsignedString(number.longValue()))); + default: + throw new IllegalStateException("Unsupported cast_value data type: " + type); + } + } + + /** + * Converts a cast result (a {@link BigInteger} for integral targets, or a boxed float/double) into + * the exact boxed primitive type expected for a fill value of {@code type}. + */ + private static Object toBoxedForType(Object value, DataType type) { + Number number = (Number) value; + switch (type) { + case FLOAT32: + return number.floatValue(); + case FLOAT64: + return number.doubleValue(); + case INT8: + case UINT8: + return number.byteValue(); + case INT16: + case UINT16: + return number.shortValue(); + case INT32: + case UINT32: + return number.intValue(); + case INT64: + case UINT64: + return number.longValue(); + default: + throw new IllegalStateException("Unsupported cast_value data type: " + type); + } + } + + // ===== Value model & comparison ========================================================== + + private static boolean scalarsEqual(Scalar a, Scalar b) { + if (a.isNaN || b.isNaN) { + return a.isNaN && b.isNaN; + } + if (a.isPositiveInfinity || b.isPositiveInfinity) { + return a.isPositiveInfinity && b.isPositiveInfinity; + } + if (a.isNegativeInfinity || b.isNegativeInfinity) { + return a.isNegativeInfinity && b.isNegativeInfinity; + } + return a.value.compareTo(b.value) == 0; + } + + /** An exact numeric value, or one of the special IEEE-754 values (NaN, +-Infinity). */ + private static final class Scalar { + final boolean isNaN; + final boolean isPositiveInfinity; + final boolean isNegativeInfinity; + final boolean isNegativeZero; + final BigDecimal value; // null for the special values above + + private Scalar(boolean isNaN, boolean isPositiveInfinity, boolean isNegativeInfinity, + boolean isNegativeZero, BigDecimal value) { + this.isNaN = isNaN; + this.isPositiveInfinity = isPositiveInfinity; + this.isNegativeInfinity = isNegativeInfinity; + this.isNegativeZero = isNegativeZero; + this.value = value; + } + + static Scalar ofDouble(double d) { + if (Double.isNaN(d)) { + return new Scalar(true, false, false, false, null); + } + if (d == Double.POSITIVE_INFINITY) { + return new Scalar(false, true, false, false, null); + } + if (d == Double.NEGATIVE_INFINITY) { + return new Scalar(false, false, true, false, null); + } + boolean negZero = (d == 0.0d) && (Double.doubleToRawLongBits(d) != 0L); + return new Scalar(false, false, false, negZero, new BigDecimal(d)); + } + + static Scalar ofFloat(float f) { + if (Float.isNaN(f)) { + return new Scalar(true, false, false, false, null); + } + if (f == Float.POSITIVE_INFINITY) { + return new Scalar(false, true, false, false, null); + } + if (f == Float.NEGATIVE_INFINITY) { + return new Scalar(false, false, true, false, null); + } + boolean negZero = (f == 0.0f) && (Float.floatToRawIntBits(f) != 0); + return new Scalar(false, false, false, negZero, new BigDecimal((double) f)); + } + + static Scalar ofInteger(BigInteger i) { + return new Scalar(false, false, false, false, new BigDecimal(i)); + } + } + + /** A single {@code scalar_map} lookup entry: match {@link #key}, emit {@link #output}. */ + static final class ScalarEntry { + final Scalar key; + final Object output; // boxed primitive of the output data type + + private ScalarEntry(Scalar key, Object output) { + this.key = key; + this.output = output; + } + } + + // ===== Data type facts =================================================================== + + private static boolean isFloat(DataType type) { + return type == DataType.FLOAT32 || type == DataType.FLOAT64; + } + + private static boolean isSigned(DataType type) { + return type == DataType.INT8 || type == DataType.INT16 || type == DataType.INT32 + || type == DataType.INT64; + } + + private static boolean isUnsigned(DataType type) { + return type == DataType.UINT8 || type == DataType.UINT16 || type == DataType.UINT32 + || type == DataType.UINT64; + } + + private static int integerBits(DataType type) { + return type.getByteCount() * 8; + } + + private static BigInteger integerMin(DataType type) { + if (isUnsigned(type)) { + return BigInteger.ZERO; + } + return BigInteger.ONE.shiftLeft(integerBits(type) - 1).negate(); + } + + private static BigInteger integerMax(DataType type) { + if (isUnsigned(type)) { + return BigInteger.ONE.shiftLeft(integerBits(type)).subtract(BigInteger.ONE); + } + return BigInteger.ONE.shiftLeft(integerBits(type) - 1).subtract(BigInteger.ONE); + } +} diff --git a/src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java b/src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java new file mode 100644 index 00000000..f131d711 --- /dev/null +++ b/src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java @@ -0,0 +1,188 @@ +package dev.zarr.zarrjava; + +import dev.zarr.zarrjava.store.FilesystemStore; +import dev.zarr.zarrjava.store.StoreHandle; +import dev.zarr.zarrjava.v3.Array; +import dev.zarr.zarrjava.v3.ArrayMetadata; +import dev.zarr.zarrjava.v3.DataType; +import dev.zarr.zarrjava.v3.codec.core.CastValueCodec; +import dev.zarr.zarrjava.v3.codec.core.CastValueCodec.OutOfRange; +import dev.zarr.zarrjava.v3.codec.core.CastValueCodec.Rounding; +import dev.zarr.zarrjava.v3.codec.core.CastValueCodec.ScalarMap; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; + +import static org.junit.Assert.assertThrows; + +public class CastValueCodecTest extends ZarrTest { + + private StoreHandle handle(String name) { + return new FilesystemStore(TESTOUTPUT).resolve("cast_value", name); + } + + private double[] roundTrip(String name, DataType target, CastValueCodec.Configuration config, + double[] data) throws ZarrException, IOException { + return roundTrip(name, target, config, data, 0); + } + + private double[] roundTrip(String name, DataType target, CastValueCodec.Configuration config, + double[] data, Object fillValue) throws ZarrException, IOException { + StoreHandle storeHandle = handle(name); + ArrayMetadata metadata = Array.metadataBuilder() + .withShape(data.length) + .withDataType(DataType.FLOAT64) + .withChunkShape(data.length) + .withFillValue(fillValue) + .withCodecs(c -> c.withCastValue(config).withBytes()) + .build(); + Array writeArray = Array.create(storeHandle, metadata); + writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.DOUBLE, new int[]{data.length}, data)); + + Array readArray = Array.open(storeHandle); + ucar.ma2.Array result = readArray.read(); + return (double[]) result.get1DJavaArray(ucar.ma2.DataType.DOUBLE); + } + + private static CastValueCodec.Configuration config(DataType target, Rounding rounding, + OutOfRange outOfRange, ScalarMap scalarMap) { + return new CastValueCodec.Configuration(target, rounding, outOfRange, scalarMap); + } + + @Test + public void testExactRoundTrip() throws Exception { + double[] data = {0, 1, 2, -3, 127, -128}; + double[] result = roundTrip("exact_int8", DataType.INT8, + config(DataType.INT8, null, null, null), data); + Assertions.assertArrayEquals(data, result); + } + + @Test + public void testClamp() throws Exception { + double[] data = {200, -200, 50}; + double[] result = roundTrip("clamp_int8", DataType.INT8, + config(DataType.INT8, null, OutOfRange.CLAMP, null), data); + Assertions.assertArrayEquals(new double[]{127, -128, 50}, result); + } + + @Test + public void testWrap() throws Exception { + double[] data = {130, -129, 261}; + double[] result = roundTrip("wrap_int8", DataType.INT8, + config(DataType.INT8, null, OutOfRange.WRAP, null), data); + Assertions.assertArrayEquals(new double[]{-126, 127, 5}, result); + } + + @Test + public void testRoundingNearestEven() throws Exception { + double[] data = {2.5, 3.5, -2.5, 0.5}; + double[] result = roundTrip("round_even", DataType.INT32, + config(DataType.INT32, Rounding.NEAREST_EVEN, null, null), data); + Assertions.assertArrayEquals(new double[]{2, 4, -2, 0}, result); + } + + @Test + public void testRoundingTowardsZero() throws Exception { + double[] data = {2.5, 3.5, -2.5, -3.9}; + double[] result = roundTrip("round_zero", DataType.INT32, + config(DataType.INT32, Rounding.TOWARDS_ZERO, null, null), data); + Assertions.assertArrayEquals(new double[]{2, 3, -2, -3}, result); + } + + @Test + public void testRoundingFloorAndCeil() throws Exception { + double[] data = {2.1, -2.1}; + double[] floor = roundTrip("round_floor", DataType.INT32, + config(DataType.INT32, Rounding.TOWARDS_NEGATIVE, null, null), data); + Assertions.assertArrayEquals(new double[]{2, -3}, floor); + double[] ceil = roundTrip("round_ceil", DataType.INT32, + config(DataType.INT32, Rounding.TOWARDS_POSITIVE, null, null), data); + Assertions.assertArrayEquals(new double[]{3, -2}, ceil); + } + + @Test + public void testNumpyCompatibilityExample() throws Exception { + // Mirrors the specification's NumPy-compatibility example: float64 -> uint8, round towards + // zero, wrap out-of-range values, and map NaN / +-Infinity to 0. + ScalarMap scalarMap = new ScalarMap( + new Object[][]{{"NaN", 0}, {"+Infinity", 0}, {"-Infinity", 0}}, null); + double[] data = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, 3.9, 300, -1}; + double[] result = roundTrip("numpy", DataType.UINT8, + config(DataType.UINT8, Rounding.TOWARDS_ZERO, OutOfRange.WRAP, scalarMap), data); + Assertions.assertArrayEquals(new double[]{0, 0, 0, 3, 44, 255}, result); + } + + @Test + public void testScalarMapDecode() throws Exception { + // Round-trip a NaN fill/value: encode NaN -> 0 and decode 0 -> NaN. + ScalarMap scalarMap = new ScalarMap( + new Object[][]{{"NaN", 0}}, + new Object[][]{{0, "NaN"}}); + double[] data = {Double.NaN, 5}; + double[] result = roundTrip("scalarmap_decode", DataType.UINT8, + config(DataType.UINT8, Rounding.TOWARDS_ZERO, OutOfRange.WRAP, scalarMap), data, 7); + Assertions.assertTrue(Double.isNaN(result[0])); + Assertions.assertEquals(5.0, result[1]); + } + + @Test + public void testOutOfRangeWithoutRuleFailsOnWrite() { + double[] data = {200}; + // The write path runs on a parallel stream, so the codec's ZarrException surfaces wrapped. + assertCastError(() -> roundTrip("oor_fail", DataType.INT8, + config(DataType.INT8, null, null, null), data)); + } + + @Test + public void testNaNToIntegerWithoutMappingFailsOnWrite() { + double[] data = {Double.NaN}; + assertCastError(() -> roundTrip("nan_fail", DataType.INT8, + config(DataType.INT8, null, OutOfRange.CLAMP, null), data)); + } + + private static void assertCastError(org.junit.function.ThrowingRunnable runnable) { + Throwable thrown = assertThrows(Throwable.class, runnable); + for (Throwable t = thrown; t != null; t = t.getCause()) { + if (t instanceof ZarrException) { + return; + } + } + Assertions.fail("Expected a ZarrException in the cause chain, got: " + thrown); + } + + @Test + public void testFillValueRoundTripValidation() { + // Fill value NaN encodes to 0 but decodes back to 0.0 (no decode mapping), so it cannot + // survive a round trip -> array creation must fail. + StoreHandle storeHandle = handle("fill_roundtrip_fail"); + ScalarMap scalarMap = new ScalarMap(new Object[][]{{"NaN", 0}}, null); + assertThrows(ZarrException.class, () -> { + ArrayMetadata metadata = Array.metadataBuilder() + .withShape(4) + .withDataType(DataType.FLOAT64) + .withChunkShape(4) + .withFillValue("NaN") + .withCodecs(c -> c.withCastValue( + config(DataType.UINT8, Rounding.TOWARDS_ZERO, OutOfRange.WRAP, scalarMap)).withBytes()) + .build(); + Array.create(storeHandle, metadata); + }); + } + + @Test + public void testFloat64ToFloat32RoundTrip() throws Exception { + // Values that are exactly representable in float32 survive the round trip unchanged. + double[] data = {0, 0.5, -0.25, 1024, -2048}; + double[] result = roundTrip("float32", DataType.FLOAT32, + config(DataType.FLOAT32, null, null, null), data); + Assertions.assertArrayEquals(data, result); + } + + @Test + public void testUnsupportedBoolTargetFails() { + double[] data = {0, 1}; + assertThrows(ZarrException.class, () -> roundTrip("bool_fail", DataType.BOOL, + config(DataType.BOOL, null, null, null), data)); + } +} From 77af95c9c1a1209f3311bc78c41cb69d2bc54da1 Mon Sep 17 00:00:00 2001 From: Norman Rzepka Date: Thu, 30 Jul 2026 14:04:52 +0200 Subject: [PATCH 2/3] Fix nearest-away rounding for float targets and add fast paths to cast_value Fix #1 (correctness): CastValueConverter.roundToFloat/roundToDouble treated nearest-away as nearest-even for floating-point targets, so exact ties rounded to even instead of away from zero (spec violation for float->float narrowing). Both now round ties away from zero and fall back to nearest for non-ties. Fix #2 (performance): castArray now dispatches common casts (identity, float<->float, integer->integer) to primitive-arithmetic paths, avoiding the per-element Scalar/BigDecimal/BigInteger allocation of the exact path. Casts with scalar_map, uint64, or float directed-rounding fall back to the exact path, which remains the correctness reference. Adds tests for the float nearest-away tie and the integer/identity fast paths. Co-Authored-By: Claude Opus 4.8 --- .../v3/codec/core/CastValueConverter.java | 209 +++++++++++++++++- .../dev/zarr/zarrjava/CastValueCodecTest.java | 68 ++++++ 2 files changed, 274 insertions(+), 3 deletions(-) diff --git a/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java b/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java index 741b3005..94a282cd 100644 --- a/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java @@ -45,6 +45,12 @@ Array castArray(Array input, DataType inputType, DataType outputType, Listfloat, integer->integer) are handled with primitive + // arithmetic to avoid the per-element BigDecimal/BigInteger allocation of the exact path. + if (fastPathApplicable(inputType, outputType, !entries.isEmpty())) { + castArrayFast(inputIt, outputIt, inputType, outputType); + return output; + } while (inputIt.hasNext()) { Scalar scalar = readScalar(inputIt, inputType); Object result = castScalar(scalar, outputType, entries); @@ -53,6 +59,93 @@ Array castArray(Array input, DataType inputType, DataType outputType, List float64 is exact; narrowing float64 -> float32 with round-to-nearest + // -even matches a primitive Java narrowing cast. Directed rounding uses the exact path. + return outputType == DataType.FLOAT64 || rounding == Rounding.NEAREST_EVEN; + } + if (isInteger(inputType) && isInteger(outputType)) { + // uint64 does not fit a signed long, so leave those casts on the exact BigInteger path. + return inputType != DataType.UINT64 && outputType != DataType.UINT64; + } + return false; + } + + /** Primitive-arithmetic implementation of the casts accepted by {@link #fastPathApplicable}. */ + private void castArrayFast(IndexIterator inputIt, IndexIterator outputIt, DataType inputType, + DataType outputType) throws ZarrException { + if (inputType == outputType) { + while (inputIt.hasNext()) { + copyElement(inputIt, outputIt, inputType); + } + } else if (isFloat(inputType)) { + while (inputIt.hasNext()) { + castFloatToFloatFast(inputIt, outputIt, inputType, outputType); + } + } else { + while (inputIt.hasNext()) { + castIntToIntFast(inputIt, outputIt, inputType, outputType); + } + } + } + + private void castFloatToFloatFast(IndexIterator inputIt, IndexIterator outputIt, DataType inputType, + DataType outputType) throws ZarrException { + double value = inputType == DataType.FLOAT32 ? inputIt.getFloatNext() : inputIt.getDoubleNext(); + if (outputType == DataType.FLOAT64) { + outputIt.setDoubleNext(value); // widening / identity: exact, sign and NaN preserved + return; + } + float result = (float) value; // round-to-nearest-even, preserving NaN, +-Infinity and -0.0f + if (Float.isInfinite(result) && !Double.isInfinite(value)) { + // A finite value overflowed the float32 range. + if (outOfRange != OutOfRange.CLAMP) { + throw floatOverflowException(new BigDecimal(value), outputType); + } + } + outputIt.setFloatNext(result); + } + + private void castIntToIntFast(IndexIterator inputIt, IndexIterator outputIt, DataType inputType, + DataType outputType) throws ZarrException { + long value = readLong(inputIt, inputType); + long min = integerMinLong(outputType); + long max = integerMaxLong(outputType); + long result; + if (value >= min && value <= max) { + result = value; + } else if (outOfRange == null) { + throw new ZarrException( + "The value '" + value + "' is out of range for the target data type '" + + outputType.getValue() + "' and no 'out_of_range' rule is configured."); + } else if (outOfRange == OutOfRange.CLAMP) { + result = value < min ? min : max; + } else { + // WRAP modulo 2^N. Only reached for out-of-range values, which never happens for a 64-bit + // output, so the shift width here is always < 64. + int bits = integerBits(outputType); + long modulus = 1L << bits; + long wrapped = ((value % modulus) + modulus) % modulus; // in [0, 2^N - 1] + if (isSigned(outputType) && wrapped > max) { + wrapped -= modulus; + } + result = wrapped; + } + writeLong(outputIt, outputType, result); + } + /** Casts a single (boxed) fill value into the boxed primitive of {@code outputType}. */ Object castFillValue(Object fillValue, DataType inputType, DataType outputType, List entries) throws ZarrException { @@ -217,7 +310,11 @@ private Object handleFloatOverflow(double infinite, DataType outputType, BigDeci // Data types with +-Infinity map out-of-finite-range values to +-Infinity. return outputType == DataType.FLOAT32 ? (Object) (float) infinite : (Object) infinite; } - throw new ZarrException( + throw floatOverflowException(value, outputType); + } + + private static ZarrException floatOverflowException(BigDecimal value, DataType outputType) { + return new ZarrException( "The value '" + value.toPlainString() + "' exceeds the finite range of the target data type '" + outputType.getValue() + "' and no 'clamp' out_of_range rule is configured."); } @@ -244,7 +341,14 @@ private float roundToFloat(BigDecimal value) { return up; case TOWARDS_NEGATIVE: return down; - default: // NEAREST_EVEN, NEAREST_AWAY + case NEAREST_AWAY: + // Only differs from nearest-even at an exact tie, where ties go away from zero. + if (value.subtract(new BigDecimal((double) down)) + .compareTo(new BigDecimal((double) up).subtract(value)) == 0) { + return value.signum() > 0 ? up : down; + } + return nearest; + default: // NEAREST_EVEN return nearest; } } @@ -270,7 +374,14 @@ private double roundToDouble(BigDecimal value) { return up; case TOWARDS_NEGATIVE: return down; - default: // NEAREST_EVEN, NEAREST_AWAY + case NEAREST_AWAY: + // Only differs from nearest-even at an exact tie, where ties go away from zero. + if (value.subtract(new BigDecimal(down)) + .compareTo(new BigDecimal(up).subtract(value)) == 0) { + return value.signum() > 0 ? up : down; + } + return nearest; + default: // NEAREST_EVEN return nearest; } } @@ -334,6 +445,81 @@ private static void writeScalar(IndexIterator it, DataType type, Object value) { } } + /** Copies one element unchanged (used for identity casts in the fast path). */ + private static void copyElement(IndexIterator inputIt, IndexIterator outputIt, DataType type) { + switch (type) { + case FLOAT32: + outputIt.setFloatNext(inputIt.getFloatNext()); + break; + case FLOAT64: + outputIt.setDoubleNext(inputIt.getDoubleNext()); + break; + case INT8: + case UINT8: + outputIt.setByteNext(inputIt.getByteNext()); + break; + case INT16: + case UINT16: + outputIt.setShortNext(inputIt.getShortNext()); + break; + case INT32: + case UINT32: + outputIt.setIntNext(inputIt.getIntNext()); + break; + case INT64: + case UINT64: + outputIt.setLongNext(inputIt.getLongNext()); + break; + default: + throw new IllegalStateException("Unsupported cast_value data type: " + type); + } + } + + /** Reads the next element as its exact signed value in a {@code long}. Not valid for uint64. */ + private static long readLong(IndexIterator it, DataType type) { + switch (type) { + case INT8: + return it.getByteNext(); + case UINT8: + return it.getByteNext() & 0xFFL; + case INT16: + return it.getShortNext(); + case UINT16: + return it.getShortNext() & 0xFFFFL; + case INT32: + return it.getIntNext(); + case UINT32: + return it.getIntNext() & 0xFFFFFFFFL; + case INT64: + return it.getLongNext(); + default: + throw new IllegalStateException("readLong is not valid for cast_value data type: " + type); + } + } + + /** Writes {@code value} into the next element, truncating to the type's width. Not valid for uint64. */ + private static void writeLong(IndexIterator it, DataType type, long value) { + switch (type) { + case INT8: + case UINT8: + it.setByteNext((byte) value); + break; + case INT16: + case UINT16: + it.setShortNext((short) value); + break; + case INT32: + case UINT32: + it.setIntNext((int) value); + break; + case INT64: + it.setLongNext(value); + break; + default: + throw new IllegalStateException("writeLong is not valid for cast_value data type: " + type); + } + } + /** Reconstructs a {@link Scalar} from a boxed value (a fill value or a scalar_map key). */ private static Scalar scalarFromBoxed(Object value, DataType type) { Number number = (Number) value; @@ -483,10 +669,27 @@ private static boolean isUnsigned(DataType type) { || type == DataType.UINT64; } + private static boolean isInteger(DataType type) { + return isSigned(type) || isUnsigned(type); + } + private static int integerBits(DataType type) { return type.getByteCount() * 8; } + /** The minimum representable value as a {@code long}. Not valid for uint64. */ + private static long integerMinLong(DataType type) { + return isUnsigned(type) ? 0L : -(1L << (integerBits(type) - 1)); + } + + /** The maximum representable value as a {@code long}. Not valid for uint64. */ + private static long integerMaxLong(DataType type) { + if (isUnsigned(type)) { + return (1L << integerBits(type)) - 1; + } + return (1L << (integerBits(type) - 1)) - 1; + } + private static BigInteger integerMin(DataType type) { if (isUnsigned(type)) { return BigInteger.ZERO; diff --git a/src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java b/src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java index f131d711..3550689a 100644 --- a/src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java +++ b/src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java @@ -50,6 +50,24 @@ private static CastValueCodec.Configuration config(DataType target, Rounding rou return new CastValueCodec.Configuration(target, rounding, outOfRange, scalarMap); } + /** Round-trips an int32-typed array, exercising the integer/identity fast paths. */ + private int[] roundTripInt(String name, CastValueCodec.Configuration config, int[] data) + throws ZarrException, IOException { + StoreHandle storeHandle = handle(name); + ArrayMetadata metadata = Array.metadataBuilder() + .withShape(data.length) + .withDataType(DataType.INT32) + .withChunkShape(data.length) + .withCodecs(c -> c.withCastValue(config).withBytes()) + .build(); + Array writeArray = Array.create(storeHandle, metadata); + writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.INT, new int[]{data.length}, data)); + + Array readArray = Array.open(storeHandle); + ucar.ma2.Array result = readArray.read(); + return (int[]) result.get1DJavaArray(ucar.ma2.DataType.INT); + } + @Test public void testExactRoundTrip() throws Exception { double[] data = {0, 1, 2, -3, 127, -128}; @@ -179,6 +197,56 @@ public void testFloat64ToFloat32RoundTrip() throws Exception { Assertions.assertArrayEquals(data, result); } + @Test + public void testRoundingNearestAwayFloatTie() throws Exception { + // 1 + 2^-24 is the exact midpoint between float32 1.0 and Math.nextUp(1.0f). nearest-away must + // round the tie away from zero, whereas nearest-even rounds to 1.0 (even mantissa). + double tie = 1.0 + 0x1p-24; + double[] data = {tie, -tie}; + double[] away = roundTrip("round_nearest_away", DataType.FLOAT32, + config(DataType.FLOAT32, Rounding.NEAREST_AWAY, null, null), data); + Assertions.assertArrayEquals(new double[]{Math.nextUp(1.0f), -Math.nextUp(1.0f)}, away); + + double[] even = roundTrip("round_nearest_even_float", DataType.FLOAT32, + config(DataType.FLOAT32, Rounding.NEAREST_EVEN, null, null), data); + Assertions.assertArrayEquals(new double[]{1.0, -1.0}, even); + } + + @Test + public void testIntToIntFastPathSigned() throws Exception { + int[] data = {0, 1, -1, 127, -128, 200, -200, 130}; + int[] clamp = roundTripInt("i2i_clamp_int8", + config(DataType.INT8, null, OutOfRange.CLAMP, null), data); + Assertions.assertArrayEquals(new int[]{0, 1, -1, 127, -128, 127, -128, 127}, clamp); + + int[] wrap = roundTripInt("i2i_wrap_int8", + config(DataType.INT8, null, OutOfRange.WRAP, null), data); + Assertions.assertArrayEquals(new int[]{0, 1, -1, 127, -128, -56, 56, -126}, wrap); + } + + @Test + public void testIntToIntFastPathUnsigned() throws Exception { + // Encode int32 -> uint8 (wrap), then decode uint8 -> int32 yields the stored unsigned byte. + int[] wrap = roundTripInt("i2i_wrap_uint8", + config(DataType.UINT8, null, OutOfRange.WRAP, null), new int[]{0, 255, 256, -1, 300}); + Assertions.assertArrayEquals(new int[]{0, 255, 0, 255, 44}, wrap); + } + + @Test + public void testIdentityFastPath() throws Exception { + int[] data = {0, 1, -1, Integer.MAX_VALUE, Integer.MIN_VALUE, 12345}; + int[] result = roundTripInt("identity_int32", + config(DataType.INT32, null, null, null), data); + Assertions.assertArrayEquals(data, result); + } + + @Test + public void testIntToIntFastPathOutOfRangeWithoutRuleFails() { + // int32 200 -> int8 with no out_of_range rule must fail on the fast path, too. + assertCastError(() -> roundTripInt("i2i_oor_fail", + config(DataType.INT8, null, null, null), new int[]{200})); + } + @Test public void testUnsupportedBoolTargetFails() { double[] data = {0, 1}; From 1fbdcd275dc1cf345070a21fba90f8d1a4786d8d Mon Sep 17 00:00:00 2001 From: Norman Rzepka Date: Thu, 30 Jul 2026 14:07:53 +0200 Subject: [PATCH 3/3] Address cast_value review nits - Drop the unused DataType target parameter from the roundTrip test helper (the target already lives in the Configuration). - Reference parseFillValue via its defining class dev.zarr.zarrjava.core.ArrayMetadata instead of the v3 subclass. - Add uint64-above-Long.MAX_VALUE coverage, exercising the exact unsigned cast path (large fill value) and the identity fast path (array data). Co-Authored-By: Claude Opus 4.8 --- .../v3/codec/core/CastValueConverter.java | 2 +- .../dev/zarr/zarrjava/CastValueCodecTest.java | 59 +++++++++++++------ 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java b/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java index 94a282cd..41327a4f 100644 --- a/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java +++ b/src/main/java/dev/zarr/zarrjava/v3/codec/core/CastValueConverter.java @@ -1,7 +1,7 @@ package dev.zarr.zarrjava.v3.codec.core; import dev.zarr.zarrjava.ZarrException; -import dev.zarr.zarrjava.v3.ArrayMetadata; +import dev.zarr.zarrjava.core.ArrayMetadata; import dev.zarr.zarrjava.v3.DataType; import dev.zarr.zarrjava.v3.codec.core.CastValueCodec.OutOfRange; import dev.zarr.zarrjava.v3.codec.core.CastValueCodec.Rounding; diff --git a/src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java b/src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java index 3550689a..c6688815 100644 --- a/src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java +++ b/src/test/java/dev/zarr/zarrjava/CastValueCodecTest.java @@ -22,12 +22,12 @@ private StoreHandle handle(String name) { return new FilesystemStore(TESTOUTPUT).resolve("cast_value", name); } - private double[] roundTrip(String name, DataType target, CastValueCodec.Configuration config, + private double[] roundTrip(String name, CastValueCodec.Configuration config, double[] data) throws ZarrException, IOException { - return roundTrip(name, target, config, data, 0); + return roundTrip(name, config, data, 0); } - private double[] roundTrip(String name, DataType target, CastValueCodec.Configuration config, + private double[] roundTrip(String name, CastValueCodec.Configuration config, double[] data, Object fillValue) throws ZarrException, IOException { StoreHandle storeHandle = handle(name); ArrayMetadata metadata = Array.metadataBuilder() @@ -71,7 +71,7 @@ private int[] roundTripInt(String name, CastValueCodec.Configuration config, int @Test public void testExactRoundTrip() throws Exception { double[] data = {0, 1, 2, -3, 127, -128}; - double[] result = roundTrip("exact_int8", DataType.INT8, + double[] result = roundTrip("exact_int8", config(DataType.INT8, null, null, null), data); Assertions.assertArrayEquals(data, result); } @@ -79,7 +79,7 @@ public void testExactRoundTrip() throws Exception { @Test public void testClamp() throws Exception { double[] data = {200, -200, 50}; - double[] result = roundTrip("clamp_int8", DataType.INT8, + double[] result = roundTrip("clamp_int8", config(DataType.INT8, null, OutOfRange.CLAMP, null), data); Assertions.assertArrayEquals(new double[]{127, -128, 50}, result); } @@ -87,7 +87,7 @@ public void testClamp() throws Exception { @Test public void testWrap() throws Exception { double[] data = {130, -129, 261}; - double[] result = roundTrip("wrap_int8", DataType.INT8, + double[] result = roundTrip("wrap_int8", config(DataType.INT8, null, OutOfRange.WRAP, null), data); Assertions.assertArrayEquals(new double[]{-126, 127, 5}, result); } @@ -95,7 +95,7 @@ public void testWrap() throws Exception { @Test public void testRoundingNearestEven() throws Exception { double[] data = {2.5, 3.5, -2.5, 0.5}; - double[] result = roundTrip("round_even", DataType.INT32, + double[] result = roundTrip("round_even", config(DataType.INT32, Rounding.NEAREST_EVEN, null, null), data); Assertions.assertArrayEquals(new double[]{2, 4, -2, 0}, result); } @@ -103,7 +103,7 @@ public void testRoundingNearestEven() throws Exception { @Test public void testRoundingTowardsZero() throws Exception { double[] data = {2.5, 3.5, -2.5, -3.9}; - double[] result = roundTrip("round_zero", DataType.INT32, + double[] result = roundTrip("round_zero", config(DataType.INT32, Rounding.TOWARDS_ZERO, null, null), data); Assertions.assertArrayEquals(new double[]{2, 3, -2, -3}, result); } @@ -111,10 +111,10 @@ public void testRoundingTowardsZero() throws Exception { @Test public void testRoundingFloorAndCeil() throws Exception { double[] data = {2.1, -2.1}; - double[] floor = roundTrip("round_floor", DataType.INT32, + double[] floor = roundTrip("round_floor", config(DataType.INT32, Rounding.TOWARDS_NEGATIVE, null, null), data); Assertions.assertArrayEquals(new double[]{2, -3}, floor); - double[] ceil = roundTrip("round_ceil", DataType.INT32, + double[] ceil = roundTrip("round_ceil", config(DataType.INT32, Rounding.TOWARDS_POSITIVE, null, null), data); Assertions.assertArrayEquals(new double[]{3, -2}, ceil); } @@ -126,7 +126,7 @@ public void testNumpyCompatibilityExample() throws Exception { ScalarMap scalarMap = new ScalarMap( new Object[][]{{"NaN", 0}, {"+Infinity", 0}, {"-Infinity", 0}}, null); double[] data = {Double.NaN, Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY, 3.9, 300, -1}; - double[] result = roundTrip("numpy", DataType.UINT8, + double[] result = roundTrip("numpy", config(DataType.UINT8, Rounding.TOWARDS_ZERO, OutOfRange.WRAP, scalarMap), data); Assertions.assertArrayEquals(new double[]{0, 0, 0, 3, 44, 255}, result); } @@ -138,7 +138,7 @@ public void testScalarMapDecode() throws Exception { new Object[][]{{"NaN", 0}}, new Object[][]{{0, "NaN"}}); double[] data = {Double.NaN, 5}; - double[] result = roundTrip("scalarmap_decode", DataType.UINT8, + double[] result = roundTrip("scalarmap_decode", config(DataType.UINT8, Rounding.TOWARDS_ZERO, OutOfRange.WRAP, scalarMap), data, 7); Assertions.assertTrue(Double.isNaN(result[0])); Assertions.assertEquals(5.0, result[1]); @@ -148,14 +148,14 @@ public void testScalarMapDecode() throws Exception { public void testOutOfRangeWithoutRuleFailsOnWrite() { double[] data = {200}; // The write path runs on a parallel stream, so the codec's ZarrException surfaces wrapped. - assertCastError(() -> roundTrip("oor_fail", DataType.INT8, + assertCastError(() -> roundTrip("oor_fail", config(DataType.INT8, null, null, null), data)); } @Test public void testNaNToIntegerWithoutMappingFailsOnWrite() { double[] data = {Double.NaN}; - assertCastError(() -> roundTrip("nan_fail", DataType.INT8, + assertCastError(() -> roundTrip("nan_fail", config(DataType.INT8, null, OutOfRange.CLAMP, null), data)); } @@ -192,7 +192,7 @@ public void testFillValueRoundTripValidation() { public void testFloat64ToFloat32RoundTrip() throws Exception { // Values that are exactly representable in float32 survive the round trip unchanged. double[] data = {0, 0.5, -0.25, 1024, -2048}; - double[] result = roundTrip("float32", DataType.FLOAT32, + double[] result = roundTrip("float32", config(DataType.FLOAT32, null, null, null), data); Assertions.assertArrayEquals(data, result); } @@ -203,11 +203,11 @@ public void testRoundingNearestAwayFloatTie() throws Exception { // round the tie away from zero, whereas nearest-even rounds to 1.0 (even mantissa). double tie = 1.0 + 0x1p-24; double[] data = {tie, -tie}; - double[] away = roundTrip("round_nearest_away", DataType.FLOAT32, + double[] away = roundTrip("round_nearest_away", config(DataType.FLOAT32, Rounding.NEAREST_AWAY, null, null), data); Assertions.assertArrayEquals(new double[]{Math.nextUp(1.0f), -Math.nextUp(1.0f)}, away); - double[] even = roundTrip("round_nearest_even_float", DataType.FLOAT32, + double[] even = roundTrip("round_nearest_even_float", config(DataType.FLOAT32, Rounding.NEAREST_EVEN, null, null), data); Assertions.assertArrayEquals(new double[]{1.0, -1.0}, even); } @@ -247,10 +247,33 @@ public void testIntToIntFastPathOutOfRangeWithoutRuleFails() { config(DataType.INT8, null, null, null), new int[]{200})); } + @Test + public void testUint64AboveLongMax() throws Exception { + // uint64 values above Long.MAX_VALUE must be handled with unsigned (two's-complement) + // semantics. The large fill value goes through the exact cast path at construction; the array + // data goes through the identity fast path. Both must preserve the unsigned bit pattern. + StoreHandle storeHandle = handle("uint64_above_long_max"); + long big = Long.MIN_VALUE + 5; // bit pattern of 2^63 + 5 (a uint64 above Long.MAX_VALUE) + long fill = -1L; // bit pattern of uint64 max (2^64 - 1) + ArrayMetadata metadata = Array.metadataBuilder() + .withShape(2) + .withDataType(DataType.UINT64) + .withChunkShape(2) + .withFillValue(fill) // must not throw: 2^64 - 1 is in range for uint64 + .withCodecs(c -> c.withCastValue(config(DataType.UINT64, null, null, null)).withBytes()) + .build(); + Array writeArray = Array.create(storeHandle, metadata); + writeArray.write(ucar.ma2.Array.factory(ucar.ma2.DataType.ULONG, new int[]{2}, new long[]{big, 7L})); + + Array readArray = Array.open(storeHandle); + long[] result = (long[]) readArray.read().get1DJavaArray(ucar.ma2.DataType.LONG); + Assertions.assertArrayEquals(new long[]{big, 7L}, result); + } + @Test public void testUnsupportedBoolTargetFails() { double[] data = {0, 1}; - assertThrows(ZarrException.class, () -> roundTrip("bool_fail", DataType.BOOL, + assertThrows(ZarrException.class, () -> roundTrip("bool_fail", config(DataType.BOOL, null, null, null), data)); } }