Results: Track, GateSequence, Centerline¶
A generator’s output is a result object: a Track (TrackGenerator), a
GateSequence (GateGenerator) or a Centerline (a centerline with no road band,
such as the spline a gates-mode Course threads through its gates). This page describes
their data model, how to read them on the host, invalid envs, the
Layout that gives their shape, the methods that check, slice, copy and allocate them,
caller-owned results (out=) and mixed batches, and the capability protocols the
runtime utilities accept.
track_gen.results is the module of all of it: Layout, Track,
GateSequence, Centerline, the protocols HasCenterline, HasBand,
HasGates, HasGateFrames, HasGeneration, HasValidity and
HasCheckpoints, and check_source. track_gen re-exports Track,
GateSequence, Layout and the seven protocols (the same objects), and
track_gen.plugin.Layout (the shape a stage is built for) is the same Layout.
from track_gen import TrackGenerator
from track_gen.pipeline import presets
from track_gen.results import Layout, Track
gen = TrackGenerator(presets.track(num_envs=8, device="cuda:0")) # or "cpu"
track = gen.generate() # the generator's result (the same object every call)
track.layout # Layout(num_envs=8, stride=384, device='cuda:0', closed=True)
track.validate() # the same Layout: every field checked
half = track.view(0, 4) # envs 0..3, zero-copy
snap = Track.alloc(track.layout) # preallocated, cold
snap.copy_from(track) # one copy per field, no allocation: the hot-path snapshot
The data model¶
Every array of a result is a flat, contiguous, 1-D Warp array on one device, in one of two forms:
per point,
[E * stride]: enveowns the rowse * stridetoe * stride + stride - 1, and its firstcount[e]rows are real. The rows pastcount[e]hold the padding: NaN in thefloat32fields and in every component of a float attribute,-1in anint32attribute, and NaN in the x and y of thevec3fpoint fields. A generatedTrack’s z component holds what the elevation stage wrote there (withelevation="flat":z_baseinouter/center/inner, 0 intangent/normal), so testi >= count[e]orisnan(x), not every component.GateSequencefields, aCenterlineand analloc()result are NaN in every component.strideis the per-env row capacity: N_max for aTrack,max_gatesfor aGateSequence, and for aCenterlineits own capacity (max_gates * samples_per_gatefor a gates-modeCourse’sCourseLine.centerline).per env,
[E]:count,valid,length,winding,generation,fail_mask,truncated.
A generator allocates its result once, at construction, and returns the same instance
with the same array addresses on every call: a CUDA graph recorded against it stays
valid for the generator’s lifetime. fail_bits (the names of fail_mask’s bits),
attributes (the per-point plugin attributes the pipeline exports, see
Per-point attributes in the result) and layout are metadata, not device arrays.
generation (its generation) is the epoch each env’s rows were built with
(Seeds, epochs and the generation counter); valid, fail_mask and truncated are covered in
Invalid envs.
Reading results on the host¶
.numpy() copies an array to the host (it waits for the device). Reshape a per-point
field to [E, stride, 3] and keep each env’s first count[e] rows; skip the
invalid envs:
from track_gen import TrackGenerator
from track_gen.pipeline import presets
E, device = 64, "cuda:0" # or "cpu"
gen = TrackGenerator(presets.track(num_envs=E, device=device))
track = gen.generate()
S = gen.n_max # the stride of a Track
center = track.center.numpy().reshape(E, S, 3) # vec3f -> (x, y, z)
count = track.count.numpy() # [E] real points per env
valid = track.valid.numpy() != 0 # [E] bool
loops = [center[e, :count[e]] for e in range(E) if valid[e]] # [count[e], 3] each
The same holds for a GateSequence with stride = max_gates
(seq.position.numpy().reshape(E, G, 3)), and for torch with wp.to_torch
(zero-copy, on the device: Generating a Batch of Tracks). The arrays are rewritten
in place by the next generation: copy what you keep (.numpy() copies; on the device,
clone or copy_from, Views, copies and snapshots).
Layout¶
Layout(num_envs, stride, device, closed=True) # frozen
num_envs(E >= 1) andstride(>= 1) are integers; a NumPy integer is accepted and stored as anint, aboolraisesTypeError, a value below 1ValueError.deviceis a string, stored as given:Layout(8, 384, "cuda")is legal, and constructing it touches no device (so a CPU-only host can build CUDA layouts).closedis abool(TypeErrorotherwise): whether the loops are closed. OnlyTrueis accepted by the pipeline and the utilities in 0.x (see Topology).
Layout equality is the dataclass equality of the stored values. Every check the
library makes (validate against a stored layout, out=, copy_from,
check_source, the conformance kit) compares layouts by their canonical form
instead, with the device resolved through wp.get_device at check time, so a hand-set
Layout(E, S, "cuda") matches a pipeline’s "cuda:0". Every layout the library
makes (the planner’s, alloc’s, view’s, an inferred one) stores the canonical
device string, so == holds between them.
result.layout is set on every result a generator, view, alloc or clone
returns. It is None on a result you build by hand: validate() then infers it
(and never stores it), and a build with out= sets it on the object you passed when
it was None. program.layout is the pipeline’s layout: the layout of the
result it allocates, and the canonical equal of an out= result’s.
Schemas¶
Each class has a fixed schema: the arrays validate checks, with their dtype and form.
An optional field may be None (a hand-built result); the others are required.
Class |
Per point, |
Per env, |
Optional |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
An attribute (an entry of attributes) is a per-point [E * stride] array of
float32, int32, vec2f or vec3f.
validate(need=())¶
result.validate() checks every array of the result and returns its Layout:
countmust be awp.array: it givesE.The layout is
result.layoutwhen it is set (it must be aLayout), else inferred:E = count.shape[0],stridefrom the first per-point field (center;positionfor gates) divided byE(a length thatEdoes not divide raises), the device ofcount,closed=True.Every schema field that is not
Nonemust be a 1-D contiguous array of its dtype,[E * stride]or[E], on the layout’s device (a 2-D[E, stride]array is named as such:expected a flat 1-D [E*stride] array, got a 2-D array of shape (4, 384) (flatten it, e.g. arr.reshape((-1,)))). A field named inneed(a name or a tuple of names) must not beNone.Every attribute must be a 1-D contiguous
[E * stride]array of an attribute dtype on that device.
It raises ValueError with the field, what was expected and what it got:
Track.inner: expected a contiguous [3072] vec3f ([E*stride] = [8*384]) array on cpu,
got shape (3064,) vec3f on cpu
Track.winding: expected a contiguous [8] float32 ([E] = [8]) array on cpu,
got shape (7,) float32 on cpu
Track.half_width is None; this consumer needs it
validate runs on the host only (a few microseconds), launches nothing, never syncs
and never mutates the result, so it is safe anywhere, a capture included. It is
explicit: constructing a Track(...) by hand checks nothing (a partial Track
is a legal value), and the library calls validate where every field is about to be
used: out=, view, copy_from and clone. The runtime utilities check only
the members they read (Capability protocols).
Views, copies and snapshots¶
view(e0, e1)An env-slice view of envs
e0toe1 - 1: the same class (a subclass is kept), whose per-point arrays arearr[e0 * S : e1 * S], per-env arraysarr[e0 : e1]and attributes sliced alike;Nonefields stayNone,fail_bitsis shared, andlayoutisLayout(e1 - e0, S, device, closed). A Warp slice of a 1-D array is a pointer offset with the same element stride, so a view is contiguous, allocates nothing, launches nothing and keeps its parent’s memory alive. It validates the parent first and raisesIndexErrorunless0 <= e0 < e1 <= E(Track.view(5, 9): env range outside [0, 8);Track.view(2, 2): empty env range: need e0 < e1). Views of views work, and a view remembers its env offset in its root result (the seeds rule of Mixed batches). Callable anywhere, inside a capture too.copy_from(src, *, stream=None, ctx=None)Copies
srcinto this result: onectx.copyper array this result holds.srcmust be an instance of the same class (TypeError) with the same canonical layout (ValueError:Track.copy_from: layouts differ: this Track is Layout(4, 384, 'cpu', True), src is Layout(8, 384, 'cpu', True)). A fieldNonehere is skipped; a field held here butNoneinsrcraises; every attribute key here must be insrcwith the same dtype (extra keys insrcare ignored). The same array on both sides is skipped (t.copy_from(t)is a no-op), and a partial overlap raisesValueErrornaming the field. It allocates nothing and never syncs: under a capture it records one copy node per array. This is the hot-path snapshot: copy into a result preallocated withalloc.clone(*, stream=None)A new result that owns its memory: one new array per held field and attribute,
Nonefields kept, the class kept, the samelayout, andfail_bitsshared (the same object). It validates first, so a malformed hand-built result raises. It allocates and blocks on every call: keep it off the hot path, and usecopy_frominto anallocresult instead. It raisesCaptureErrorbefore allocating under any capture (a Warp CPU or CUDA capture of Warp’s current stream or of the generator’s stream, or a torch capture): inside a graph,copy_frominto a preallocatedallocresult.alloc(layout, *, attributes=None)(a classmethod)A new result of
layoutwith every field allocated (the optional ones included) in the “no track yet” state: per-point fields and attributes at their padding,count,valid,truncated,generationandfail_maskzero,lengthandwindingzero,fail_bitsempty.attributesmaps plugin keys ("<package>.<name>") to dtypes ({"acme.grip": wp.float32}; the names"float32","int32","vec2f","vec3f"work too). It allocates, blocks and raisesCaptureErrorunder a capture, likeclone.
Streams. validate and view do no device work. The others order themselves as
follows:
copy_fromruns onctxwhen given (stream=andctx=together raiseTypeError), else on the stream resolved fromstream=, the ambientLaunchContext.scope, the stream that producedsrc, torch’s capture stream and Warp’s current stream, in that order. “The stream that producedsrc” is the default stream of the generator whose result it is (TrackGenerator(..., stream=s)), and a view inherits it; a hand-built orallocresult has none. When you generated on another stream (gen.generate(stream=t)), passstream=tor order the streams with events:copy_fromonly knows the generator’s default.cloneallocates on Warp’s current stream (wherewp.emptyallocates), makes it wait on the generator’s stream (stream=names it; else the ambient scope, the generator’s default, torch, current), copies on Warp’s current stream, then waits for those copies on the host. The clone is therefore usable on any stream as soon as it returns: read it (.numpy(), a utility), orcopy_frominto it on a generator’s stream.allocallocates and fills on Warp’s current stream (no ambient or torch stream is adopted), likewp.full, then waits for the fills on the host: the result is usable on any stream on return.snap = Track.alloc(track.layout)followed bysnap.copy_from(track)on the generator’s stream needs no sync of yours (the fills cannot land after the copies, and the generator’s stream never writes memory Warp’s current stream is still using), nor does a generator later built without=a view of it on another stream.
Call |
Allocates |
Device work |
Under a capture |
|---|---|---|---|
|
nothing |
none |
fine (host only) |
|
nothing |
none |
fine |
|
nothing |
one copy per held array (resolved stream) |
recorded |
|
every held array |
a stream wait, one copy per array (current stream), a host wait |
|
|
every array |
one fill per array (current stream), a host wait |
|
View safety¶
A kernel that takes a view sees an ordinary array whose element 0 is the view’s
first row and whose length is the view’s. It is view-safe when it touches only indices
in [0, shape[0]) derived from wp.tid() and the per-env layout (e * S + i),
reaches every buffer through its arguments and assumes nothing about two arguments being
distinct allocations: the bounds-correctness every kernel already needs on an owned array
of the same shape. Every TrackGen kernel that takes a result array (the writers: the
arc-length, winding, offset, validity, lift, frames, resample-scan and attribute-carry
kernels, the commit’s hold / restore / poison kernels, the gate frame, validity, relax
and elevation kernels, the seed-generation copy; the consumers: every runtime utility)
indexes this way; none uses an absolute pointer or the parent’s size. Plugin kernels
follow the same rule, and the conformance kit’s output.view check runs every
generator and stage with out= a view of a larger poisoned result
(Testing a Plugin).
Caller-owned results: out=¶
TrackGenerator(config_or_spec, rng=None, *, seeds=None, stream=None, out=None),
GateGenerator(..., out=None) and pipeline.build(spec, *, ..., out=None) take a
result you own. The Program’s result is out: its arrays are the ones the stages
and the commit write, from the warm-up on, and their addresses never change, so a graph
of yours that reads them keeps working. The checks run on the host, before anything is
allocated:
outis aTrack(aGateSequencefor a gate pipeline) instance, elseTypeError: an object that merely has the fields is refused.out.validate()passes, with every required field present, and its canonical layout equals the pipeline’s (ValueError:out: a Track for Layout(4, 384, 'cpu', True); this pipeline writes Layout(8, 384, 'cpu', True)). Messages name the field asout.<field>(out.truncated is None; this pipeline needs it).An optional field
outleavesNone(generation,fail_mask,half_width;truncatedis required) is not added toout: the Program keeps a private buffer for it, whichtapreaches.out.attributesfollows the rule of Attributes of a caller-owned result.outmay not overlap the result of a live Program (Disjoint outputs).
After the build, out.layout is set when it was None, out.fail_bits names the
pipeline’s bits, and the generator’s default stream is recorded as the stream that
produced out (for copy_from and clone). A view keeps its parent’s memory alive, so
out=big.view(...) needs nothing else to stay valid.
Mixed batches¶
Several generators can write disjoint env ranges of one result, each through a view of it: a batch whose envs come from different generators or configurations, read by one set of utilities and recorded into one graph.
import warp as wp
from track_gen import TrackGenerator
from track_gen.pipeline import presets
from track_gen.results import Layout, Track
E, dev = 8, "cuda:0" # or "cpu"
seeds = wp.array(list(range(100, 100 + E)), dtype=wp.int32, device=dev)
done = wp.zeros(E, dtype=wp.int32, device=dev) # your done mask
big = Track.alloc(Layout(E, 384, dev))
a = TrackGenerator(presets.track(num_envs=4, device=dev, generator="bezier"),
seeds=seeds[0:4], out=big.view(0, 4))
b = TrackGenerator(presets.track(num_envs=4, device=dev, generator="polar"),
seeds=seeds[4:8], out=big.view(4, 8))
with wp.ScopedCapture(device=dev) as cap: # one graph of yours
a.generate(advance=True, mask=done[0:4])
b.generate(advance=True, mask=done[4:8])
done.fill_(1)
wp.capture_launch(cap.graph) # big holds both halves, regenerated
One stride. Every generator of the batch uses the same
n_max(the samemax_gatesfor gates), the stride ofbig.Seeds. Pass each generator its
[E_i]slice of one seed array, as above (a slice is bound by pointer, like any seed array). Withseeds=Noneor anint, enveof a view starting at envkof its root result gets seedbase + k + e(baseis theint, or0): a mixed batch with default seeds is seeded like one batch, and two halves running the same generator never get the same seeds. A seed array, aPerEnvSeededRNGor aSeedStateis used as given. A view rebuilt by hand (dataclasses.replace) has no offset.Masks. Each generator takes its own
[E_i]slice of your done mask. A masked generation holds and restores only that generator’s rows; the other generators’ rows are untouched.Streams. The generators may share a stream (ordered) or use different ones (their memory is disjoint). Order your readers of
bigafter both.Fail bits. Each generator’s
fail_bitsis set on its own view object (and ongen.program.fail_bits);big.fail_bitsstays empty. Generators with different plugin checks may give one bit different names: readfail_maskper range with that range’sfail_bits.Utilities. A utility bound to
bigreads every env; one bound to a view reads its range only. ACoursebuilds its own generator and takes noout=.
Disjoint outputs¶
Two live Programs may not write the same memory. At every build, the planner looks up the
allocation that holds out.count and the byte range out.count covers in it; when the
range overlaps that of a Program still alive, the build raises PlanError:
out: envs [0, 4) of this storage are already written by a live Program (envs [2, 6)
of the same allocation); views given to different generators must be disjoint; to
rebuild into the same storage, release the earlier generator / Program first
(del gen)
Every successful build registers its result, an owned one included, once the Program is
usable (after its warm-up; a facade after its whole construction), so
build(spec, out=gen.program.result) raises while gen is alive, and a build that
raised registers nothing (a retry with the same out= is not refused). Once a Program
is garbage (del gen), its range is free again: to rebuild into the same storage
(new static params, keeping the pointers a graph of yours holds), drop the earlier
generator first. The check is keyed on count: two hand-built results that share per-point storage but have
separate count arrays are not detected.
Attributes of a caller-owned result¶
On a view of a larger result, out.attributes may hold any keys, so a mixed batch can
share one attribute array between generators that export it and generators that do not:
A key this pipeline exports (an exported per-point plugin channel, see Per-point attributes in the result) is bound: its final version is
out’s array (the dtype must be the family’s,ValueErrorotherwise), it is listed inProgram.attributes, and the commit holds, restores and poisons it.Every other key (a family this pipeline does not write, or one it carries without exporting it, whose dtype it must then have) is checked (
[E * stride], an attribute dtype, the device, contiguous) and filled once with its padding (NaN, or-1) at build, ordered before the warm-up, and never written again: the pipeline keeps its own private buffer for such a channel. The rows of this generator hold the padding through every plain, masked and poisoned generation, andProgram.attributesdoes not list the key.An exported key that
outleaves out is not added to it (the Program keeps a private buffer,tapreaches it).
On the whole of its allocation (out is not a view of a larger result, or a view
of all its envs), no other generator can write a key, so a key this pipeline does not
export is refused (ValueError: out.attributes['acme.gripp']: this
pipeline does not export 'acme.gripp'; exported: ['acme.grip'] ...): a misspelled key
is reported instead of NaN-filling your buffer. An exported key of the wrong dtype
raises ValueError (out.attributes['acme.tag'] is float32; this pipeline exports
'acme.tag' as int32).
So in a batch of a generator that exports acme.grip and one that does not,
big.attributes["acme.grip"] holds grip values on the first generator’s envs and NaN
on the other’s.
Capability protocols¶
A runtime utility needs a few arrays of its source, not a whole Track. Each
capability is a typing.Protocol (runtime_checkable) in track_gen.results: the
members a family of consumers shares. Each consumer checks the subset it actually reads
(check_source(..., members=...), listed in Runtime utilities and components), so a source
needs only those:
Protocol |
Members |
Read by |
|---|---|---|
|
|
|
|
|
|
|
|
a gates-mode |
|
|
a gates-mode |
|
|
read when present (an optional member) by |
|
|
read when present by |
|
|
|
The course line (CourseLine) and the frame checker (FrameChecker) are internal
to Course in 0.x (no public import path; their components CenterlineFromGates and
FrameCollision in track_gen.tools are public); the other consumers are public.
A Track satisfies HasCenterline, HasBand and HasValidity, a
GateSequence HasGates, HasGateFrames and HasValidity, a Centerline
HasCenterline and HasValidity, a CheckpointSet HasCheckpoints (and
HasValidity when its valid is set); each satisfies HasGeneration when its
generation is set. Any object with the members works: your own dataclass, a
SimpleNamespace, a proxy. No protocol has a layout member: a source without one
gets its layout inferred.
Shipped protocols are frozen. Their members never change; a new capability is a
new protocol (a later HasWidth or HasAttributes), and a consumer that needs two
capabilities names both. The library itself never calls isinstance against a
protocol (runtime_checkable member checks differ between Python 3.10 / 3.11 and
3.12 for objects that compute attributes in __getattr__): it checks sources with
check_source. Use the protocols for typing, for documentation, and in your own
isinstance checks.
Centerline¶
A centerline without a road band: center, tangent ([E * S] vec3f),
arclen ([E * S] float32), all NaN past count[e]; length
([E] float32), count, valid ([E] int32), generation
(optional) and layout, with validate, view, copy_from, clone and
alloc as above. A gates-mode Course localizes its agent on
course.course_line.centerline, a Centerline sampled from a closed Catmull-Rom
spline through the gate centers (3D gate courses). CourseLine.track is a
deprecated alias of centerline (it emits a DeprecationWarning; removed in 1.0).
check_source¶
check_source(obj, protocol, *, consumer, topology="closed", members=None,
optional=()) -> Layout
The check every utility runs at construction, public so that your own utilities (and the
components of track_gen.tools, Writing a Component) validate
their sources with the same rules and messages:
protocolis a capability protocol oftrack_gen.results, or a tuple of them ((HasCenterline, HasGeneration): their members are checked together, against one layout); anything else raisesTypeError.members, when given, must be members of it (ValueError: check_source: members ['centre'] are not members of HasBand (center, outer, inner, count, valid)): a consumer’s typo is reported as such.Each member of
protocol(or ofmembers, the subset a consumer reads) is read withgetattr, once; missing (orNone) ones raiseTypeError(CollisionChecker needs a HasBand source (center, outer, inner, count; optional: valid): Centerline has no 'outer', 'inner': the members the consumer reads only when present are listed apart, afteroptional:).The layout is
obj.layoutwhen it is aLayout. On aTrack,GateSequenceorCenterlineit must be aLayoutorNone(TypeErrorotherwise); another object’slayoutattribute that is not aLayout(a string, an enum of another library) is ignored. Otherwise the layout is inferred from the checked members only:num_envsfromcount(else the first per-env member),stridefrom the first per-point member (1, i.e. undefined, when every checked member is per env, e.g.HasGenerationalone).Only those members are checked, with the schema’s dtype and form and the messages of validate(need=()), prefixed by the consumer (
CollisionChecker: Track.inner: expected a contiguous [3072] vec3f ...). Fields outside the protocol are never looked at, and the object’s ownvalidatemethod, if any, is never called.With
topology="closed", a source whose layout saysclosed=FalseraisesValueError(Topology).optionalnames members ofprotocolthat may be absent: each one that is present (notNone) is checked like the others, with the same messages; an absent one raises nothing. A consumer reads the absent ones as its documented fallback (valid: every env valid;generation: no self-reset). The components’optionalprotocols use it (check_source(obj, (HasCenterline, HasValidity, HasGeneration), ..., optional=("valid", "generation"))).
It returns the layout (with the canonical device). The utilities pass members= when
they read fewer members than the protocol lists (CollisionChecker, PropSampler,
HeightFieldBaker, FrameChecker, CheckpointSet.from_gates, curvature) and
optional= for the members they read when present (valid, generation); see
Runtime utilities and components. A malformed optional member therefore raises at
construction. A utility binds the arrays it checked at construction and
uses those on every call: reassigning a member of the source afterwards has no effect
(rebuild the utility), and a proxy computing its members is read once.
Topology¶
Layout.closed says whether the loops are closed. Every built-in stage, generator and
utility assumes a closed loop, and 0.x accepts nothing else; the flag is a host-side
contract, checked before anything runs (no kernel takes it):
BatchSpec.closedmust be abool(PlanError: BatchSpec.closed must be a bool, got 1).closed=Falseis refused right after the spec’s shape checks, before any generator is bound or anyParams.validateruns, naming the stages that support closed loops only (PlanError: BatchSpec.closed=False: open tracks are not supported in 0.x: stages 'generator' (bezier), 'resample' (resample.constant_spacing), ... support closed loops only (Stage.topology='closed'); open topology arrives after 1.0). A pipeline whose stages all declaretopology="any"is refused too.Stages declare
Stage.topology("closed"or"any"); every built-in is"closed", and a legacyGeneratorSpeccounts as"closed".The utilities that read a result declare
topology = "closed"as a class attribute (DiscCheckerreads raw disc arrays and has none) and refuse a source whose layout hasclosed=False(ValueError: CollisionChecker supports closed loops only (topology='closed'); its source has layout.closed=False).copy_fromrefuses to copy across topologies (the layouts differ).
Open tracks are scheduled after 1.0.
Invalid envs¶
An invalid env is an env whose result failed a check: its valid flag is
0 (valid[e] == 0), and
fail_mask[e] holds one bit per failed check (fail bits; valid[e] ==
(fail_mask[e] == 0)). Every env is generated and checked on its own, so an invalid env
never affects the others, and the generator never retries: the env stays invalid, at its
index, until a later generation gives it a new seed or epoch
(generate(advance=True, mask=...), Two masks: regenerate and reset).
Fail bits. result.fail_bits (and gen.program.fail_bits) maps each bit’s name to
its position. The built-in checks of a track pipeline:
bit |
name |
set when |
typical cause, and the knob that lowers it |
|---|---|---|---|
0 |
|
the generator flagged this env |
|
1 |
|
the loop is not one simple closed turn (also set for an env with no points) |
a raw loop that crosses itself and that the relax could not open: raise
|
2 |
|
a point is NaN or infinite |
a NaN parameter or ingested data |
3 |
|
the road band overlaps itself |
a road too wide for the course: lower |
4 |
|
a point’s half-width is at or below |
a per-point width stage that writes a zero width |
5 |
|
a border crosses itself (only with |
as |
6 |
|
a segment is steeper than |
a hilly elevation on a small course: lower the hills or raise |
7 |
|
the loop needed more than |
|
A gate pipeline’s bits are count (0: fewer than min_gates or more than
max_gates real gates), finite (1), tangent (2: a degenerate tangent),
spacing (3: two gates closer than 2 * gate_radius), crossing (4: two gate
bars cross, the common one: lower gate_width or raise size,
Bar width and invalid envs) and grade (5). Plugin checks take bits 16 to
30, named in fail_bits (Fail bits, Gate fail bits).
import numpy as np
from track_gen import TrackGenerator
from track_gen.pipeline import presets
E, device = 64, "cuda:0" # or "cpu"
gen = TrackGenerator(presets.track(num_envs=E, device=device, half_width=0.03))
track = gen.generate()
invalid = track.valid.numpy() == 0
print(f"{invalid.mean():.1%} invalid") # 1.6% invalid: check it after construction
fail = track.fail_mask.numpy()
for name, bit in track.fail_bits.items(): # why, per check
n = int(((fail >> bit) & 1).sum())
if n:
print(f"{name}: {n} envs") # here: truncation: 1 envs
What an invalid env’s result holds. on_invalid (presets.track(on_invalid=...)
and presets.gates(on_invalid=...); a config always uses "keep") chooses:
"keep"(the default): the env keeps the geometry it computed, withvalid = 0. The rows are finite and may look usable: readvalidbefore using them."poison": the env reads like an env with no track:count = 0; every per-point field NaN on every slot (outer,center,inner,tangent,normal,arclen,half_width; for gates every per-gate field,orientationandhalf_sizeincluded) and every attribute its padding;length = 0andwinding = 0;valid = 0.fail_maskandtruncatedkeep their values, so the cause stays readable, andgenerationis written as usual.
Either way the runtime utilities treat an invalid env as an env with no track: off the
road for a band collision (oob = 1, NaN distance), no post or frame hit, frozen
progress with dist_to_next = 0, no checkpoints. Invalid envs lists
what each utility reports.
How many envs. Measured at the preset defaults (E = 256, seeds 0..255, CPU):
configuration |
invalid |
fail bits |
|---|---|---|
|
0 % |
|
|
1.2 % (3 envs) |
|
|
0.4 % (1 env) |
|
|
1.6 % (4 envs) |
|
|
0 % |
|
|
19 % (49 envs) |
|
|
3.1 % (8 envs) |
|
|
0 % |
Gates depend on the bar width: from 0 % for point gates to most of the batch for wide
bars (Bar width and invalid envs). Rates grow with every setting that truncates
the loop or thins the course relative to the road: measure yours with
(track.valid.numpy() == 0).mean() after construction (course.result.valid for a
Course).
Lowering it. Size n_max for your longest loop (truncation is the most common
cause outside gates; the build warns when a size range would truncate,
Size: the sizing rule), keep the road narrow relative to the course (half_width against
size and scale), keep gate bars narrow relative to the gate spacing, and give
the relax enough iterations. The invalid fraction that remains is the price of a
batch with no retry: regenerate those envs.
In a loop. Gate every reward, observation and termination on valid, with a
select (np.where(valid, r, 0.0), never r * valid: a NaN times zero is NaN), and
give an invalid env a new course with a masked regeneration rather than a respawn on the
same one: The recipe on the host has the recipe, RL auto-reset the loop.