跳轉到

Indexing and Assignment

SimpleArray keeps its descriptor small by handling coordinates at the access boundary. Scalar keys go through normalize_index() and at(); slice keys go through the Python wrapper and py::slice.compute(). Both translate body-relative axis-0 keys into storage coordinates before following signed strides.

One descriptor, two assignment paths

nghost does not move logical_data() or change shape and strides. It only defines the axis-0 body origin:

Text Only
body() = logical_data() + nghost * stride[0]

The wrapper dispatches on the Python assignment form:

assignment normalization writer
scalar RHS + integer key ghost shift, then negative-index wrap at()
array RHS + slice key shift explicit bounds, then slice.compute() TypeBroadcast

Both paths change values in the existing allocation. Neither replaces the allocation nor rewrites the descriptor.

Scalar indexing

For a SimpleArray of length eight with three ghost cells, negative three maps to the first ghost cell, zero maps to the first body cell, and negative four wraps to the final storage cell
Axis 0 adds nghost before Python negative-index wrapping.

The scalar implementation makes that ordering explicit:

SimpleArray.hpp
ssize_t normalize_index(ssize_t it) const
{
    if (ndim() != 1)
    {
        throw std::out_of_range(
            std::format("SimpleArray::normalize_index(): "
                        "cannot use scalar index for {}-dimensional array",
                        ndim()));
    }
    auto const dim_length = static_cast<ssize_t>(shape(0));
    auto const ghost_offset = static_cast<ssize_t>(m_nghost);
    ssize_t const shifted_index = it + ghost_offset;

    if (shifted_index < -dim_length)
    {
        throw std::out_of_range(
            std::format("SimpleArray: index {} < -nghost - shape[0]: {}",
                        it, -dim_length - ghost_offset));
    }
    if (shifted_index >= dim_length)
    {
        throw std::out_of_range(
            std::format("SimpleArray: index {} >= {} (shape[0]: {} - nghost: {})",
                        it, dim_length - ghost_offset, shape(0), m_nghost));
    }
    return to_nonnegative_index(shifted_index, dim_length);
}

at() then converts the normalized coordinate into an address:

SimpleArray.hpp
value_type & at(ssize_t it)
{
    shape_type const idx{normalize_index(it)};
    ssize_t const offset = buffer_offset(m_stride, idx);
    return *(logical_data() + offset);
}

For storage length N=8 and nghost=3, -3 reaches storage 0, 0 reaches storage 3, and -4 wraps to storage 7. The complete valid interval is [-N-G, N-G). Only axis 0 receives the ghost offset in a multidimensional tuple.

An edge alias

-11 + 3 = -8, which wraps to storage 0. Therefore -11 and -3 name the same cell. This follows from Python wrapping after the ghost shift.

Slice assignment

A slice is not two scalar indices. None, negative steps, and clipped bounds must be normalized together.

Python
import numpy as np
import solvcon

a = solvcon.SimpleArrayFloat64(shape=5, value=0)
a.nghost = 2

a[-2:0] = np.array([10.0, 11.0])
a[0:1] = np.array([20.0])

np.testing.assert_array_equal(a.ndarray, [10, 11, 20, 0, 0])
With two ghost cells, the explicit slice negative two to zero shifts to storage slice zero to two, is normalized by py slice compute, and writes the two ghost cells
Explicit bounds become storage coordinates before Python normalizes the complete slice.

The wrapper first shifts explicit bounds. shift_slice_bound() deliberately leaves None unchanged:

array_common.hpp
pybind11::object ArrayPropertyHelper<T>::shift_slice_bound(
    pybind11::handle bound, ssize_t offset)
{
    if (bound.is_none())
    {
        return pybind11::none();
    }

    PyObject * index = PyNumber_Index(bound.ptr());
    if (index == nullptr)
    {
        throw pybind11::error_already_set();
    }
    return pybind11::reinterpret_steal<pybind11::object>(index)
           + pybind11::int_(offset);
}

copy_slice() then delegates clipping and negative-step behavior to Python:

array_common.hpp
pybind11::slice normalized_slice = slice_in;
if (offset != 0)
{
    pybind11::object const start =
        shift_slice_bound(slice_in.attr("start"), offset);
    pybind11::object const stop =
        shift_slice_bound(slice_in.attr("stop"), offset);
    normalized_slice =
        pybind11::slice(start, stop, slice_in.attr("step"));
}

pybind11::ssize_t start = 0;
pybind11::ssize_t stop = 0;
pybind11::ssize_t step = 0;
pybind11::ssize_t slicelength = 0;
if (!normalized_slice.compute(
        length, &start, &stop, &step, &slicelength))
{
    throw pybind11::error_already_set();
}

slice_out[0] = start;
slice_out[1] = stop;
slice_out[2] = step;
slice_out[3] = slicelength;

Finally, TypeBroadcast follows the destination's signed strides:

TypeBroadcast.hpp
ssize_t offset = 0;
for (ssize_t axis = 0; axis < arr_out.ndim(); ++axis)
{
    ssize_t const index =
        slices[axis][0] + sidx[axis] * slices[axis][2];
    offset += arr_out.stride(axis) * index;
}
return offset;

The RHS array is walked with its own signed byte strides. Because omitted bounds remain None, a[::2] and a[::-1] cover the full storage, including ghost cells. In contrast, explicit a[0:] begins at the body.

Supported operations

  • A scalar RHS accepts an integer key or an all-integer tuple.
  • An array RHS accepts slices and at most one ellipsis.
  • The RHS must have identical rank and per-axis shape; singleton broadcasting is not implemented.
  • Scalar-to-slice assignment, mixed integer/slice tuples, newaxis, fancy indexing, and boolean indexing are unsupported.
  • __getitem__ does not yet support slices.

These are parser limits, not descriptor limits. Both paths end at the first page's rule: logical_data + sum(index[d] * stride[d]).

Sources