Generating tracks and gates¶
A generator builds one track (TrackGenerator) or one gate sequence
(GateGenerator) per env, for a whole batch of E envs at once, from
one seed per env. It allocates everything once, at construction, and every call
rewrites the same result object in place: a Track or a GateSequence
(Results: Track, GateSequence, Centerline). This page covers how to build a generator, its parameters
and stages, how seeds and calls work, and how to save and inspect a pipeline. For a
guided first run, start with Generating a Batch of Tracks or
Generating Gate Sequences for Drone Courses.
from track_gen import GateGenerator, TrackGenerator
from track_gen.pipeline import presets
E, device = 64, "cuda:0" # or "cpu"
gen = TrackGenerator(presets.track(num_envs=E, device=device)) # runs one generation
track = gen.generate() # the result: a Track (the same object on every call)
valid = track.valid.numpy() # [E] int32: 1 where env e holds a usable track
gates = GateGenerator(presets.gates(num_envs=E, device=device, gate_width=0.1))
seq = gates.generate() # a GateSequence
The ways to build a generator¶
Every generator runs a spec: a PipelineSpec, the ordered list of the
stages it runs and the batch they share. There are three ways to get
one, and they give the same generator:
A preset (recommended).
presets.track(...)andpresets.gates(...)return a spec, with each replaceable stage chosen by a keyword (Presets: presets.track, Gate presets: presets.gates).A config.
TrackGenConfigandGateGenConfighold one flat field per knob (Config classes).TrackGenerator(cfg)builds the default spec of the config,legacy.to_pipeline_spec(cfg)(legacy.to_gate_pipeline_specfor gates).presets.track(num_envs=E, device=d)builds whatlegacy.to_pipeline_spec(TrackGenConfig(num_envs=E, device=d))builds: the same stage list, equal parameters once resolved, byte-identical outputs.An edited spec. A spec is plain data:
replace,insert_after,insert_before,removeandwith_randomizereturn a new spec (What a spec holds).
import numpy as np
from track_gen import TrackGenConfig, TrackGenerator
from track_gen.pipeline import StageRef, presets
E, device = 64, "cuda:0" # or "cpu"
spec = presets.track(num_envs=E, device=device, half_width=0.05) # a preset
cfg = TrackGenConfig(num_envs=E, device=device, half_width=0.05) # a config
no_relax = spec.replace("relax", StageRef("relax", "none")) # an edited spec
a = TrackGenerator(spec)
b = TrackGenerator(cfg)
c = TrackGenerator(no_relax)
same = np.array_equal(a.generate().center.numpy(), b.generate().center.numpy(),
equal_nan=True) # True
Constructing a generator¶
TrackGenerator(config_or_spec, rng=None, *, seeds=None, stream=None, out=None) takes
a TrackGenConfig or a track pipeline’s PipelineSpec:
Seeds come from
rng(aPerEnvSeededRNG) orseeds(anintk: envegets seedk + e; an[E]int32wp.array, bound by pointer; aPerEnvSeededRNGor aSeedState; see Seeds, epochs and the generation counter), not both (ValueError). With neither, envegets seede.streamis the default stream of the calls that name none (Calling a generator).outis aTrackyou own for the generator to write (Caller-owned results: out=).
Construction checks the spec and every stage’s parameters (Parameters), allocates
every buffer the generator will ever use, and runs one generation, so the result already
holds a batch when the constructor returns. Construct a generator before you begin a
capture: construction inside one raises CaptureError
(Rules for recording into your graph). A gate pipeline’s spec raises ValueError pointing at
GateGenerator, before anything is bound; anything other than a config or a spec
raises TypeError.
gen.configreturns what was passed (a copy of a config; a spec is immutable and returned as is), soTrackGenerator(gen.config, rng)rebuilds an identical generator.gen.paramsmaps"generator"and every stage with a dynamic field to its live parameters (Parameters).gen.programis the pipeline the spec built: its result (gen.program.result), its fail bits, its taps (Inspecting a generator).gen.inputsandgen.input_infoare the generator’s device inputs (regen_mask, and those a stage declares, such asexternal’s centerlines).gen.num_envs,gen.deviceandgen.n_maxread the batch, whichever form was passed.
A course (Course) takes a spec, a config or a generator (Courses).
Presets: presets.track¶
presets.track(...) returns the PipelineSpec of a track pipeline, with each
replaceable stage chosen by a keyword. It reads the registry (registry, default
track_gen.plugin.registry) for two things only: whether the generator provides its
own altitudes or half-widths (Generator-provided altitude and width), and whether the width stage has a half_width field. A name the
registry does not know is treated as a plain stage, and fails when the generator is
built.
presets.track(*, num_envs, device, n_max=384, scale=1.0, size=1.0,
generator="bezier", generator_params=None, num_points=256,
half_width=0.1, spacing=None,
relax="auto", width="constant", elevation="flat",
checks=("default",), on_invalid="keep",
z_base=0.0, registry=None, randomize=None) -> PipelineSpec
The stages, by id: generator, resample (resample.constant_spacing),
width, relax, uniform (resample.uniform), frames, offset,
elevation, the checks (the first one’s id is "validity"), and lift
(Choosing stages). A generator that provides its half-widths has no width stage,
one that provides its altitudes no elevation stage. Each randomize= entry adds a
randomize.<stage>.<field> stage right before its target (Per-env draws: randomize=).
relax, width, elevation and each entry of checks take one of:
Form |
Meaning |
|---|---|
|
The stage registered as |
|
The same with |
|
Used as given (id and ports included). Its |
|
The generator’s preferred relax stage when it declares one, else |
|
|
Anything else raises TypeError, and an empty name ValueError. checks is a
sequence of selections whose first is "default" (validity.default runs the
built-in checks; plugin checks follow it): a single selection raises TypeError, an
empty sequence or another first check ValueError. The other arguments:
generatoris a generator name, withgenerator_params(Parameters), or aStageRef(kind="generator", ...), which carries its own parameters (passinggenerator_paramstoo raisesValueError).num_pointsis the generator’s raw point count (unless theStageRefsets acapacity).n_maxis N_max, the per-env point capacity of the result. Size it for the longest track:n_max >= longest perimeter / spacing + 1; a track that needs more points is truncated and invalid (Invalid envs, Size: the sizing rule).sizeis the initial per-env course size (the generator’s dynamicsize,generator.size; Size: the sizing rule):1.0adds nothing to the spec, another value goes into the generator’s parameters. A generator that declares nosize(external) raisesValueErrorwith a hint, and so does asizegiven both here and ingenerator_params(give it once).scaleis the static coordinate scale of the whole batch.half_widthis the road half-width: it is filled into thewidthselection when that stage’s Params class has ahalf_widthfield (width.constant, or a plugin’s such asacme.ripple) and its parameters, in any selection form, areNoneor a mapping withouthalf_width. A Params instance is taken as is. A selection whose parameters set anotherhalf_widththan the preset’s raisesValueErrornaminghalf_width=(width=("constant", {"half_width": 0.2})with the defaulthalf_width=0.1). The relax, offset and validity stages read the per-point half-width the width stage writes (Width).spacing(spacing) defaults to0.6 * half_width, as inTrackGenConfig. For a width stage without ahalf_widthfield (width.from_channel) or a generator that provides its half-widths,half_widthis only this nominal width for the default spacing; ahalf_widthother than the default 0.1 that nothing reads (such a width and an explicitspacing) raisesValueError.liftislift.flat(atz_base, default0, asTrackGenConfig.z_base) when the elevation iselevation.flat, elselift.elevated(a nonzeroz_basethen raisesValueError: the elevation’s own parameters set its altitude). A generator that provides its altitudes getslift.elevatedand no elevation stage.registryis the registry the preset reads (defaulttrack_gen.plugin.registry).Each further check gets the id
ref.id or name. Two checks with the same id raiseValueError: give one an explicitStageRef(..., id=...).on_invalidis"keep"or"poison"(ValueErrorotherwise): what an invalid env’s rows hold (Invalid envs).randomizeisNone, a mapping{"<stage id>.<field>": (lo, hi)}or a sequence ofStageRef("randomize", "uniform", ...): per-env draws of dynamicfloat32fields (Per-env draws: randomize=, Per-env randomization: randomize=). A key naming a stage the preset does not have raisesValueError.generator="external"ingests centerlines you load (Your own centerlines): withgenerator_params={"with_z": True}it provides the altitude, with{"with_half_width": True}the half-width (the preset drops the elevation or width stage), andrelax="auto"runs no relax.
A preset is a starting point: use the edit operations for the rest, for example
presets.track(...).insert_after("validity", StageRef("validity",
"acme.max_curvature", id="kmax")) or .replace("resample", StageRef("resample",
"constant_spacing", {"spacing": 0.05})).
Gate presets: presets.gates¶
presets.gates(*, num_envs, device, generator="bezier", generator_params=None,
source="auto", gates=None, max_gates=None, min_gates=4,
order=None, stations=None,
gate_radius=0.025, gate_solve_iters=8, gate_width=0.0,
gate_align="yaw_only", elevation="flat", z_base=0.0,
z_valid_grade=0.0, checks=("gate_default",), scale=1.0, size=1.0,
on_invalid="keep", n_max=384, num_points=256, half_width=0.1,
spacing=None, relax="auto", width="constant",
spacing_metric="xy", elevation_on=None,
registry=None, randomize=None) -> PipelineSpec
presets.gates(num_envs=E, device=d) builds what
legacy.to_gate_pipeline_spec(GateGenConfig(num_envs=E, device=d)) builds: the same
stage list, equal parameters once resolved, a byte-identical GateSequence. It reads
the registry (registry, default track_gen.plugin.registry) to see the
generator’s output, what it provides and the orderings it accepts, which decide the
stage list.
generatoris a name (withgenerator_params) or aStageRefof kindgate_generatororgenerator;sourceis"auto","anchors","stations"or"relaxed_stations": gates from a gate generator’s anchors, or at equal arc length along any track generator’s centerline (Stations: gates from any track generator).gatesis the station count of a centerline source (default 12); with an anchors generator it raisesValueError(the generator decides its own count). It fills thegatesfield of astationsselection that does not set it; a selection that sets another count raisesValueError.max_gatesdefaults to 32 for anchors, to the station count for stations.order,stations,elevation,relaxand each entry ofcheckstake the forms of Presets: presets.track (a name,(name, params), aStageRef, orNone:elevation=Noneiselevation.flat,relax=Noneisrelax.none;relaxdefaults to"auto", the centerline generator’s preferred relax, else"xpbd").order=Noneis the generator’s default order: its preferred order when it accepts it, else"ccw"when it accepts it, else the first order it accepts (the rule ofGateGenConfig.gate_ordering=None).checksstarts with"gate_default".stations=with an anchors generator raisesValueError(it writes its own anchors).gate_radius,gate_solve_iters,gate_width,gate_align,z_base,z_valid_grade,min_gatesandscaleare theGateGenConfigfields, passed to every stage that takes them (one value per pipeline, see Planner rules for gate pipelines).gate_widthis the initial value ofgate_frames.default’s per-env bar width (Per-env bar width and course size): keep it small, wide bars make many envs invalid (Bar width and invalid envs).validity.gate_defaultgets these values unless its parameters inchecksset them; parameters setting anothergate_radiusorgate_widthraiseValueError.z_baseiselevation.flat’s altitude: a nonzero value with another elevation or with a generator that provideszraisesValueError.gate_alignis"yaw_only"or"full_tangent"(3D gate courses).sizeis the initial per-env course size (Per-env bar width and course size), routed to the stage that holds it: the generator’s parameters (generator.size) for thebezier/hullgate generators and for the track generator of a"stations"/"relaxed_stations"source,normalize.extent’s (normalize.size) for thepolar,voronoiandcheckpointgate generators.1.0adds nothing to the spec; a generator with neither raisesValueError, as does asizein itsgenerator_paramstoo.n_max,num_points,half_width,spacing,relaxandwidthconfigure the centerline part of a station source: a"stations"source readsn_maxandnum_points, a"relaxed_stations"source all six (widthas inpresets.track), and setting one that the source does not read raisesValueError.spacing_metric("xy"or"xyz") is the stations’ arc-length metric, andelevation_on(None,"gates"or"centerline") where the elevation runs; both apply to a centerline source, and"xyz"with an anchors generator raisesValueError(Gates on a track centerline: 3D spacing).on_invalidis"keep"or"poison".randomizeis the per-env draws of Presets: presets.track (Per-env draws: randomize=); a randomizer of a gate generator’s field ({"generator.m": (3.0, 9.0)}) is placed before the generator.generator="external"withsource="stations"or"relaxed_stations"places gates on ingested centerlines (Your own centerlines); itswith_zaltitude and its attributes reach the gates through the stations.A legacy
GateGeneratorSpecorders its own gates:order=with one raisesValueError, choose its ordering withGateGenConfig(gate_ordering=...)(GeneratorSpec and the 0.1 plugin ABIs).
Running a gate spec: GateGenerator(spec)¶
GateGenerator(config_or_spec, rng=None, *, seeds=None, stream=None, out=None) takes
a GateGenConfig or a gate pipeline’s PipelineSpec (and out= a
GateSequence you own, Caller-owned results: out=), with the seed rules of
TrackGenerator (rng or seeds, not both; with neither, env e gets seed
e). A track pipeline’s spec raises ValueError pointing at TrackGenerator,
anything else TypeError.
gen.configreturns what was passed, soGateGenerator(gen.config, rng)rebuilds an identical generator.gen.programis the pipeline;gen.num_envs,gen.deviceandgen.max_gatesread the batch whichever form was passed.gen.paramsmaps"generator"and every stage with a dynamic field to its live parameters (gate_framesfor the bar width,normalizefor the size of thepolar,voronoiandcheckpointgate generators).
Everything else (generate, enqueue, seeds, inputs, the generator’s own
CUDA graph, masked regeneration) works as for tracks.
What a spec holds¶
StageRef(kind, name, params=None, id=None, ports={}, capacity={})One stage slot, frozen.
kindandnameselect the stage ("relax","xpbd").paramsis the stage’s Params instance, a mapping of its fields, orNonefor its defaults; a mapping is coerced and validated when the generator is built.idnames the slot (slot_idisid or kind): two stages of one kind need explicit ids (the default list has tworesamplestages,"resample"and"uniform"), and the generator’s slot is always"generator".portsrewires a stage’s port to another channel of the same dtype and size, and wires a stage’s required read ports (width.from_channel’ssrc, see Required read ports).capacityis the generator’s{"raw": num_points}, its default raw point count; a Stage generator’s ownStage.capacity()overrides it. Acapacityon any other stage is refused.BatchSpec(num_envs, device, n_max, closed=True, scale=1.0, on_invalid="keep", max_gates=None)The batch every stage shares:
Eenvs on one device, the per-env point capacityN_max, the coordinatescale, and what happens to an invalid env’s rows ("keep"or"poison", Invalid envs).closedmust be abool, and open tracks (closed=False) are refused, naming the stages that support closed loops only (Topology).max_gatesselects the result:None(default) builds a track pipeline whose result is aTrack; anint >= 2builds a gate pipeline whose result is aGateSequencewithmax_gatesslots per env. In a gate pipelinen_maxis read only by a centerline source; the presets pass 384.PipelineSpec(batch, stages, require_caller_capturable=False)The batch and the ordered tuple of
StageRef.idslists the slot ids.require_caller_capturable=Truemakes the build reject every stage that runs on the host, so the pipeline can always be recorded into your graph (HOST stages and segments).replace(id, ref),insert_after(id, ref),insert_before(id, ref)andremove(id)return a new spec;replacekeeps the slot’s id whenref.idisNone. They are pure data operations: an unknown anchor id raisesValueErrorat once, everything else is checked when the generator is built.with_randomize(randomize)inserts randomizers, as the presets do (Per-env randomization: randomize=).to_dict()andfrom_dict(d)save and load it (Saving and loading specs).legacy.to_pipeline_spec(config, *, registry=None)The default spec for a
TrackGenConfig, the oneTrackGenerator(config)builds (see The default track stages). It resolves the generator’s parameters, and therelax_solverandz_profilenames (see Pipeline stages: relax_solver and z_profile), throughregistry, and raises therelax_solver,smooth_finishandz_profileerrors ofTrackGenerator(config).legacy.to_gate_pipeline_spec(config, *, registry=None)The default gate spec for a
GateGenConfig, the oneGateGenerator(config)builds (see The gate stages). It resolves the gate generator and its parameters, thegate_ordering(None: the generator’s default order) and thez_profilethroughregistry, and raises the errors ofGateGenerator(config)(unknown names, an unsupported ordering, the gate-count checks of Gate counts: max_gates, min_gates and the generator’s capacity).
Parameters¶
Each stage has a typed Params class, a frozen dataclass of its knobs: the
generator’s (BezierParams, PolarParams, …; Built-in generator parameters)
and every other stage’s (XpbdParams, ElevationNoiseParams, …; Choosing
stages). Give them as a mapping of field names, or as an instance:
from track_gen import TrackGenerator
from track_gen.pipeline import presets
from track_gen.plugin import registry
E, device = 64, "cuda:0" # or "cpu"
PolarParams = registry.get("generator", "polar").Params # no public import path
spec = presets.track(num_envs=E, device=device, generator="polar",
generator_params={"polar_num_knots": 9, "polar_radial_jitter": 0.5},
relax=("xpbd", {"relax_iters": 80}))
gen = TrackGenerator(spec)
gen.params["generator"].params # the resolved PolarParams(polar_num_knots=9, ...)
gen.params["generator"].set("polar_radial_jitter", 0.3) # a dynamic field
track = gen.generate() # built with the new value
Checked at construction. The spec only holds the values; the generator checks them when it is constructed (
ValueErrornaming the field and its rule, such aspolar_radial_jitter must be in [0, 0.85], got 2.0; an unknown key lists the valid fields). The rules of each built-in are in Built-in generator parameters.Static or dynamic. A static field (a count, a buffer size, a branch:
polar_num_knots,max_num_points,half_width,spacing, the relax knobs) is fixed when the generator is built. A dynamic field (the generators’ shape knobs, their per-envsize, the elevation ranges, the gate bar width) is a per-env[E]array, initialised from the configured value, that you can rewrite between generations or draw per env withrandomize=: Domain randomization and curricula lists them and shows how.The configuration is copied at construction.
gen.configandgen.params[stage].paramskeep the construction values; mutating the config or the mapping you passed afterwards has no effect. To change a dynamic field, writegen.params[stage](set(field, value), a scalar or an[E]array, or your own kernel intogen.params[stage][field]); to change a static field, build a new generator.seton a static field raisesKeyErrorwith that advice.
registry.describe(kind, name).params_schema is the JSON schema of a stage’s
parameters (fields, defaults, bounds, which ones are dynamic).
Choosing stages¶
A preset picks one stage for each slot from a keyword; replace or insert_after
pick any registered stage. The built-in track stages, in pipeline order:
Slot id |
Stage |
What it does |
Params |
Preset argument |
|---|---|---|---|---|
|
|
draws each env’s raw centerline from its seed (Choosing a Centerline Generator) |
the generator’s (Built-in generator parameters) |
|
|
|
resamples the loop at a constant arc spacing into |
|
|
|
|
writes the per-point half-width |
|
|
|
|
pushes the loop apart where the band would overlap itself (Track relaxation — overview) |
|
|
|
|
restores the uniform spacing after the relax |
none |
|
|
|
tangents, normals, curvature, arc length, winding |
none |
|
|
|
inflates the centerline into the |
||
|
|
the altitude of each point (2.5D tracks) |
|
|
|
|
the checks that set |
|
|
|
|
writes the |
|
follows |
|
|
draws one dynamic field per env, before its target (Domain randomization and curricula) |
|
The gate pipeline (presets.gates) runs a gate generator (gate_generator.bezier,
hull, polar, voronoi, checkpoint) or a track generator with a
stations stage (stations.raw, stations.relaxed,
StationsParams; source=, gates=), then
order (ccw, raw, random_pairs; order=), normalize
(normalize.extent, NormalizeExtentParams; the
polar, voronoi and checkpoint gate generators), gate_relax
(gate_relax.spheres, GateRelaxParams;
gate_radius=, gate_solve_iters=), elevation, lift, tangents,
gate_frames (gate_frames.default,
GateFramesParams; gate_width=, gate_align=)
and validity (validity.gate_default,
GateValidityParams; min_gates=,
z_valid_grade=); The gate stages has the full list.
Plugins can register stages of six kinds: relax, width, elevation and
validity in the track pipeline, order and stations in the gate pipeline
(elevation and validity serve both); the other kinds are TrackGen’s own
(Stage kinds). List what is installed with
registry.available(kind); Writing a Stage shows how to write
one.
from track_gen import TrackGenerator
from track_gen.pipeline import presets
from track_gen.plugin import registry
E, device = 64, "cuda:0" # or "cpu"
registry.available("elevation") # ['flat', 'noise', 'random_walk', 'uniform', ...]
hilly = presets.track(num_envs=E, device=device, relax=None,
elevation=("noise", {"z_min": -0.3, "z_max": 0.3,
"z_noise_amplitude": 0.3}))
hilly.ids
# ('generator', 'resample', 'width', 'relax', 'uniform', 'frames', 'offset',
# 'elevation', 'validity', 'lift')
gen = TrackGenerator(hilly)
Seeds, epochs and the generation counter¶
Each env is keyed by a pair (seed, epoch), held by the generator’s
SeedState (gen.seeds):
seeds:[E]int32. A generator bindsrng.seeds_warpby pointer, sorng.set_seeds_warp(...)reseeds it, and the rng must live on the generator’s device (construction raisesValueErrorotherwise).SeedStateand the generators never write a seed array they were given (awp.arrayor aPerEnvSeededRNG’s): such a state isbound, andset()on it raisesRuntimeError. Write that array yourself instead. The rng is another matter:PerEnvSeededRNG.set_seeds_warpandCourse.generate(seeds=...)overwriterng.seeds_warpin place, and that IS your array when the rng was built from awp.array. ASeedStatebuilt fromNoneor anintkowns its seeds (k + arange(E), asPerEnvSeededRNG(seeds=k)), as does one built withcopy=True.epoch:[E]int32, owned, zero at construction and changed only on device:advance(mask=None)adds 1 wheremask[e] != 0(to every env without a mask),reset_epoch(mask=None)zeroes it the same way, andset(seeds, ids=None)scatters new seeds into owned seeds and zeroes those envs’ epochs. Each is one launch (setwithids=None: a copy and a launch) on the call’s stream, allocation-free, so it can be recorded into a graph. Withoutstream=, the generator’sseedsmethods run on its constructorstream=, like itsgenerate().
Every generation opens with one kernel that folds the pair into the effective seed the
stages read (right after the epoch advance when advance=True):
epoch
0: the seed itself;epoch
k != 0:hash32(uint32(seed) ^ hash32(uint32(k) * 0x9E3779B9)) & 0x7FFFFFFF, a non-negative int32, wherehash32is the MurmurHash3 finaliser (track_gen.ops.hash32).
(seed, epoch) fully determines an env’s output: a generator whose env e has the
same seed and the same epoch rebuilds the same track or gate sequence. So does a
generator at epoch 0 seeded with track_gen.seeds.effective_seed(seed, epoch), the
bit-exact host twin of that kernel. The kernel is recorded with the rest of the
generation, so device-side writes to the seeds or to the epochs reach every replay, of
your graph or of the generator’s own.
import numpy as np
import warp as wp
from track_gen import TrackGenerator
from track_gen.pipeline import presets
from track_gen.seeds import effective_seed
E, device = 64, "cuda:0" # or "cpu"
gen = TrackGenerator(presets.track(num_envs=E, device=device), seeds=0) # env e: seed e
gen.generate(advance=True) # a new batch: every env at epoch 1
gen.generate(advance=True) # epoch 2
gen.generate().generation.numpy()[:4] # [2 2 2 2]
# the same batch from a fresh generator, seeded with the effective seeds
twin_seeds = wp.array([effective_seed(e, 2) for e in range(E)], dtype=wp.int32,
device=device)
twin = TrackGenerator(presets.track(num_envs=E, device=device), seeds=twin_seeds)
Each generation ends by writing generation[e] = epoch[e] into the result (for the
masked envs only, on a masked generation). Track.generation and
GateSequence.generation ([E] int32) therefore tell a consumer, on device, which
epoch each env’s current output was built with: its generation. It is
the epoch, not a change counter: a reseed at the same epoch does not change it, and after
reset_epoch() earlier values come back. It changes exactly when an env gets new
output only while nothing reseeds or resets the epochs. The field defaults to None
so results built by hand still construct, and clone() copies it.
Reseeding through the rng leaves the epochs alone: after an advance,
rng.set_seeds_warp(new_seeds, None) gives the batch of (new_seeds, epoch), not
that of a fresh generator. Call gen.seeds.reset_epoch() as well to reproduce a fresh
generator; Course.generate(seeds=...) does both.
Calling a generator¶
Construction already ran one generation. The calls:
generate(num_or_ids=None, *, stream=None, sync=True, advance=False, mask=None): withadvance=Truefirst advances the epoch of every env (of the envs withmask[e] != 0whenmaskis given;maskrequiresadvance=True), then runs the generation (recorded into your graph, a replay of the generator’s own CUDA graph, or eager; see Execution modes), writes the result’sgenerationand returns the result. Withoutadvanceit rebuilds the current batch from the current seeds, epochs and parameters.num_or_idsmust beNone(the batch is fixed). See Masked regeneration and Two masks: regenerate and reset.enqueue(*, stream=None, advance=False, mask=None): the same generation, recorded into the active capture or run eagerly on the resolved stream. It never builds the generator’s own graph and never waits on the host (a stage that runs on the host blocks for its own host work only, HOST stages and segments). An iterative stage (repulsive) runs its full iteration bound when called eagerly oncuda; the generator’s own graph skips the converged windows on device (Iterative stages (BOUNDED)).graph: the generator’s ownwp.Graphfor a plaingenerate()(noadvance, nomask). It isNoneuntil the first suchcudacall outside a capture, and staysNoneoncpuand for a generator with a stage that runs on the host, whose recordable parts get graphs of their own (HOST stages and segments). Every other(advance, mask)combination gets a graph of its own.seeds: theSeedState(Seeds, epochs and the generation counter).inputs:{"regen_mask": ...}, a persistent, zero-initialised[E]int32 array with a stable address, for your own kernels to write a mask into, plus every per-env input a stage declares.params: the live parameters (Parameters), written in place between or inside replays with no new capture.Track.clone()/GateSequence.clone(): a snapshot of the current output (Views, copies and snapshots).
import warp as wp
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() # waits for its own stream only
track = gen.generate(sync=False) # returns at once; results are stream-ordered
gen.enqueue(advance=True) # a new batch, never waits on the host
done = wp.array([1, 0] * (E // 2), dtype=wp.int32, device=device)
gen.generate(advance=True, mask=done) # new tracks for the envs with done[e] != 0
Execution modes¶
Each call resolves ONE stream: an explicit stream= (a wp.Stream or "torch"),
then an enclosing LaunchContext.scope(), then the constructor’s stream=, then
torch’s capture stream while torch.cuda.graph is capturing on the device, else Warp’s
current stream. The state of that stream picks the mode:
Mode |
When |
What |
|---|---|---|
Record |
The resolved stream is capturing. |
Enqueues the whole generation on that stream, so it becomes nodes of your graph.
There is no graph of its own, no |
Own graph |
|
The first call records the generation once into the generator’s own graph on a
private stream (one graph per |
Own graph, in parts |
|
The first call records each recordable part of the generation into a graph of its
own. Every call then runs the parts in order on the resolved stream: the host
stage eagerly, the others with |
Eager |
|
Launches the stages directly on the resolved stream. |
The seed kernel is part of what is recorded, in the generator’s own graph and in yours
alike. Every replay therefore reads the rng’s CURRENT seeds and the CURRENT epochs:
rng.set_seeds_warp(new_seeds, None) writes into the rng’s existing buffer, and the
next replay generates the new batch with no new capture. A recorded
generate(advance=True) advances the epochs on device, so each replay generates a new
batch without any reseed.
Outside a capture, generate() waits for its stream to finish before returning
(sync=True, the default). The wait covers that one stream. It is skipped while torch
is capturing in the calling thread (a host wait there could invalidate torch’s capture),
and it runs with the thread’s CUDA capture mode relaxed, so it never invalidates a
capture another thread has open. generate(sync=False) returns at once, and the
results are stream-ordered like every other library call (see
Runtime utilities and components).
A call is recorded only when the stream it RESOLVES is capturing. When you capture a
stream that is not Warp’s current one, pass it (generate(stream=s)) or wrap the calls
in LaunchContext(device, s).scope(). Otherwise the call runs, or replays its own
graph, on another stream and is silently missing from your graph. A generator
constructed with stream= resolves to that stream before torch’s capture stream, so
inside torch.cuda.graph pass stream="torch" to it. The rules for recording a
generator into your graph, and what to do when a capture fails, are in
Rules for recording into your graph and Troubleshooting capture.
Course.regenerate() follows the same modes with one body: generation plus the
downstream refresh (see Courses). It is not split in parts: over a
generator with a stage that runs on the host it runs eagerly outside a capture, and
raises NotCapturableError inside one.
Saving and loading specs¶
A spec is plain data. spec.to_dict() returns a JSON-serialisable dict, and
PipelineSpec.from_dict(d) reads it back without a registry; building the round-tripped
spec gives byte-identical outputs, plugin stages and their parameters included (a plugin
stage needs its plugin installed to build).
import json
from track_gen import TrackGenerator
from track_gen.pipeline import PipelineSpec, presets
E, device = 64, "cuda:0" # or "cpu"
spec = presets.track(num_envs=E, device=device, generator="polar",
randomize={"generator.size": (0.8, 1.5)})
with open("spec.json", "w") as f:
json.dump(spec.to_dict(), f, indent=2)
with open("spec.json") as f:
loaded = PipelineSpec.from_dict(json.load(f))
gen = TrackGenerator(loaded)
The device is saved with the batch: to load a spec on another device, edit
d["batch"]["device"] before from_dict. A TrackGenConfig is saved through its
spec: legacy.to_pipeline_spec(cfg).to_dict().
The saved format¶
to_dict() writes {"format": "track_gen.pipeline/2", "batch": {...}, "stages":
[...]}: the batch’s fields (on_invalid and max_gates are optional when reading,
"keep" and None when absent), then one entry per stage with its kind,
name, params and the optional id, ports and capacity. A missing key
raises a ValueError naming it (stages[3] is missing 'kind'), and so does any
format string from_dict does not know. require_caller_capturable is written only
when it is set.
from_dict also reads the older "track_gen.pipeline/1" dicts and migrates them,
with one DeprecationWarning; the migration guide says how, and which ones
cannot be migrated.
Inspecting a generator¶
from track_gen import TrackGenerator
from track_gen.pipeline import presets
from track_gen.plugin import registry
E, device = 64, "cuda:0" # or "cpu"
gen = TrackGenerator(presets.track(num_envs=E, device=device))
gen.config.ids # the slot ids, in pipeline order
gen.program.fail_bits # {'generator': 0, 'turning': 1, ..., 'truncation': 7}
xy = gen.program.tap("cl.xy", after="resample") # an intermediate channel, read-only
info = registry.describe("generator", "bezier") # origin, capture, Params schema, ...
info.seeded # True: its output depends on the seed
gen.program.tap(channel, after=stage_id)reads a channel (an intermediate array, such as the raw loopraw.xyor the resampledcl.xy) as it stood after a stage, on the last generation (Taps);gen.program.channelslists them. Measure the longest raw loop of your configuration withgen.program.tap("raw.xy")(Size: the sizing rule).gen.program.fail_bitsnames the bits offail_mask(Invalid envs).registry.describe(kind, name)describes a registered stage: where it comes from, whether it can be recorded into a graph, its parameter schema, what it provides, and whether its output depends on the seed (seeded).
Your own centerlines¶
The external generator runs centerlines you supply (a dataset, a learned model, a
hand-drawn circuit) through the same stages: resample, width, checks, band, elevation and
lift, or the gate stations. Load them with load_centerlines, in your data’s units
(set spacing, half_width and n_max for them), before a generation:
import numpy as np
from track_gen import TrackGenerator
from track_gen.pipeline import load_centerlines, presets
E, device = 64, "cuda:0" # or "cpu"
t = np.linspace(0.0, 2.0 * np.pi, 200, endpoint=False)
loops = [np.stack([(1.0 + 0.01 * e) * np.cos(t), 0.6 * np.sin(t)], axis=1)
for e in range(E)] # [m_e, 2] each, not closed by hand
gen = TrackGenerator(presets.track(num_envs=E, device=device, generator="external",
half_width=0.1, n_max=384))
load_centerlines(gen, loops)
track = gen.generate()
Ingesting your own centerlines has the inputs, the sizing check, altitudes, per-point widths, attributes and how to load new rows for a masked regeneration.