transform-tree 0.0.2__cp39-abi3-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tf_tree/__init__.py +107 -0
- tf_tree/_core.pyd +0 -0
- tf_tree/_core.pyi +544 -0
- tf_tree/py.typed +0 -0
- transform_tree-0.0.2.dist-info/METADATA +321 -0
- transform_tree-0.0.2.dist-info/RECORD +11 -0
- transform_tree-0.0.2.dist-info/WHEEL +4 -0
- transform_tree-0.0.2.dist-info/licenses/LICENSE-APACHE +201 -0
- transform_tree-0.0.2.dist-info/licenses/LICENSE-MIT +21 -0
- transform_tree-0.0.2.dist-info/licenses/NOTICE +30 -0
- transform_tree-0.0.2.dist-info/sboms/tf_tree_py.cyclonedx.json +1731 -0
tf_tree/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""tf_tree — a transform tree engine.
|
|
2
|
+
|
|
3
|
+
A faster, more scalable alternative to ROS ``tf2``.
|
|
4
|
+
|
|
5
|
+
Two things surprise people, and both are deliberate:
|
|
6
|
+
|
|
7
|
+
**Stamps are integer nanoseconds.** There is no float-seconds overload. At a
|
|
8
|
+
2026 epoch the ULP of ``float64`` seconds is 238 ns, so *every* interval in a
|
|
9
|
+
1 kHz stream is wrong after a round trip. :func:`from_sec` exists for callers
|
|
10
|
+
who genuinely have float seconds and accept the loss.
|
|
11
|
+
|
|
12
|
+
**Nothing returns a view into shared memory.** An edge's samples are a ring
|
|
13
|
+
being overwritten by another process, and correct reads go through a seqlock;
|
|
14
|
+
an array pointing into it would be a data race by construction. "Zero-copy"
|
|
15
|
+
here means no *intermediate* allocation — results are written once, into their
|
|
16
|
+
final home. Use :meth:`Plan.at_into` to supply that home yourself.
|
|
17
|
+
|
|
18
|
+
**Identifying a build.** A benchmark number or a bug report has to say which
|
|
19
|
+
build produced it, and three values do that::
|
|
20
|
+
|
|
21
|
+
tf_tree.__version__ # the extension build, as a str
|
|
22
|
+
tf_tree.arena_format_version() # the header's set of fields
|
|
23
|
+
f"0x{tf_tree.arena_layout_hash():08X}" # the geometry, as tft prints it
|
|
24
|
+
|
|
25
|
+
They are three because they fail independently: the right version can still
|
|
26
|
+
refuse to attach, because the arena it was pointed at was written by a
|
|
27
|
+
different geometry. The last two are what every participant compares on attach.
|
|
28
|
+
|
|
29
|
+
``__version__`` is compiled in from ``crates/tf_tree_py/Cargo.toml``, and it is
|
|
30
|
+
*not* the canonical answer for the wheel: ``importlib.metadata.version`` is,
|
|
31
|
+
and it reads ``pyproject.toml``. ``tests/python/test_version.py`` asserts the
|
|
32
|
+
two agree, so a disagreement is a stale wheel or a half-applied bump rather
|
|
33
|
+
than two right answers — which is the attribution this whole trio exists to
|
|
34
|
+
get right.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from ._core import (
|
|
38
|
+
BufferError,
|
|
39
|
+
DerivativesUnavailableError,
|
|
40
|
+
DisconnectedError,
|
|
41
|
+
ExtrapolationError,
|
|
42
|
+
FrameNotDeclaredError,
|
|
43
|
+
NoDataError,
|
|
44
|
+
NoSegmentError,
|
|
45
|
+
Plan,
|
|
46
|
+
Publisher,
|
|
47
|
+
TfTreeError,
|
|
48
|
+
TopologyChangedError,
|
|
49
|
+
Tree,
|
|
50
|
+
arena_format_version,
|
|
51
|
+
arena_layout_hash,
|
|
52
|
+
build,
|
|
53
|
+
from_parts,
|
|
54
|
+
from_ros,
|
|
55
|
+
from_sec,
|
|
56
|
+
has_shared_memory,
|
|
57
|
+
open_arena,
|
|
58
|
+
open_file,
|
|
59
|
+
push,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Its own statement, and the redundant alias is not a typo. `__version__` is
|
|
63
|
+
# deliberately absent from `__all__` (the note below it says why), so the alias
|
|
64
|
+
# is what marks it as re-exported — without it ruff reports F401 and a type
|
|
65
|
+
# checker treats the name as private to this module. ruff's isort keeps aliased
|
|
66
|
+
# imports in a separate statement from plain ones, which is why it is down here
|
|
67
|
+
# rather than inside the block above.
|
|
68
|
+
from ._core import (
|
|
69
|
+
__version__ as __version__,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
__all__ = [
|
|
73
|
+
"BufferError",
|
|
74
|
+
"DerivativesUnavailableError",
|
|
75
|
+
"DisconnectedError",
|
|
76
|
+
"ExtrapolationError",
|
|
77
|
+
"FrameNotDeclaredError",
|
|
78
|
+
"NoDataError",
|
|
79
|
+
"NoSegmentError",
|
|
80
|
+
"Plan",
|
|
81
|
+
"Publisher",
|
|
82
|
+
"TfTreeError",
|
|
83
|
+
"TopologyChangedError",
|
|
84
|
+
"Tree",
|
|
85
|
+
"arena_format_version",
|
|
86
|
+
"arena_layout_hash",
|
|
87
|
+
"build",
|
|
88
|
+
"from_parts",
|
|
89
|
+
"from_ros",
|
|
90
|
+
"from_sec",
|
|
91
|
+
"has_shared_memory",
|
|
92
|
+
"open",
|
|
93
|
+
"open_arena",
|
|
94
|
+
"open_file",
|
|
95
|
+
"push",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
# `__version__` is deliberately absent from `__all__` above. It is not an
|
|
99
|
+
# oversight and not a style call: `tests/python/test_stubs.py` asserts `__all__`
|
|
100
|
+
# equals the package's public namespace, and it computes that namespace by
|
|
101
|
+
# skipping underscore-prefixed names — so listing the dunder here would make
|
|
102
|
+
# those two sets differ by exactly this name. `from tf_tree import *` binding a
|
|
103
|
+
# `__version__` is not something anyone wants either.
|
|
104
|
+
|
|
105
|
+
# `open` shadows the builtin inside this module only; the public spelling is
|
|
106
|
+
# `tf_tree.open()`, which is what §4.1 promises.
|
|
107
|
+
open = open_arena # noqa: A001
|
tf_tree/_core.pyd
ADDED
|
Binary file
|
tf_tree/_core.pyi
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""Type stubs for the `tf_tree` extension module.
|
|
2
|
+
|
|
3
|
+
Hand-written, not generated (`docs/PHASE3.md` §9). Generated stubs cannot
|
|
4
|
+
express the scalar-vs-array return overloads on `Plan.at`, and those overloads
|
|
5
|
+
are the most important thing a user needs to see: they are what makes the
|
|
6
|
+
vectorised path the obvious one.
|
|
7
|
+
|
|
8
|
+
The standing hazard with hand-written stubs is not that they are wrong on day
|
|
9
|
+
one — it is that a method added in Rust never reaches them. `tests/python/
|
|
10
|
+
test_stubs.py` closes that: it asserts every public symbol of the built module
|
|
11
|
+
appears here. Signatures are ours; *existence* is checkable, and that is the
|
|
12
|
+
half that rots.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from typing import Literal, overload
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
from numpy.typing import NDArray
|
|
20
|
+
|
|
21
|
+
class TfTreeError(Exception): ...
|
|
22
|
+
class ExtrapolationError(TfTreeError): ...
|
|
23
|
+
class DisconnectedError(TfTreeError): ...
|
|
24
|
+
class NoDataError(TfTreeError): ...
|
|
25
|
+
class TopologyChangedError(TfTreeError): ...
|
|
26
|
+
class FrameNotDeclaredError(TfTreeError): ...
|
|
27
|
+
class BufferError(TfTreeError): ...
|
|
28
|
+
|
|
29
|
+
class DerivativesUnavailableError(TfTreeError):
|
|
30
|
+
"""This edge's interpolator has no exact derivative.
|
|
31
|
+
|
|
32
|
+
Raised only by `layout="quat_twist"`: `LerpSlerp` is `tf2`'s interpolator
|
|
33
|
+
and has no exact body twist, so it is refused rather than
|
|
34
|
+
finite-differenced. Declare the edge `ScLerp` — which is the default — or
|
|
35
|
+
ask for a pose layout.
|
|
36
|
+
|
|
37
|
+
A property of the *edge*, so it fires at element 0 of a batch and does not
|
|
38
|
+
go away on its own. Its sibling `NoSegmentError` is the opposite.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
class NoSegmentError(TfTreeError):
|
|
42
|
+
"""A pose exists at this stamp, but no segment to differentiate.
|
|
43
|
+
|
|
44
|
+
The other refusal `layout="quat_twist"` adds over the pose layouts, and the
|
|
45
|
+
one that is **transient**: the edge retains a single sample, or the two
|
|
46
|
+
samples bracketing the stamp carry equal stamps (which is legal — stamps are
|
|
47
|
+
non-decreasing, not strictly increasing). Publish another sample, or ask
|
|
48
|
+
again later.
|
|
49
|
+
|
|
50
|
+
Distinct from `NoDataError`, which means the edge is empty. Here the
|
|
51
|
+
transform is perfectly well defined and only the derivative is not, so being
|
|
52
|
+
told "no data" would send you to the wrong problem. A property of the
|
|
53
|
+
*stamp*, so it can fire partway through a batch.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
F32Layout = Literal["affine32"]
|
|
57
|
+
"""The one layout that writes `float32`."""
|
|
58
|
+
|
|
59
|
+
F64Layout = Literal["mat4", "quat", "quat_twist"]
|
|
60
|
+
"""The layouts that write `float64`."""
|
|
61
|
+
|
|
62
|
+
Layout = Literal["mat4", "quat", "affine32", "quat_twist"]
|
|
63
|
+
"""How a transform is written into memory. Stated, never inferred.
|
|
64
|
+
|
|
65
|
+
`"mat4"` is `(4, 4)` / `(N, 4, 4)` float64; `"quat"` is `(7,)` / `(N, 7)`
|
|
66
|
+
float64 as `[qw qx qy qz tx ty tz]`; `"affine32"` is `(12,)` / `(N, 12)`
|
|
67
|
+
**float32**, row-major 3x4, GPU-facing; `"quat_twist"` is `(13,)` / `(N, 13)`
|
|
68
|
+
float64, `"quat"` with the body twist `[wx wy wz vx vy vz]` appended.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
class Plan:
|
|
72
|
+
"""A compiled lookup path. Build with `Tree.plan`."""
|
|
73
|
+
|
|
74
|
+
@overload
|
|
75
|
+
def at(self, stamps: int, /) -> NDArray[np.float64]:
|
|
76
|
+
"""One stamp in, one `(4, 4)` float64 transform out.
|
|
77
|
+
|
|
78
|
+
The default layout is `"mat4"`, which is float64 — so this returns
|
|
79
|
+
`NDArray[np.float64]`, not a union a caller has to narrow. Only
|
|
80
|
+
`layout="affine32"` produces float32, and it has its own overload.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
@overload
|
|
84
|
+
def at(self, stamps: NDArray[np.int64], /) -> NDArray[np.float64]:
|
|
85
|
+
"""`(N,)` stamps in, `(N, 4, 4)` float64 out — the path to prefer.
|
|
86
|
+
|
|
87
|
+
A Python loop over the scalar form costs ~200 ns per iteration; this
|
|
88
|
+
amortises to near-native.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
@overload
|
|
92
|
+
def at(
|
|
93
|
+
self, stamps: int | NDArray[np.int64], /, *, layout: F32Layout
|
|
94
|
+
) -> NDArray[np.float32]:
|
|
95
|
+
"""`layout="affine32"`: `(12,)` or `(N, 12)` **float32**, row-major 3x4."""
|
|
96
|
+
|
|
97
|
+
@overload
|
|
98
|
+
def at(
|
|
99
|
+
self, stamps: int | NDArray[np.int64], /, *, layout: F64Layout | None = ...
|
|
100
|
+
) -> NDArray[np.float64]:
|
|
101
|
+
"""`layout=` selects what is written per stamp (see `Layout`).
|
|
102
|
+
|
|
103
|
+
Keyword-only; `stamps` stays positional-only, which is where the
|
|
104
|
+
measured 29 ns of `METH_FASTCALL` lives.
|
|
105
|
+
|
|
106
|
+
`layout="quat_twist"` is `at_with_derivatives` as a batch: it appends
|
|
107
|
+
the body twist, in the plan's **source** frame, angular part first. It
|
|
108
|
+
is the only layout that can raise `DerivativesUnavailableError` or
|
|
109
|
+
`NoSegmentError`.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
@overload
|
|
113
|
+
def at(
|
|
114
|
+
self, stamps: int | NDArray[np.int64], /, *, layout: Layout | None = ...
|
|
115
|
+
) -> NDArray[np.float64] | NDArray[np.float32]:
|
|
116
|
+
"""The fallback, for a `layout` whose value is not statically known.
|
|
117
|
+
|
|
118
|
+
Passing a variable of type `Layout` cannot resolve to one dtype, so this
|
|
119
|
+
is the only overload that hands back a union — and it is reached only by
|
|
120
|
+
a caller who genuinely does not know which layout they are asking for.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
@overload
|
|
124
|
+
def at_into(
|
|
125
|
+
self, stamps: int, out: object, /, *, layout: Layout | None = ...
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Evaluate one stamp into a caller-provided `(4, 4)` float64 array.
|
|
128
|
+
|
|
129
|
+
**The allocation-free scalar path, for a control loop.** A node does one
|
|
130
|
+
lookup per tick and cannot batch, so `at`'s per-call array allocation is
|
|
131
|
+
paid every tick forever. Measured on a depth-3 chain, release build:
|
|
132
|
+
`at` 224 ns against `at_into` **173 ns**, and nothing allocated.
|
|
133
|
+
|
|
134
|
+
Allocate `out` once, outside the loop.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
@overload
|
|
138
|
+
def at_into(
|
|
139
|
+
self, stamps: NDArray[np.int64], out: object, /, *, layout: Layout | None = ...
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Evaluate into a caller-provided `(N, 4, 4)` float64 array.
|
|
142
|
+
|
|
143
|
+
With `layout=`, `out` is `(N, layout_elems)` — or `(layout_elems,)` for
|
|
144
|
+
a scalar stamp — and `float32` for `"affine32"`, `float64` otherwise.
|
|
145
|
+
Every batch entry point has an `_into` form (`API.md` R2), so a layout
|
|
146
|
+
reachable only through the allocating call would be a batch path with
|
|
147
|
+
no allocation-free tier.
|
|
148
|
+
|
|
149
|
+
Allocates nothing. `out` must be C-contiguous and exactly the right
|
|
150
|
+
shape; it is validated completely *before* any element is written, so a
|
|
151
|
+
rejected call leaves it untouched.
|
|
152
|
+
|
|
153
|
+
Raises `BufferError` on a wrong shape, dtype or stride. Non-contiguous
|
|
154
|
+
input is refused rather than silently copied — a silent copy would
|
|
155
|
+
defeat the point of this method while appearing to work.
|
|
156
|
+
|
|
157
|
+
**With `layout=`, a bad `stamps` is reported the way `at` reports it**
|
|
158
|
+
— numpy's or PyO3's own conversion `TypeError` ("only integer scalar
|
|
159
|
+
arrays can be converted to a scalar index" for a float64 array) rather
|
|
160
|
+
than a `BufferError` naming `(N,) int64`. That is the trade for the two
|
|
161
|
+
things it bought: an `np.int64` scalar is accepted, and a `float` stamp
|
|
162
|
+
meets the `TypeError` carrying the 238 ns measurement instead of a
|
|
163
|
+
complaint about a buffer. The default `mat4` path still gives the
|
|
164
|
+
shape-naming `BufferError`, and still refuses `np.int64`; the
|
|
165
|
+
difference is in `Plan.at_into.__doc__`.
|
|
166
|
+
|
|
167
|
+
`out` is typed `object` rather than `NDArray` because the device check
|
|
168
|
+
below accepts anything and then refuses it by message. **Only
|
|
169
|
+
`numpy.ndarray` is written to** (subclasses included); a `memoryview`,
|
|
170
|
+
or a pinned torch or CuPy allocation, is refused whatever its layout.
|
|
171
|
+
`PHASE3.md` §5.5 describes those as qualifying and that is **not
|
|
172
|
+
implemented** — `np.asarray(...)` first.
|
|
173
|
+
|
|
174
|
+
**Device memory is refused** with a message naming the fix: a CPU store
|
|
175
|
+
to a `cudaMalloc` pointer is undefined, not slow.
|
|
176
|
+
|
|
177
|
+
A genuine `numpy.ndarray` skips the device check, because its data
|
|
178
|
+
pointer is host memory by construction; CuPy and torch arrays are not
|
|
179
|
+
numpy subclasses, so they still pay for it. The probe is a Python method
|
|
180
|
+
call — `__dlpack_device__()` — and running it on every numpy call cost
|
|
181
|
+
~120 ns of a ~173 ns lookup.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
def adaptive(
|
|
185
|
+
self,
|
|
186
|
+
start_ns: int,
|
|
187
|
+
end_ns: int,
|
|
188
|
+
/,
|
|
189
|
+
*,
|
|
190
|
+
lin: float = ...,
|
|
191
|
+
ang: float = ...,
|
|
192
|
+
) -> tuple[NDArray[np.int64], NDArray[np.float64]]:
|
|
193
|
+
"""Knots whose linear interpolation stays within `lin` m / `ang` rad.
|
|
194
|
+
|
|
195
|
+
Returns `(stamps, poses)` of shapes `(K,)` and `(K, 4, 4)`, strictly
|
|
196
|
+
increasing. LERP between adjacent knots on whatever device they live
|
|
197
|
+
on; the reconstruction error is bounded by construction.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
def latest(self) -> NDArray[np.float64]:
|
|
201
|
+
"""The most recent transform on this path, as `(4, 4)`."""
|
|
202
|
+
|
|
203
|
+
def depth(self) -> int:
|
|
204
|
+
"""Folded depth of this path, in edges."""
|
|
205
|
+
|
|
206
|
+
def edges(self) -> list[tuple[str, str]]:
|
|
207
|
+
"""The **dynamic** edges this plan samples, as `(parent, child)` pairs.
|
|
208
|
+
|
|
209
|
+
In fold order — the order the compositions happen, which is the order
|
|
210
|
+
the plan is.
|
|
211
|
+
|
|
212
|
+
Shorter than `depth()` when the path crosses a static edge. A static
|
|
213
|
+
edge (or a whole run of them) is folded into one constant transform at
|
|
214
|
+
compile time and its identity does not survive the fold, so a plan
|
|
215
|
+
cannot list what it no longer knows. Use `Tree.edges()` for the
|
|
216
|
+
topology; this is what *this path samples at evaluation time*.
|
|
217
|
+
|
|
218
|
+
Raises `TfTreeError` on a tree inherited across a `fork()`.
|
|
219
|
+
"""
|
|
220
|
+
|
|
221
|
+
class Publisher:
|
|
222
|
+
"""A claimed edge. Use as a context manager; the claim releases on exit."""
|
|
223
|
+
|
|
224
|
+
def __enter__(self) -> Publisher: ...
|
|
225
|
+
def __exit__(self, *args: object) -> bool: ...
|
|
226
|
+
def release(self) -> None:
|
|
227
|
+
"""Drop the claim now, rather than at an unspecified finalization."""
|
|
228
|
+
|
|
229
|
+
def push(self, stamp_ns: int, quat7: list[float], /) -> None:
|
|
230
|
+
"""Publish `[qw, qx, qy, qz, tx, ty, tz]` at `stamp_ns`."""
|
|
231
|
+
|
|
232
|
+
def push_many(
|
|
233
|
+
self, stamps: NDArray[np.int64], poses: NDArray[np.float64], /
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Publish `(N,)` stamps and `(N, 7)` poses in one crossing."""
|
|
236
|
+
|
|
237
|
+
class Tree:
|
|
238
|
+
"""A transform tree. Obtain with `tf_tree.open()` or `tf_tree.build()`."""
|
|
239
|
+
|
|
240
|
+
def plan(self, target: str, source: str, /) -> Plan:
|
|
241
|
+
"""Compile a path from `source` to `target`.
|
|
242
|
+
|
|
243
|
+
Compile once and reuse: the path walk and per-edge metadata lookup
|
|
244
|
+
happen here, not per sample.
|
|
245
|
+
"""
|
|
246
|
+
|
|
247
|
+
def publisher(self, child: str, parent: str, /) -> Publisher:
|
|
248
|
+
"""Claim `child`'s edge. Argument order is **(child, parent)**."""
|
|
249
|
+
|
|
250
|
+
def lookup(self, target: str, source: str, stamp_ns: int, /) -> NDArray[np.float64]:
|
|
251
|
+
"""One transform, without compiling a plan first.
|
|
252
|
+
|
|
253
|
+
The plan is cached per *thread*. Prefer `tree.plan(...)` in a loop —
|
|
254
|
+
this pays a cache probe per call and a compiled plan pays nothing.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
def freeze(
|
|
258
|
+
self, path: str | os.PathLike[str], /, *, source: str | None = ...
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Write this tree to `path` as a frozen `.tft` (`PHASE5.md` §2.3).
|
|
261
|
+
|
|
262
|
+
The file *is* the arena: `open_file` maps it back with no parse and no
|
|
263
|
+
fixups, and the lookups it answers are bit-identical to this tree's.
|
|
264
|
+
|
|
265
|
+
Replacing `path` is atomic — the bytes land in a sibling temporary and
|
|
266
|
+
are renamed over it — so an interrupted freeze leaves the previous index
|
|
267
|
+
intact instead of a half-written one under the name somebody will open
|
|
268
|
+
next week.
|
|
269
|
+
|
|
270
|
+
`source` is the recording these poses came from; it is recorded in the
|
|
271
|
+
manifest as `null` when there is none. Linux only.
|
|
272
|
+
|
|
273
|
+
The GIL is released for the copy, so a background freeze does not stall
|
|
274
|
+
the threads servicing your progress bar or socket.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
def span(self, target: str, source: str, /) -> tuple[int, int] | None:
|
|
278
|
+
"""The interval, in nanoseconds, over which `plan(target, source)` answers.
|
|
279
|
+
|
|
280
|
+
`LatestCommon` generalised to a range: the *intersection* of every
|
|
281
|
+
dynamic edge's retained window, so the lower end is a `max` and the
|
|
282
|
+
upper end a `min`. It is the query to reach for when a lookup fails at
|
|
283
|
+
a stamp, because the answer is nearly always "one edge on the path had
|
|
284
|
+
not started yet".
|
|
285
|
+
|
|
286
|
+
Three distinct answers:
|
|
287
|
+
|
|
288
|
+
* `(t0, t1)` with `t0 <= t1` — answerable there, nowhere else.
|
|
289
|
+
* `(t0, t1)` with `t0 > t1` — the windows do not overlap. That is a real
|
|
290
|
+
answer, not an error: `t0 <= t <= t1` is correctly false everywhere.
|
|
291
|
+
* `None` — every step on the path is static (or the path is empty), so
|
|
292
|
+
the plan answers at *any* stamp and there is no finite interval.
|
|
293
|
+
|
|
294
|
+
Raises `NoDataError`, naming the edge's two **frames**, when an edge on
|
|
295
|
+
the path has no samples at all — which is a different situation from a
|
|
296
|
+
non-overlapping window and calls for a different fix. Raises
|
|
297
|
+
`TopologyChangedError` if the tree was re-parented under the call.
|
|
298
|
+
|
|
299
|
+
On a live tree the answer is a snapshot that ages immediately, exactly
|
|
300
|
+
as `Plan.latest` does.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
def frames(self) -> list[str]:
|
|
304
|
+
"""The frame names on this tree, in declaration order.
|
|
305
|
+
|
|
306
|
+
The cheap way to see what is in an arena without shelling out to
|
|
307
|
+
`tf_tree doctor`. Frame identity is append-only, so a name that appears
|
|
308
|
+
here will never be removed or renumbered — but on a *live* shared arena
|
|
309
|
+
a peer process can add one under you, so treat the list as a snapshot,
|
|
310
|
+
exactly as `Plan.latest` and `Tree.span` already are.
|
|
311
|
+
|
|
312
|
+
A name longer than 48 bytes was truncated when it was interned; the
|
|
313
|
+
stored form is what comes back.
|
|
314
|
+
|
|
315
|
+
**The list is not guaranteed to be free of duplicates.** If a peer
|
|
316
|
+
process stalls mid-intern and another rescues the slot, the loser's
|
|
317
|
+
record stays written but unreferenced, so the same name can appear at
|
|
318
|
+
two ids. Rare — it needs the rescue path — but it means `len()` is an
|
|
319
|
+
upper bound and `dict(zip(tree.frames(), ...))` can lose an entry.
|
|
320
|
+
|
|
321
|
+
Raises `TfTreeError` on a tree inherited across a `fork()` — the child's
|
|
322
|
+
mapping is gone, so there is nothing to list.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
def edges(self) -> list[tuple[str, str]]:
|
|
326
|
+
"""The edges on this tree, as `(parent, child)` name pairs.
|
|
327
|
+
|
|
328
|
+
`(parent, child)` is the order `tf_tree.build` and `tf_tree.open(
|
|
329
|
+
create=...)` take. It is deliberately *not* `Tree.publisher`'s
|
|
330
|
+
`(child, parent)` order: an edge list silently reversed builds a tree
|
|
331
|
+
that is upside down and still perfectly valid.
|
|
332
|
+
|
|
333
|
+
**This is the parent/child graph, not a round trip.** The list does not
|
|
334
|
+
say whether an edge is static or dynamic, and `tf_tree.build` has no way
|
|
335
|
+
to declare a static one — so feeding it back reproduces the graph, but
|
|
336
|
+
every static edge comes back as a dynamic edge with no samples, and a
|
|
337
|
+
lookup crossing one raises `NoDataError` instead of returning the
|
|
338
|
+
constant it had. On a tree you built with `tf_tree.build` every edge is
|
|
339
|
+
already dynamic and the distinction cannot arise; on a `.tft` or on an
|
|
340
|
+
arena a Rust or C peer created, it can.
|
|
341
|
+
|
|
342
|
+
**Names only** — no rate, no jitter, no gaps, no sample count. Those are
|
|
343
|
+
`PHASE5.md` §4.2's `ds.edges()` and are held back until the counting
|
|
344
|
+
pass that can answer them honestly exists: a ring knows what it
|
|
345
|
+
*retained*, which is not what the publisher produced, and a rate derived
|
|
346
|
+
from the one and reported as the other is worse than no rate at all.
|
|
347
|
+
|
|
348
|
+
Raises `TfTreeError` on a tree inherited across a `fork()`.
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
def instance_uuid(self) -> str:
|
|
352
|
+
"""Which arena instance this is, as 32 hex characters.
|
|
353
|
+
|
|
354
|
+
All-zero in-process. Two processes that resolved the same *name* can
|
|
355
|
+
still hold different segments; this is what tells them apart.
|
|
356
|
+
|
|
357
|
+
Raises `TfTreeError` on a tree inherited across a `fork()`, rather than
|
|
358
|
+
returning the all-zero value the child's poison mapping holds — which is
|
|
359
|
+
the spelling that means "in-process", so two peers chasing a split brain
|
|
360
|
+
would conclude they had never been shared. `repr()` does not raise; it
|
|
361
|
+
prints `detached-by-fork` in place of the instance.
|
|
362
|
+
"""
|
|
363
|
+
|
|
364
|
+
def is_shared(self) -> bool:
|
|
365
|
+
"""Whether this tree's arena is shared with other processes."""
|
|
366
|
+
|
|
367
|
+
def is_writable(self) -> bool:
|
|
368
|
+
"""Whether this process may publish into this tree."""
|
|
369
|
+
|
|
370
|
+
def build(
|
|
371
|
+
edges: list[tuple[str, str]],
|
|
372
|
+
*,
|
|
373
|
+
capacity: int = ...,
|
|
374
|
+
interp: Literal["sclerp", "lerpslerp"] = ...,
|
|
375
|
+
frame_headroom: int = ...,
|
|
376
|
+
) -> Tree:
|
|
377
|
+
"""An in-process tree from `(parent, child)` edges.
|
|
378
|
+
|
|
379
|
+
Topology is builder-time (decision `0004`), so there is no `declare_*` on a
|
|
380
|
+
live tree: the layout is a property of the arena, fixed when it is created.
|
|
381
|
+
|
|
382
|
+
`interp` defaults to `"sclerp"` — the SE(3) screw geodesic, which is the
|
|
383
|
+
engine's own default and the only policy with an exact derivative, so
|
|
384
|
+
`plan.at(stamps, layout="quat_twist")` works on a tree built this way.
|
|
385
|
+
|
|
386
|
+
Pass `interp="lerpslerp"` for `tf2`-bit-compatible interpolation
|
|
387
|
+
(translation LERP + rotation SLERP). It is not right-invariant —
|
|
388
|
+
interpolating `T0 @ C, T1 @ C` is not `interp(T0, T1) @ C` — and it has no
|
|
389
|
+
exact body twist, so `layout="quat_twist"` over such an edge raises
|
|
390
|
+
`DerivativesUnavailableError`.
|
|
391
|
+
|
|
392
|
+
This binding hard-coded `"lerpslerp"` until now, diverging from Rust with no
|
|
393
|
+
measurement behind it; `PROJECT.md` §5 D5 requires one, so the default moved
|
|
394
|
+
rather than the rule.
|
|
395
|
+
"""
|
|
396
|
+
|
|
397
|
+
def push(
|
|
398
|
+
tree: Tree, child: str, parent: str, stamp_ns: int, quat7: list[float], /
|
|
399
|
+
) -> None:
|
|
400
|
+
"""Publish `[qw, qx, qy, qz, tx, ty, tz]` onto an edge at `stamp_ns`.
|
|
401
|
+
|
|
402
|
+
Takes the engine's own representation rather than a 4x4: a *nearly* rigid
|
|
403
|
+
matrix — which is what arrives after any floating-point round trip — has no
|
|
404
|
+
exact conversion back, only a projection.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
def open_arena(
|
|
408
|
+
*,
|
|
409
|
+
name: str | None = ...,
|
|
410
|
+
domain: int | None = ...,
|
|
411
|
+
mode: Literal["ro", "rw"] = ...,
|
|
412
|
+
create: list[tuple[str, str]] | None = ...,
|
|
413
|
+
capacity: int = ...,
|
|
414
|
+
interp: Literal["sclerp", "lerpslerp"] = ...,
|
|
415
|
+
frame_headroom: int = ...,
|
|
416
|
+
) -> Tree:
|
|
417
|
+
"""Attach to a running arena. Exported as `tf_tree.open`.
|
|
418
|
+
|
|
419
|
+
`mode="ro"` by default and `create=None`: a consumer must be incapable of
|
|
420
|
+
corrupting a robot's tree (the MMU enforces it), and a notebook started
|
|
421
|
+
before the robot must fail loudly rather than create an empty arena the
|
|
422
|
+
real publisher then refuses to join.
|
|
423
|
+
|
|
424
|
+
Pass `create=[(parent, child), ...]` — the same edge list `build` takes —
|
|
425
|
+
to create the arena when it is absent. An arena is sized from its declared
|
|
426
|
+
edges, so there is no way to create one without saying what is in it; that
|
|
427
|
+
is why this is an edge list rather than a boolean. **It requires
|
|
428
|
+
`mode="rw"`** and is refused otherwise, so a read-only consumer still
|
|
429
|
+
cannot bring an arena into existence.
|
|
430
|
+
|
|
431
|
+
`capacity` and `interp` describe the edges being created; both are
|
|
432
|
+
`build`'s, with the same defaults. Without `create` they describe nothing —
|
|
433
|
+
but `interp` is still validated, so a misspelling raises here exactly as it
|
|
434
|
+
does in `build` rather than being silently discarded.
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
def open_file(path: str | os.PathLike[str], /) -> Tree:
|
|
438
|
+
"""Open a frozen `.tft` and read it as an ordinary `Tree` (`PHASE5.md` §4.1).
|
|
439
|
+
|
|
440
|
+
Opening is an `mmap`, so it costs microseconds and no parse — and it hands
|
|
441
|
+
back the **same** `Tree` a live arena does. `plan`, `at`, `at_into`,
|
|
442
|
+
`adaptive`, `latest` and `span` are the objects that were already there,
|
|
443
|
+
with the same semantics and bit-identical results. There is no offline API
|
|
444
|
+
to learn.
|
|
445
|
+
|
|
446
|
+
The tree is permanently read-only: `is_writable()` is `False` and
|
|
447
|
+
`publisher()` refuses, because the mapping is `PROT_READ` and a store
|
|
448
|
+
through it would be a fault rather than an error.
|
|
449
|
+
|
|
450
|
+
Raises `FileNotFoundError` (and its `OSError` siblings) for a path problem,
|
|
451
|
+
and `TfTreeError` for a file that is not a readable `.tft` — a layout or
|
|
452
|
+
format mismatch names both values and says to re-freeze, since a `.tft` is a
|
|
453
|
+
cache and not an archive.
|
|
454
|
+
|
|
455
|
+
**Dataloader pattern (§4.3).** Documented, not shipped: a
|
|
456
|
+
`torch.utils.data.Dataset` subclass would bind this package to a framework
|
|
457
|
+
version for no benefit, and the pattern is four lines::
|
|
458
|
+
|
|
459
|
+
class Frames(Dataset):
|
|
460
|
+
def __init__(self, path):
|
|
461
|
+
self.path, self.ds = path, None
|
|
462
|
+
|
|
463
|
+
def __getitem__(self, i):
|
|
464
|
+
if self.ds is None: # per worker
|
|
465
|
+
self.ds = tf_tree.open_file(self.path)
|
|
466
|
+
...
|
|
467
|
+
|
|
468
|
+
Open it **in the worker, not in the parent**. A `Tree` cannot be pickled,
|
|
469
|
+
and a `DataLoader` with `num_workers > 0` sends the dataset object to its
|
|
470
|
+
workers — by pickle under `spawn` and `forkserver`, which is CPython 3.14's
|
|
471
|
+
default start method on Linux. The lazy `None` is what keeps the object
|
|
472
|
+
picklable.
|
|
473
|
+
|
|
474
|
+
Under a plain `fork` an inherited `.tft` mapping does keep working: it is
|
|
475
|
+
`MAP_PRIVATE | PROT_READ` and is deliberately *not* poisoned at fork, unlike
|
|
476
|
+
a shared-memory attach. So the rule is about picklability, not about the
|
|
477
|
+
arena going away — §4.3 gives the fork-poisoning reason and that reason does
|
|
478
|
+
not apply to a frozen file.
|
|
479
|
+
|
|
480
|
+
Sixteen workers that each open the same file share one set of clean
|
|
481
|
+
page-cache pages, so the marginal cost per worker is about zero. That is the
|
|
482
|
+
entire argument for `.tft` (§2.2), and opening once in the parent and
|
|
483
|
+
passing poses down instead gives it up.
|
|
484
|
+
"""
|
|
485
|
+
|
|
486
|
+
def from_sec(seconds: float, /) -> int:
|
|
487
|
+
"""Nanoseconds from float seconds. Lossy above ~10^7 s.
|
|
488
|
+
|
|
489
|
+
Prefer the exact converters: `from_parts` for a `(sec, nanosec)` pair and
|
|
490
|
+
`from_ros` for a `builtin_interfaces/Time`. Neither takes a float.
|
|
491
|
+
"""
|
|
492
|
+
|
|
493
|
+
def from_parts(sec: int, nanosec: int, /) -> int:
|
|
494
|
+
"""Exact nanoseconds from a `(sec, nanosec)` pair.
|
|
495
|
+
|
|
496
|
+
Raises `ValueError` for a `nanosec` outside `[0, 1e9)` — **refused, not
|
|
497
|
+
normalised** — and for a sum outside `int64` — **refused, not wrapped**.
|
|
498
|
+
Both alternatives produce a stamp that looks perfectly well formed, which
|
|
499
|
+
is the failure this converter exists to prevent.
|
|
500
|
+
"""
|
|
501
|
+
|
|
502
|
+
def from_ros(stamp: object, /) -> int:
|
|
503
|
+
"""Exact nanoseconds from a ROS 2 `builtin_interfaces/Time`.
|
|
504
|
+
|
|
505
|
+
Never via `to_sec()`: the message is `{int32 sec, uint32 nanosec}` and
|
|
506
|
+
converts exactly. Duck-typed on `.sec` and `.nanosec`, so `rclpy` is not a
|
|
507
|
+
dependency of this wheel and must not become one.
|
|
508
|
+
|
|
509
|
+
Refusals are `from_parts`'s.
|
|
510
|
+
"""
|
|
511
|
+
|
|
512
|
+
def has_shared_memory() -> bool:
|
|
513
|
+
"""Whether this build can share a tree between processes."""
|
|
514
|
+
|
|
515
|
+
__version__: str
|
|
516
|
+
"""This extension's version, compiled in from the crate manifest.
|
|
517
|
+
|
|
518
|
+
`importlib.metadata.version("transform_tree")` is the canonical answer and reads a
|
|
519
|
+
different file — `pyproject.toml`'s `[project] version` against this one's
|
|
520
|
+
`Cargo.toml`. `tests/python/test_version.py` asserts the two agree, which is
|
|
521
|
+
the only thing that keeps them from drifting.
|
|
522
|
+
"""
|
|
523
|
+
|
|
524
|
+
def arena_format_version() -> int:
|
|
525
|
+
"""This build's arena format version — the *set of fields* in the header.
|
|
526
|
+
|
|
527
|
+
3 as of `PHASE5.md` §1. A different one is never compatible: there is no
|
|
528
|
+
conversion layer, so every participant is rebuilt from one commit and
|
|
529
|
+
restarted together.
|
|
530
|
+
"""
|
|
531
|
+
|
|
532
|
+
def arena_layout_hash() -> int:
|
|
533
|
+
"""This build's arena layout hash — the *geometry*.
|
|
534
|
+
|
|
535
|
+
Checked on attach beside `arena_format_version()`, and a mismatch on either
|
|
536
|
+
is refused. Two builds agreeing on the version and disagreeing on the hash
|
|
537
|
+
disagree about *where* things are, which is worse than disagreeing about
|
|
538
|
+
what they are.
|
|
539
|
+
|
|
540
|
+
An `int`, because it is compared rather than read. For a report write
|
|
541
|
+
`f"0x{tf_tree.arena_layout_hash():08X}"` — the literal `0x` matters, since
|
|
542
|
+
Python's `{:#010X}` produces `0X…` and would not match what
|
|
543
|
+
`tft doctor --explain-version` prints.
|
|
544
|
+
"""
|
tf_tree/py.typed
ADDED
|
File without changes
|