Proposal 0005 — Multiple coordinates per axis (incl. affine coordinates)
| Status | Draft — steps 1–5 implemented (#82 phase 1, affine); step 6 (#82 phase 2, curvilinear) storage + .sel landed, .interp not yet |
| Author | (proposed) |
| Created | 2026-07-26 |
| Updated | 2026-07-29 — curvilinear coordinate storage + nearest-neighbor .sel (step 6, #82 phase 2) implemented |
| Tracking | #65; generalises 0001/0002; interacts with 0004 (numeric .sel), #82 (affine + curvilinear) |
Abstract
Today an axis carries exactly one coordinate (coords = {dim: coordinate}).
xarray lets a dimension carry many — one dimension coordinate (the index
used by .sel/alignment) plus any number of non-dimension coordinates — and
coordinates that span several dimensions (curvilinear grids). This proposal
adopts that model, and extends the compact spacing/origin form of 0001 into
a multi-dimensional affine coordinate — i.e. a NIfTI-like voxel↔world map.
Motivation
Real datasets label an axis more than one way at once:
- a
timeaxis that is a numeric coordinate (seconds) and carries aseasonlabel per step; - a
stepaxis indexed by integer, with awavelengthcoordinate alongside; - an image whose
(y, x)grid has 2-Dlat/loncoordinates — a coordinate over two dims at once. In imaging this is the affine: `world = A·index - origin
, exactly a NIfTIsform/qform`.
One-coordinate-per-axis expresses none of these. This is the last major gap between our coordinate model and xarray's — and the affine case makes it a first-class feature for medical / neuro imaging, not just parity.
xarray's model (the target)
coordsis keyed by coordinate name, each an array over one or more dims:{name: (dims, values)}.- A dimension coordinate has
name == dim, is 1-D over that dim, and is the index for.sel/alignment. At most one per dim. - Non-dimension coordinates ride along (carried, sliced; selectable only after promotion to the index).
swap_dims/set_indexchange which coordinate is a dim's index.- Coordinates may be multi-dimensional (
lat(y, x)) — curvilinear grids.
Proposed model for fiery.xtensor
Storage — unified into {name: (dims, coord)} (resolves open Q2 — done, #84)
All coordinates live in one map keyed by coordinate name, each entry a
(dims, coord) pair —
where coord is any of the existing kinds (categorical labels; a numeric
Coordinate — compact spacing/origin or explicit values) and dims is the
tuple of dim names it spans (length 1 for today's coordinates, ≥ 2 for
curvilinear/affine, landing in step 3 below). A coordinate is a dimension
coordinate (the index) iff dims == (name,); a non-dimension coordinate
has a name that is not itself a dim, and rides along the dim(s) in dims
without being an index. Reconciliation, slicing, and propagation are one code
path keyed by coord-name over a tuple of dims, rather than the previous
incremental _coords/_axis_coord/_extra_coords split.
Input
A coords value keyed by a dim name is that dim's index coordinate, as today;
a value keyed by any other name is (dim, values) — a non-dimension coordinate
riding along dim:
xtensor(data, names=("t",), coords={
"t": {"spacing": (0.5, "s")}, # dimension coord (index)
"season": ("t", ["w", "w", "sp", "sp"]), # non-dimension coord along t
})
.sel
Resolves against the index (the dimension coordinate). Selecting on a
non-dimension coordinate raises, directing to swap_dims/set_index (xarray).
Propagation
A dimension coordinate is carried and re-sliced exactly like today (affine
update on a basic slice, materialise-then-index on an advanced one). A
non-dimension coordinate rides through unchanged as long as every dim in
its dims survives at its original size; it drops the moment one of them is
renamed away, removed, or resized (conservative — no slice-tracking yet, see
the delivery plan's step 6).
Compact affine coordinates (step 3, implemented)
Generalise 0001's 1-D compact form value[i] = origin + i·spacing by letting
spacing be a per-source-dim vector and the coordinate span several dims:
xtensor(field, names=["y", "x"], coords={
"lat": (["y", "x"], {"spacing": ([sy_lat, sx_lat], "deg"), "origin": (lat0, "deg")}),
"lon": (["y", "x"], {"spacing": ([sy_lon, sx_lon], "deg"), "origin": (lon0, "deg")}),
})
# lat[i,j] = lat0 + sy_lat·i + sx_lat·j ; lon[i,j] = lon0 + sy_lon·i + sx_lon·j
Stacked, (lat, lon) is a 2×2 linear map + translation from index to world — a
NIfTI sform. Properties:
- Materialisation:
origin + Σ_d spacing[d]·index_dvia broadcastaranges → an N-D grid, differentiable (spacing/origin may be learnable — a fit for registration). - Exact slicing: slicing dim
d_kby(start, step)updatesorigin += start·spacing[k]; spacing[k] *= step— 0001's trick, per component. - Closed-form inverse:
index = A⁻¹·(world − origin). So affine curvilinear.sel/.interpis tractable — invert the (small) affine, then nearest /grid_pull— sidestepping the nonlinear inversion a generallat(y,x)array needs (#82). Requires querying the coupled coords jointly (.sel(lat=…, lon=…)), and a full-rank (invertible) map.
Coordinate reconciliation on broadcast (resolves open Q3)
When two operands share a coordinate name (a dim coord or a non-dim coord), reconcile per coordinate — the same "keep on agreement, drop on conflict" rule descriptors and labels already follow:
- same kind, equal (labels / affine / explicit values) → keep;
- both categorical, differing → inner-join by label;
- both numeric, differing → require equality, else drop (fuzzy float
inner-join is a trap; value-based reindex is
.interp-alignment, separate); - different kinds (numeric vs categorical, 1-D vs curvilinear) → drop (the axis still broadcasts by name; only the contested coord is dropped).
The dimension-coordinate half of this shipped with the partial-name broadcasting
work (#83,
_reconcile_coords); binary ops don't yet carry non-dimension coordinates
through at all (they're dropped, conservatively) — extending _reconcile_coords
to them is future work, not blocking this proposal.
Delivery plan — split across PRs
Per review, this lands as a sequence of small PRs, not one massive change:
- ✅ Storage unification (#84) —
migrated the internals to
{name: (dims, coord)}with a dimension coordinate =dims == (name,); behaviour-preserving refactor, no new user surface. - ✅ Non-dimension (1-D) coordinates (#72) —
(dim, values)input,.coordsexposure,.sel"not an index" guard, conservative propagation (implemented directly against the unified storage — no separate_extra_coordsattr). - ✅ Compact affine / multi-dim coordinates — vector
spacingover adimstuple: materialisation, exact per-component affine slicing, learnable. (No.selyet — that's step 5.) - ✅
swap_dims— promote a non-dimension coordinate to the index. - ✅ Affine
.sel/.interp(#82 phase 1) — closed-formA⁻¹inverse + (for interp) N-Dgrid_pull, joint query over the coupled dims. - ✅
.sel— a joint query (x.sel(lat=…, lon=…)) over a square, invertible affine solvesindex = A⁻¹(world − origin)and snaps to the nearest position; under/over-determined and non-invertible queries raise rather than approximating. - ✅
.interp— the same closed-form inverse, kept fractional, feeds a genuine N-Dgrid_pull(or a built-in separable nearest gather); a multi-valued joint query collapses the spanned dims into one new axis, point-wise. - ✅ Curvilinear coordinate storage +
.sel(#82 phase 2) — a general multi-dim explicit coordinate (no analytic form, e.g.lat(y, x)on an irregular grid). - ✅ Storage — an N-D tensor spanning several dims, materialising in the host tensor's own axis order; drops (rather than rebinding wrong) once a spanned dim's slice moves its size on without it.
- ✅
.sel— a joint query resolves to the single nearest grid point by squared Euclidean distance over the queried coordinates' raw magnitudes (brute force, torch-native, unit-blind). Single point only, with a size guard against a runaway distance computation — seevs-xarray.mdfor why bulk regridding isn't in scope. .interpover a curvilinear coordinate is not implemented.- Non-dim coordinate slice-tracking — carry non-dim coords through slicing instead of dropping on resize.
Each PR is independently reviewable/mergeable; (3) and (5) are the headline affine feature.
What this slice implements (step 2)
- A
coordsvalue keyed by a non-axis name is parsed as(dim, values)and stored as{key: ((dim,), coord)}in the same unified_coordsmap a dimension coordinate uses —dims != (key,)is exactly what marks it non-dimension. .coordsexposes it by name, validated the same way a dimension coordinate is (its dim must still be named, at the same size)..sel(name=...)raises "not an index coordinate" for one, pointing atswap_dims(step 4, below).renameremaps the dim it rides on (not its own key); ops that reslice a named axis (sort/flip/roll/gather/index_select/take_along_dim) drop any non-dimension coordinate riding on the touched axis, alongside that axis' own dimension coordinate — both are conservatively invalidated the same way.renamealso raises on a coordinate-name collision (renaming an axis onto an existing coordinate's name) rather than silently dropping one.- Binary ops (broadcasting) don't carry non-dimension coordinates through yet (dropped) — see the reconciliation section above.
- Only labels or an explicit numeric tensor are accepted as
valuesfor a single-dim non-dimension coordinate — a compact (spacing/origin) spec over one dim raisesNotImplementedError. Unlike a dimension coordinate, a single-dim non-dimension one isn't re-sliced when its dim is (no slice-tracking yet, step 6); a label or explicit coordinate is at least caught by the length check on resize, but a compact one binds to any size, so it would silently rebind to the wrong affine after a non-trivial slice instead of raising or dropping. Lifting this restriction is part of step 6.
What step 3 implements
- A
coordsvalue keyed by a non-axis name, given as(dims, {spacing, [origin]})wheredimsis a sequence of two or more dim names, is a compact affine coordinate:spacingis a vector with one component per dim (_as_unitful_vector, preserving a tensor component's autograd graph viatorch.stackrather than flattening it throughtorch.as_tensor);originstays a single scalar shared across all of them. ["values"]materialises the N-D grid lazily (Coordinate._materialise_axes, bound to per-dim sizes by_bound_axes):origin + Σ_d spacing[d]·index_dvia a broadcastarangeper dim — no dense grid cached, so it stays differentiable exactly like the 1-D case. The grid is laid out in the tensor's axis order rather than indimsorder (the two differ whendimsis given out of order, or after apermute/transpose/movedimcarries the coordinate through), since["values"]is a bare array carrying no dims of its own to disambiguate the layout.__getitem__re-slices it exactly, per spanned dim (_slice_affine_coordinate): a basic slice updates that dim's component (origin += start·component; component *= step); an integer index folds the dim out ofdims/spacingentirely (origin += index·component) — the coordinate survives with one fewer dim, and collapsing all the way to one remaining dim yields an ordinary 1-D compact non-dimension coordinate (a plain scalarspacing, not a length-1 vector — reslicing code has to branch on this, it isn't just a vector of length 1); anything else (boolean/advanced indexing) can't stay affine, so the whole coordinate drops.renameremaps every dim indimsthe same way a 1-D non-dimension coordinate's single dim already does (already generic overdimslength), but now also refuses to rename an axis onto a multi-dim coordinate's key — that would leave a key which is a dim yet isn't that dim's index, which.seland the dimension-coordinate slicing pass would then misread.- A general multi-dim explicit (curvilinear) coordinate — arbitrary
lat(y,x)values, not a compact affine map — is a separate follow-on (#82 phase 2, see below), not a natural extension of this slice. .to(unit)(position-unit conversion) needed no changes — it already rescalesspacing["value"]elementwise, which works the same whether that value is a scalar or a vector.
What step 4 implements
swap_dims({old_dim: new_name})(positional dict, orswap_dims(old_dim=new_name)as keywords):new_namemust already be a non-dimension coordinate ridingold_dimalone (dims == (old_dim,)) — the same restriction xarray'sswap_dimshas (it needs a coordinate that is a function of exactly that one dim). Renames the axisold_dim -> new_name(so.namesand any axis descriptor follow, exactly likerename), and demotesold_dim's previous dimension coordinate to a non-dimension coordinate riding the renamed axis, under its old key — matching xarray's own behaviour (da.swap_dims({"time": "label"})keeps a"time"coordinate around, now riding the"label"axis).- Not a thin wrapper over
rename: renamingold_dimontonew_namewould re-keyold_dim's own dimension coordinate ontonew_nametoo, colliding with the very coordinate being promoted — the same collisionrenameitself already raises on (step 3).swap_dimsnever re-keys a coordinate; it only remapsdimstuples through the axis substitution (exactly whatrenamealready does to a coordinate'sdims), so which entry counts as the dimension coordinate falls out structurally afterwards (dims == (key,)), rather than needing to be assigned explicitly. - A demoted compact dimension coordinate (e.g.
spacing/origin) becomes, structurally, a single-dim compact non-dimension coordinate — a state thecoords=constructor input itself refuses to create directly (see step 2's restriction above), but one the existing per-component affine slicing (_slice_affine_coordinate, already generic overdimslength) handles correctly regardless of how it arose, so it keeps re-slicing exactly afterswap_dims. swap_dims_is the in-place variant, matchingrename/rename_.set_index(xarray's more general sibling, supporting aMultiIndex) is not implemented — there's noMultiIndexanalogue planned here, andswap_dimsalready covers every case this model can express (promoting a single existing non-dimension coordinate to be the index).
What step 5's .sel half implements (#82 phase 1)
.sel's indexer loop groups every queried coordinate name that spans more than one dim (a compact affineCoordinate, step 3) by itsdimstuple. A group must supply exactlylen(dims)values (one per spanned dim) — a square system — or it raises rather than falling back to a least-squares fit; a lone affine coordinate queried by itself is under-determined this way, not "not an index" (that error is now reserved for an ordinary, non-affine non-dimension coordinate).- Each queried coordinate name in a group contributes one row of
A(itsspacingvector, already ordered alongdims) and one entry ofb(target − origin);index = A⁻¹ b(torch.inverse, chosen overtorch.linalg.solvefor wider old-torch support — the floor is 1.7), rounded to the nearest integer per dim. A singular (non-invertible)Araises, naming the offending coordinates. - Never materialises the affine grid — the raw
spacing/originalone are enough, mirroring the 1-D compact.selfast path (#110). Solved in float64 regardless of the spacing's own or default dtype, matching the 1-D closed-form path's convention — a spacing difference near float32 epsilon must not be silently rounded away into a near-singular system. - Only
mode="round"(the default) is supported;floor/ceil/prev/nexthave no well-defined meaning jointly across several coupled dims and raiseNotImplementedError.toleranceapplies per queried coordinate name against the rounded position's own value, same as a 1-D numeric.sel— a bare joint query is exact by default too. - A joint affine query composes with ordinary 1-D
.selindexers in the same call (x.sel(t=1.0, lat=52.1, lon=4.3)) — but a dim resolved by the joint solve can't also be queried directly in the same call (raises, rather than silently letting one overwrite the other). .interp's N-Dgrid_pullhalf is separate, follow-up work (not implemented here) — the pull itself needs genuine multi-axis simultaneous sampling, not the axis-by-axis separable interpolation.interpalready does for 1-D coordinates.
What step 5's .interp half implements (#82 phase 1)
- Groups
.interp's indexers the same way.seldoes; the square-system requirement is identical, and only one joint group per call is supported for now (a second group raisesNotImplementedError— callinterpagain for it). - Inverts
Aonce per call (shared across every query point, unlike.sel's single-point solve) to a fractional index — never rounded — then genuinely pulls the tensor's values at those N-D positions viafiery.interpol.grid_pull(order ≥ 1, or order 0 with the backend installed) or a built-in separable nearest gather (order 0, no backend — nearest-neighbour rounding needs no cross-axis interpolation weight, so it stays exact without the optional dependency). Solved in float64 regardless of the spacing's own/default dtype, mirroring both.sel's and the 1-D.interppath's conventions. - A query with every name given as a scalar is a single point: the
spanned dims drop entirely, the same scalar-drops-the-axis convention
.interp/.selalready have for a 1-D coordinate. - Any name given as a list/tensor makes the whole group "many": every
name's query broadcasts to a common length
N(a length-1 query broadcasts against a longer one; two different non-1 lengths raise), and the spanned dims collapse into one new axis ofNsampled points — not an outer-product grid. The dims are coupled, so you can't vary one queried name's value without moving through every spanned dim at once; this mirrors xarray's own vectorized/pointwise-indexing convention for a value-based query on a multi-dim coordinate (confirmed againstxarray.indexes.CoordinateTransformIndex, its own affine-inversion index type — a bare scalar/list query isn't accepted there at all, only point-wiseDataArrayindexers sharing a dimension, collapsing to that one dimension). - The new axis is named via
interp's newname=keyword if given, else the shared name of any query that is itself a named 1-DXTensor—x.interp(lat=XTensor([...], names=("pts",)), lon=[...])needs noname=at all, mirroring how xarray derives its vectorized-indexing result's new dimension from the indexer arrays' own shared dim name, not a separate parameter; disagreeing indexer names with noname=override to resolve them raise. With neither, the axis stays unnamed —xstack's convention for a brand-new axis with nothing to infer from, sincetorch.stackitself gives no way to name one either. Either way it carries every queried name's own sampled values as a riding (non-dimension) coordinate — you get the world coordinates of your sampled points back, the same way xarray's vectorized indexing collapses its own auxiliary coordinates onto the new dimension for free. - The new axis is inserted at the left-most spanned dim's position (a well-defined choice regardless of whether the spanned dims are adjacent in the tensor); every other axis keeps its name and relative order.
- Composes with ordinary 1-D
.interpindexers in the same call (x.interp(t=1.0, lat=52.1, lon=4.3)) — attempting to also query a spanned dim directly afterward fails naturally (the dim no longer exists once the joint pull replaces it), with no special-case guard needed (unlike.sel, where the dim's integer position still exists after the solve, making a silent overwrite possible there). interpacceptsindexersas an explicit mapping (x.interp({"method": 5.0})), not just**kwargs— xarray's own escape hatch for a dim whose name collides with one ofinterp's own keyword parameters (method,bound,extrapolate, and nowname): a matching keyword argument always binds to the parameter, never reaching the indexers, so the dict form is the only way to query such a dim. Passing both raises.
What step 6 implements (#82 phase 2)
- Storage. A
coordsvalue keyed by a non-axis name whose spec is(dims, values)withlen(dims) > 1andvaluesan explicit tensor (not a compact{"spacing": ...}map) is a curvilinear coordinate: an N-D array with one axis per spanned dim, no analytic form. Validated at construction (values.ndim == len(dims), and its shape matches each spanned dim's current size). Materialises reordered to the host tensor's own axis order (like the affine form), so it survivespermute/transpose/movedimregardless of the orderdimswas given in. - No re-slicing formula. Unlike the affine form (which updates its
spacing/originexactly through any basic slice), an explicit curvilinear array has no formula to fold a slice through — it rides through a slice on one of its spanned dims unchanged, the same way a 1-D explicit non-dimension coordinate already does, and is dropped (not rebound to a mismatched shape) once a spanned dim's size no longer matches. Re-slicing it exactly is a possible future refinement, not implemented here. .sel— brute-force nearest neighbor. Grouped the same way as the affine joint query: every coordinate name queried that spans the samedimsis one group, one value per dim. Stacks the group's coordinate arrays into a(*grid_shape, k)point cloud (promoted to float64, regardless of the grid's own dtype, matching.sel's affine-path convention) and finds the closest grid point by direct squared distance — no scipy/sklearn KD-tree, so it stays GPU-capable, but brute force (O(grid size)memory/time) and unit-blind (mixing e.g. degrees and metres weights the nearer-magnitude one more heavily), since an arbitrary grid has no closed-form inverse.torch.cdistwas considered and rejected: its default compute mode switches to a‖a‖²+‖b‖²−2a·bmatrix-multiply identity above 25 points, which catastrophically cancels in float32 for realistic coordinate magnitudes (found in review — silently wrong nearest neighbor on real-sized grids). A NaN grid point (a masked/ fill swath cell) is excluded from the search rather than winning by NaN-propagation. Onlymode="round"/method="nearest"(the default) is supported;toleranceis enforced per queried coordinate name against its own gap to the chosen point (not the joint distance), same convention as every other.selpath.- Single point only, by design. A query vectorized over many points at
once (the shape a bulk regrid needs) is not supported: the distance
matrix's cost is the product of the grid size and the query count, and
that product is exactly what motivates every tree-index library in this
space (xarray's
NDPointIndex,xoak,pyresample) to exist rather than brute-forcing it — seevs-xarray.md. A size guard rejects a query whose distance-matrix estimate crosses a byte threshold, as a backstop against a pathologically large single grid rather than a routine limit (a single-point query's distance matrix is only one column wide). .interpover a curvilinear coordinate is not implemented. There is no gradient/interpolation-weight analogue of the nearest-neighbor lookup here; unlike the affine case, a curvilinear.interpwould need genuine scattered-data interpolation (e.g. inverse-distance weighting or a local gradient search, the approachpyresample's gradient-search resampler takes) — a separate, harder piece of future work.
Open questions — resolved
.selon a non-index coordinate — resolved: no sugar. Same as xarray:.selonly ever resolves against the index; selecting by a non-index coordinate always needs an explicitswap_dimsfirst, with no implicit one-shot shortcut. Matches xarray exactly, and avoids the implicit-magic risk of silently redefining "the index" for one call.- Affine query ergonomics — resolved. No dedicated syntax for the
joint query:
x.sel(lat=…, lon=…)is just ordinary kwargs, and the implementation (step 5, #82 phase 1) recognises thatlat/lonsharedimsand solvesA⁻¹once for the pair. Posedness:.selonly supports a square, invertible affine (one world coordinate per spanned dim) — an under/over-determined map raises rather than falling back to a least-squares pseudo-inverse. Keeps the feature to the case the closed-form inverse actually covers; a non-square affine is out of scope here, not silently approximated.
(Resolved since the first draft: storage unification — done; alignment /
reconciliation policy — above; swap_dims does rename the dim to the
promoted coordinate's name, matching xarray — above; both items in this
section — above.)
References
- xarray coordinates & indexes — https://docs.xarray.dev/en/stable/user-guide/data-structures.html#coordinates
- Proposals 0001 / 0002 — the single (dimension) coordinate this generalises
- Proposal 0004 — numeric
.sel, which resolves against the index coordinate - #82 — curvilinear / N-D
.interp(affine = closed-form inverse) - #83 —
_reconcile_coords(dimension-coordinate reconciliation) - #84 — storage unification
- #72 — non-dimension coordinates (step 2)