Skip to content
Merged
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
23 changes: 19 additions & 4 deletions src/ASDF.jl
Original file line number Diff line number Diff line change
Expand Up @@ -785,9 +785,17 @@ function NDArray(
offset = 0
end
if strides isa Nothing
# Calculate byte strides in C order
# Calculate byte strides in C order, treating any zero-length dimension as
# length 1 within this product only (not in `shape` itself). This matches NumPy's
# convention for default C-contiguous strides (`PyArray_NewFromDescr`),
# relied on by the reference Python `asdf` package. Without the clamp, a
# zero-length dimension collapses the stride of every outer dimension whose
# product includes it down to zero, which then fails the `strides` positivity
# check below even though no data is ever read from a zero-size array. Negative
# entries are left unclamped so the shape negativity check below still reports them with its own clear error message. STScI Roman L2 `.asdf` products contain such
# zero-shape arrays (e.g. `chisq`, `dumo`) with no explicit `strides` key.
sz = sizeof(Type(datatype))
strides = reverse(cumprod([sz; reverse(shape[(begin + 1):end])]))
strides = reverse(cumprod([sz; reverse(max.(shape[(begin + 1):end], 1))]))
end
return NDArray(
lazy_block_headers, source, data, Vector{Int64}(shape), datatype, byteorder, Int64(offset), Vector{Int64}(strides)
Expand Down Expand Up @@ -833,7 +841,7 @@ size(result) == Tuple(reverse(ndarray.shape))
eltype(result) == ASDF.materialized_eltype(ndarray.datatype)
```

For the `ucs4` and `ascii` string datatypes, [`materialized_eltype`](@ref) is a thin `AbstractString` view over the characters ([`UCS4String`](@ref) / [`AsciiString`](@ref); see [`stringify_data`](@ref)). For all other datatypes, `eltype(result) == Type(ndarray.datatype)` and additionally `sizeof(eltype) .* strides(result) == Tuple(reverse(ndarray.strides))`.
For the `ucs4` and `ascii` string datatypes, [`materialized_eltype`](@ref) is a thin `AbstractString` view over the characters ([`UCS4String`](@ref) / [`AsciiString`](@ref); see [`stringify_data`](@ref)). For all other datatypes, `eltype(result) == Type(ndarray.datatype)` and additionally, when the array has at least one element, `sizeof(eltype) .* strides(result) == Tuple(reverse(ndarray.strides))` along every dimension with more than one element. (A dimension of length 1 has no well-defined stride, there is no pair of adjacent elements to space apart along it, and in a zero-element array no dimension does, so `reshape`/`reinterpret` are free to report any value there; such strides are excluded from this check.)
"""
function Base.getindex(ndarray::NDArray)
if ndarray.data !== nothing
Expand Down Expand Up @@ -870,7 +878,14 @@ function Base.getindex(ndarray::NDArray)
# Check array layout
@assert size(data) == Tuple(reverse(ndarray.shape)) # `data` conforms to specified `ndarray.shape`
@assert eltype(data) == Type(ndarray.datatype) # `data` matches type specified by `ndarray.datatype`
if sizeof(eltype(data)) .* Base.strides(data) != Tuple(reverse(ndarray.strides))
# A dimension of length 1 has no meaningful stride (there is no pair of adjacent elements
# to space apart along it), and in an empty array no dimension does — Julia reports
# stride 0 along any dimension whose faster-varying dimensions include a zero length.
# Only compare strides where they are meaningful.
computed_strides = sizeof(eltype(data)) .* Base.strides(data)
expected_strides = Tuple(reverse(ndarray.strides))
data_shape = size(data)
if !isempty(data) && any(data_shape[i] > 1 && computed_strides[i] != expected_strides[i] for i in eachindex(data_shape))
error("`data` has different stride from `ndarray.strides`")
end

Expand Down
48 changes: 48 additions & 0 deletions test/test-ndarray.jl
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,54 @@ end
)
end

@testset "implicit strides with zero-length dimensions" begin
# 2026/08/06: Regression test for STScI Roman L2 `.asdf` products produced by romanisim,
# which contain `!core/ndarray` nodes (e.g. `chisq`/`dumo`) with a zero-length shape and
# no explicit `strides` key; see the default-strides comment in the `NDArray` outer
# constructor for the NumPy convention involved. Expected values below were verified
# against `np.ndarray(shape, dtype, buffer, offset, strides=None, order='C').strides`.
cases = [
(Int64[0, 0], ASDF.Datatype_float16, Int64[2, 2]),
(Int64[0, 5], ASDF.Datatype_float32, Int64[20, 4]),
(Int64[5, 0], ASDF.Datatype_float32, Int64[4, 4]),
(Int64[0, 0, 3], ASDF.Datatype_float32, Int64[12, 12, 4]),
(Int64[2, 0, 3], ASDF.Datatype_float32, Int64[12, 12, 4]),
(Int64[0, 2, 3], ASDF.Datatype_float32, Int64[24, 12, 4]),
]
for (shape, datatype, expected_strides) in cases
lbh = ASDF.LazyBlockHeaders()
push!(lbh.block_headers, make_block_header(UInt8[]))
nd = make_ndarray(;
lazy_block_headers = lbh, source = Int64(0), data = nothing, shape, datatype, strides = nothing,
)
@test nd.strides == expected_strides
# Materialize from an empty block to catch stride-check false positives at read time.
arr = nd[]
@test size(arr) == Tuple(reverse(shape))
@test eltype(arr) == Type(datatype)
end
end

@testset "implicit strides with negative-shape elements" begin
# Regression test: the implicit-strides branch of
# the `NDArray` outer constructor clamps negative entries so the
# `strides` positivity check downstream can't misfire on them. That clamping only affects
# the temporary array used to compute `strides`; it must not suppress the `shape`
# negativity check in the inner constructor, which always receives the original,
# unclamped `shape`. Cover a negative element both outside (`shape[1]`) and inside
# (`shape[2:end]`) the slice passed to `max.(...)`.
for shape in (Int64[-1, 3], Int64[3, -1])
test_ndarray(
ArgumentError,
"`shape` cannot have negative elements.";
source = Int64(0),
data = nothing,
shape,
strides = nothing,
)
end
end

@testset "getindex" begin
opposite = ASDF.host_byteorder == ASDF.Byteorder_little ? ASDF.Byteorder_big : ASDF.Byteorder_little

Expand Down
Loading