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:
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
nghost before Python negative-index wrapping.
The scalar implementation makes that ordering explicit:
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:
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.
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])
The wrapper first shifts explicit bounds. shift_slice_bound() deliberately
leaves None unchanged:
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:
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:
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]).