Skip to content

Axis descriptors

A dimension name can be enriched into a descriptor — a dict carrying any extra fields you like — and those fields follow the dimension the way coordinates do, merge across name-aware ops, and can address whole groups of axes at once.

A name is enriched by passing a dict through axes= (names= takes bare strings only). The dict needs a name; every other key is up to you:

x = xtensor(
    torch.zeros(2, 3, 4),
    axes=[
        {"name": "b", "type": "batch"},
        "h",
        {"name": "w", "type": "space", "orientation": "left-to-right"},
    ],
)
x.names          # ('b', 'h', 'w')          — the bare, ergonomic view
x.axes           # full descriptors, one dict per axis
x.flip("w").axes[2]["orientation"]   # 'right-to-left'  — flip reverses it

Which keys are special

A descriptor is an open dict — attach whatever metadata suits your data. Only a few keys are reserved by the library:

  • name (required) — becomes the dimension's name.
  • coord / labels — the dimension's coordinate (see Coordinates); handed straight to coords.
  • orientation — the one otherwise-free field with built-in behaviour: it must read "{a}-to-{b}", and flipping the axis rewrites it to "{b}-to-{a}".

Everything else is free-form. The type in the examples is just the OME-NGFF convention — it is not a first-class key, and nothing in the library privileges it. Use {"name": "c", "modality": "MRI", "role": "readout"} or whatever keys your pipeline cares about; they are stored, carried, merged, and queryable exactly the way type is.

axes= is the general per-axis container; names= (bare strings) and coords= are shortcuts into it. Descriptor fields are keyed by dimension name, so — like coordinates — they follow their dimension through permute, reductions, rename, etc.

Physical units are not an axis descriptor

There is no blessed axis unit. An axis's physical unit is carried by its coordinate values (a position unit, see Coordinates), while a tensor's values carry a data unit (see Data units). A unit key placed in a descriptor is just inert free-form metadata — it gets no unit semantics.

When two operands meet in a name-aware op (broadcast, alignment), their descriptors are merged by dim name the same way coordinates are: the result is the union of the axes, and for a shared dim the fields the operands agree on are kept while conflicting fields are dropped. That policy is configurable via set_options(combine_axes=...), usable globally or as a context manager:

from fiery.xtensor import set_options

a = xtensor(torch.ones(3), axes=[{"name": "x", "type": "space"}])
b = xtensor(torch.ones(3), axes=[{"name": "x", "type": "time"}])
(a + b).axes                      # ({'name': 'x'},)  — the clash drops 'type'

with set_options(combine_axes="strict"):
    a + b                         # raises ValueError: conflicting 'type' …

The policy is one of "drop_conflicts" (default), "strict" (alias "raise"), "override" (keep the left operand's value), or "drop" (always drop the field). Pass a {field: policy} dict to set it per descriptor field"*" is the default for fields you don't name:

# drop everything by default, but a clashing type is an error
with set_options(combine_axes={"*": "drop", "type": "raise"}):
    ...

A descriptor is also a way to address axes. Anywhere you can pass a dim (method form), a query dict selects every axis whose descriptor matches — so one call can act on a whole group of axes at once:

x.movedim({"type": "space"}, -1)   # all space axes to the back, order kept
x.sum(dim={"type": "channel"})     # reduce every channel axis

A query that matches a single axis behaves exactly like naming it (so it still works with single-dim-only ops such as prod).