"""Params classes of the built-in track and gate stages.
Like the built-in generators' Params, every class
keeps the FLAT ``TrackGenConfig`` field names and ``from_legacy(config)`` copies the flat
values (no casts), except ``WidthFromChannelParams`` (dynamic ``scale`` / ``lo`` /
``hi``) and ``StationsParams`` (``gates``, ``spacing_metric``), which have no
``TrackGenConfig`` counterpart. Every field is static except ``width.from_channel``'s and
the elevation profiles' altitude window and shape knobs (``z_min`` / ``z_max`` of
``elevation.uniform`` / ``random_walk`` / ``noise``, ``z_max_step`` and
``z_noise_amplitude``; per-env arrays, latched each generation, read by the
``warp_zprofile`` twins), ``normalize.extent``'s ``size`` and ``gate_frames.default``'s
``gate_width`` (the course size of the polar / voronoi / checkpoint gate generators and
the per-env bar width; ``validity.gate_default``'s ``gate_width`` / ``check_crossing``
are inert: it checks the bars of every env per env).
The stage hands its Params instance (or, for elevation, a namespace built from its
static view) to the legacy wrappers as their ``config``
argument, so the same values reach the same ``float()`` / ``int()`` conversions and the
launches are unchanged.
``validate()`` re-runs the ``TrackGenConfig.__post_init__`` rules of the class's own
fields with the same messages: a spec made from a ``TrackGenConfig`` always passes; the
rules exist for hand-built specs. The stage Params map the fields a config HAS
(``from_legacy`` of a ``GateGenConfig`` leaves ``z_control_points`` and
``check_crossing`` at their class defaults).
"""
from __future__ import annotations
import dataclasses
import warnings
import numpy as np
from .._src.builtin_params import _FlatParams
from ..ops.constants import SIZE_BOUNDS
from ..plugin.params import Params, dynamic, static
__all__ = [
"ConstantSpacingParams",
"ElevationFlatParams",
"ElevationNoiseParams",
"ElevationRandomWalkParams",
"ElevationUniformParams",
"GateFramesParams",
"GateFramesPosedParams",
"GateRelaxParams",
"GateValidityParams",
"LiftFlatParams",
"NormalizeExtentParams",
"OffsetParams",
"StationsParams",
"ValidityParams",
"WidthConstantParams",
"WidthFromChannelParams",
"XpbdParams",
]
_ALIGNS = ("yaw_only", "full_tangent")
@dataclasses.dataclass(frozen=True, kw_only=True)
class _StageFlatParams(_FlatParams):
"""A stage's flat Params: ``from_legacy`` copies the fields the config has (a
``TrackGenConfig`` or a ``GateGenConfig``) and leaves the others at their class
defaults."""
@classmethod
def from_legacy(cls, cfg):
"""An instance holding the values of the config fields of the same names, for
the fields ``cfg`` (a ``TrackGenConfig`` or a ``GateGenConfig``) has; the others
keep their class defaults."""
return cls(**{f.name: getattr(cfg, f.name) for f in dataclasses.fields(cls)
if hasattr(cfg, f.name)})
_HW_DOC = ("Track half-width: every point's (cl.half_width; at constant width the "
"consumers take it as a host scalar).")
def _check_half_width(p) -> None:
if float(p.half_width) <= 0.0:
raise ValueError(f"half_width must be > 0, got {p.half_width!r}")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class ConstantSpacingParams(_FlatParams):
"""``resample.constant_spacing``: arc-length resample of the raw loop to a constant
step, padded to ``N_max``."""
spacing: float = static(0.6 * 0.1, doc="Constant arc-length step between points "
"(> 0).")
def validate(self, layout) -> None:
if float(self.spacing) <= 0.0:
raise ValueError(f"spacing must be > 0, got {self.spacing!r}")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class XpbdParams(_FlatParams):
"""``relax.xpbd``: the XPBD relaxation (band / rest length setup + solve). The
half-width is ``cl.half_width`` (the width stage runs before relax)."""
relax_band: "int | None" = static(None, doc="Separation band override (None: auto).")
relax_iters: int = static(50, doc="XPBD Jacobi sweeps.")
relax_margin: float = static(0.15, doc="Separation target margin.")
relax_sep_relax: float = static(1.0, doc="Separation correction scale.")
relax_spc_relax: float = static(1.0, doc="Edge-spacing correction scale.")
relax_bend_relax: float = static(1.5, doc="Bending correction scale.")
relax_sep_every: int = static(20, lo=1, doc="Broadphase refresh interval (>= 1).")
relax_sep_cache_slots: int = static(16, lo=0, doc="Separation cache slots per bead "
"(0: no cache).")
relax_sep_cache_skin: float = static(0.5, lo=0.0, doc="Broadphase skin (>= 0).")
relax_accel: str = static("chebyshev", choices=("none", "chebyshev"),
doc="Sweep acceleration.")
relax_cheby_rho: float = static(0.98, doc="Chebyshev spectral radius, in [0, 1).")
relax_cheby_gamma: float = static(0.9, doc="Chebyshev under-relaxation, in (0, 1].")
relax_cheby_start: int = static(8, lo=1, doc="First accelerated sweep (>= 1).")
relax_smooth_passes: int = static(5, lo=0, doc="Taubin smoothing passes (>= 0).")
relax_smooth_spacing_iters: int = static(10, lo=0, doc="Spacing-only polish sweeps "
"(>= 0).")
def validate(self, layout) -> None:
if int(self.relax_sep_every) < 1:
raise ValueError(f"relax_sep_every must be >= 1, got {self.relax_sep_every!r}")
if int(self.relax_sep_cache_slots) < 0:
raise ValueError(
f"relax_sep_cache_slots must be >= 0, got {self.relax_sep_cache_slots!r}")
if float(self.relax_sep_cache_skin) < 0.0:
raise ValueError(
f"relax_sep_cache_skin must be >= 0, got {self.relax_sep_cache_skin!r}")
if str(self.relax_accel) not in ("none", "chebyshev"):
raise ValueError(
f"relax_accel must be 'none' or 'chebyshev', got {self.relax_accel!r}")
if not (0.0 <= float(self.relax_cheby_rho) < 1.0):
raise ValueError(
f"relax_cheby_rho must be in [0, 1), got {self.relax_cheby_rho!r}")
if not (0.0 < float(self.relax_cheby_gamma) <= 1.0):
raise ValueError(
f"relax_cheby_gamma must be in (0, 1], got {self.relax_cheby_gamma!r}")
if int(self.relax_cheby_start) < 1:
raise ValueError(
f"relax_cheby_start must be >= 1, got {self.relax_cheby_start!r}")
if int(self.relax_smooth_passes) < 0:
raise ValueError(
f"relax_smooth_passes must be >= 0, got {self.relax_smooth_passes!r}")
if int(self.relax_smooth_spacing_iters) < 0:
raise ValueError(
"relax_smooth_spacing_iters must be >= 0, got "
f"{self.relax_smooth_spacing_iters!r}")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class WidthConstantParams(_FlatParams):
"""``width.constant``: one half-width for every point."""
half_width: float = static(0.1, doc=_HW_DOC)
def validate(self, layout) -> None:
_check_half_width(self)
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class WidthFromChannelParams(Params):
"""``width.from_channel``: ``cl.half_width = clamp(scale * src, lo, hi)`` from a
float32 per-point channel the user wires (``StageRef.ports={"src": "cl.<pkg>.<name>"}``,
e.g. the carried image of a generator's ``raw.<pkg>.<name>``). A NaN source point
stays NaN (and fails the width floor). ``lo`` defaults to 0: a source that pinches
below ``w_floor`` fails validity bit 4 instead of being clamped away. ``lo <= hi`` is
checked at build; a run-time ``hi`` below ``lo`` clamps to ``lo`` (the kernel uses
``max(hi, lo)`` as the upper bound)."""
scale: float = dynamic(1.0, lo=0.0, doc="Factor applied to the source (>= 0).")
lo: float = dynamic(0.0, lo=0.0, doc="Lower clamp (>= 0).")
hi: float = dynamic(1.0e9, lo=0.0, doc="Upper clamp (>= 0 and >= lo).")
def validate(self, layout) -> None:
if float(self.lo) > float(self.hi):
raise ValueError(f"lo must be <= hi, got {self.lo!r} > {self.hi!r}")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class OffsetParams(_FlatParams):
"""``offset.default``: the outer / inner borders at ``cl.half_width`` (the per-point
half-width channel the width stage writes)."""
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class ValidityParams(_FlatParams):
"""``validity.default``: the fused per-env validity gate (the thickness and width
floor checks read ``cl.half_width``)."""
turning_tol: float = static(0.1, doc="Turning-number tolerance (rad).")
w_floor: float = static(1e-3, doc="Width floor: every real point's cl.half_width "
"must exceed it (bit 4).")
relax_tol: float = static(0.02, doc="Thickness tolerance: valid when every point's "
"clearance >= (1 - relax_tol) * its half-width.")
z_valid_grade: float = static(0.0, lo=0.0, doc="Maximum |dz|/ds grade (0: off).")
validity_border_check: bool = static(False, doc="Add the border self-intersection "
"check.")
def validate(self, layout) -> None:
if float(self.z_valid_grade) < 0.0:
raise ValueError(f"z_valid_grade must be >= 0, got {self.z_valid_grade!r}")
def _check_z_range(p) -> None:
if float(p.z_min) > float(p.z_max):
raise ValueError(f"z_min must be <= z_max, got {p.z_min!r} > {p.z_max!r}")
def _check_control_points(p) -> None:
if int(p.z_control_points) < 3:
raise ValueError(f"z_control_points must be >= 3, got {p.z_control_points!r}")
def _warn_inert(p, ranges, knob: str, what: str) -> None:
"""A ``knob`` that can be positive (its drawn ``hi``, or its value when not drawn)
has no effect in a window closed at plan time (``z_min == z_max``, neither drawn):
the profile is clamped to ``[z_min, z_max]``. A window opened at run time by
``set`` cannot be seen here."""
drawn = ranges.get(knob)
if "z_min" in ranges or "z_max" in ranges or float(p.z_min) != float(p.z_max):
return
if (float(drawn[1]) if drawn is not None else float(getattr(p, knob))) > 0.0:
from .._src.generator_binding import _outside_track_gen_stacklevel # noqa: PLC0415
value = f"={getattr(p, knob)!r}" if drawn is None else \
f" drawn in [{drawn[0]}, {drawn[1]}]"
warnings.warn(f"{what}: {knob}{value} has no effect in the window "
f"z_min == z_max == {p.z_max!r} (the profile is clamped to "
f"[z_min, z_max]); open the window (e.g. a symmetric z_min < 0 < "
f"z_max, or draw z_min / z_max), unless you open it at run time "
f"with set()", UserWarning, stacklevel=_outside_track_gen_stacklevel())
def _check_z_draws(params, ranges) -> None:
"""Randomizers on ``z_min`` / ``z_max`` must not cross (roadmap PR13c): a run-time
``z_min > z_max`` gives draws in ``[z_max, z_min]`` (uniform) or ``z = z_max``
(random_walk / noise: the clamp returns ``hi``). ``ranges``: ``field -> (lo, hi)``
of the stage's drawn fields (empty when none)."""
zmin, zmax = ranges.get("z_min"), ranges.get("z_max")
if zmin is not None and zmax is not None:
if float(zmin[1]) > float(zmax[0]):
raise ValueError(f"the z_min draws [{zmin[0]}, {zmin[1]}] and z_max draws "
f"[{zmax[0]}, {zmax[1]}] can cross (hi(z_min) > lo(z_max)); a "
f"run-time z_min > z_max inverts the window: keep "
f"hi(z_min) <= lo(z_max)")
elif zmin is not None and float(zmin[1]) > float(params.z_max):
raise ValueError(f"the z_min draws [{zmin[0]}, {zmin[1]}] can exceed "
f"z_max={params.z_max!r}; a run-time z_min > z_max inverts the "
f"window: keep hi(z_min) <= z_max")
elif zmax is not None and float(zmax[0]) < float(params.z_min):
raise ValueError(f"the z_max draws [{zmax[0]}, {zmax[1]}] can fall below "
f"z_min={params.z_min!r}; a run-time z_max < z_min inverts the "
f"window: keep lo(z_max) >= z_min")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class ElevationFlatParams(_StageFlatParams):
"""``elevation.flat``: every real point at ``z_base``. On a gate pipeline it fills
``gates.z`` with ``z_base`` (``GateGenConfig.z_base``); on a track it must stay 0
(the flat lift adds ``presets.track(z_base=...)``)."""
z_base: float = static(0.0, doc="Altitude of every real gate (gate pipelines; a "
"track's flat altitude is lift.flat's z_base).")
_ZMIN_DOC = "Lower altitude clamp (<= z_max; per env)."
_ZMAX_DOC = "Upper altitude clamp (per env)."
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class ElevationRandomWalkParams(_StageFlatParams):
"""``elevation.random_walk``: a grade-capped random walk over the knots
(``z_min`` / ``z_max`` / ``z_max_step`` dynamic: the difficulty knob is
``z_max_step`` in a wide window)."""
z_base: float = static(0.0, doc="Baseline altitude.")
z_min: float = dynamic(0.0, doc=_ZMIN_DOC)
z_max: float = dynamic(0.0, doc=_ZMAX_DOC)
z_max_step: float = dynamic(0.0, lo=0.0, doc="Grade cap |dz| per unit arc (>= 0; per "
"env).")
z_control_points: int = static(10, lo=3, doc="Arc-spaced altitude knots (>= 3).")
def validate(self, layout) -> None:
_check_z_range(self)
if float(self.z_max_step) < 0.0:
raise ValueError(f"z_max_step must be >= 0, got {self.z_max_step!r}")
_check_control_points(self)
@classmethod
def _check_draws(cls, params, ranges, randomized) -> None:
# Plan time (every build, drawn or not): crossing draws, then an inert grade
# cap in a closed window.
_check_z_draws(params, ranges)
_warn_inert(params, ranges, "z_max_step", "elevation.random_walk")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class ElevationNoiseParams(_StageFlatParams):
"""``elevation.noise``: periodic harmonic noise around ``z_base`` (``z_min`` /
``z_max`` / ``z_noise_amplitude`` dynamic: the difficulty knob is
``z_noise_amplitude`` in a wide, symmetric window)."""
z_base: float = static(0.0, doc="Baseline altitude.")
z_min: float = dynamic(0.0, doc=_ZMIN_DOC)
z_max: float = dynamic(0.0, doc=_ZMAX_DOC)
z_noise_amplitude: float = dynamic(0.0, lo=0.0, doc="Oscillation amplitude (>= 0; per "
"env).")
z_noise_harmonics: int = static(3, lo=1, doc="Summed harmonics (>= 1).")
def validate(self, layout) -> None:
_check_z_range(self)
if float(self.z_noise_amplitude) < 0.0:
raise ValueError(
f"z_noise_amplitude must be >= 0, got {self.z_noise_amplitude!r}")
if int(self.z_noise_harmonics) < 1:
raise ValueError(
f"z_noise_harmonics must be >= 1, got {self.z_noise_harmonics!r}")
@classmethod
def _check_draws(cls, params, ranges, randomized) -> None:
# Plan time (every build, drawn or not): crossing draws, then an inert
# amplitude in a closed window.
_check_z_draws(params, ranges)
_warn_inert(params, ranges, "z_noise_amplitude", "elevation.noise")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class LiftFlatParams(_FlatParams):
"""``lift.flat``: the constant altitude of every point."""
z_base: float = static(0.0, doc="Altitude of every point.")
# ---------------------------------------------------------------------------------------
# The gate pipeline (roadmap PR11)
# ---------------------------------------------------------------------------------------
def _f32_distance(gate_radius) -> float:
"""``float32(2 * gate_radius)``: the target distance the relax kernel receives."""
return float(np.float32(2.0 * float(gate_radius)))
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class GateRelaxParams(_StageFlatParams):
"""``gate_relax.spheres``: separate overlapping gate spheres (plan view) to a centre
distance of ``2 * gate_radius``, for at most ``gate_solve_iters`` sweeps."""
gate_radius: float = static(0.025, lo=0.0, doc="Gate sphere radius (the centre "
"distance is 2 * gate_radius).")
gate_solve_iters: int = static(8, lo=0, doc="Maximum separation sweeps (>= 1).")
def validate(self, layout) -> None:
if int(self.gate_solve_iters) < 1 or not _f32_distance(self.gate_radius) > 0.0:
raise ValueError("gate_relax.spheres needs gate_solve_iters >= 1 and a "
"gate_radius whose float32 2*r is > 0; use gate_relax.none "
"to skip the solve")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class NormalizeExtentParams(_StageFlatParams):
"""``normalize.extent``: the per-env size of the normalized gate layout
(``GateGenConfig.size`` for the polar / voronoi / checkpoint gate generators)."""
size: float = dynamic(1.0, lo=SIZE_BOUNDS[0], hi=SIZE_BOUNDS[1],
doc="Per-env size multiplier of the target extent (scale * "
"TARGET_EXTENT * size; per env; 1: unchanged).")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class GateFramesParams(_StageFlatParams):
"""``gate_frames.default``: the gate frames, ``half_size = 0.5 * gate_width``
(``gate_width`` is dynamic: the initial value of a per-env width)."""
gate_width: float = dynamic(0.0, lo=0.0, doc="FULL gate bar width (half_size = 0.5 "
"* gate_width; 0: point gates; per "
"env).")
gate_align: str = static("yaw_only", choices=_ALIGNS,
doc="yaw_only (level gates) or full_tangent (tilted).")
def validate(self, layout) -> None:
if float(self.gate_width) < 0.0:
raise ValueError(f"gate_width must be >= 0, got {self.gate_width!r}")
if self.gate_align not in _ALIGNS:
raise ValueError("gate_align must be one of {'yaw_only', 'full_tangent'}, got "
f"{self.gate_align!r}")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class GateFramesPosedParams(_StageFlatParams):
"""``gate_frames.posed``: the gate frames of a generator that provides per-gate
``half_size`` and ``roll``."""
gate_align: str = static("yaw_only", choices=_ALIGNS,
doc="yaw_only (level gates) or full_tangent (tilted).")
def validate(self, layout) -> None:
if self.gate_align not in _ALIGNS:
raise ValueError("gate_align must be one of {'yaw_only', 'full_tangent'}, got "
f"{self.gate_align!r}")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class GateValidityParams(_StageFlatParams):
"""``validity.gate_default``: the fused per-env gate validity check. The bar-crossing
check runs per env on every env with a non-zero bar, so ``gate_width`` and
``check_crossing`` are INERT (kept so specs and dicts load; a ``gate_width`` other
than its default 0 must still equal ``gate_frames``' initial width; removed in
1.0)."""
#: Inert: left out of the planner's agreement check at the default.
_inert_fields = ("gate_width", "check_crossing")
_field_hints = {
"gate_width": "inert since 0.2.0.dev5: the per-env bar width is "
"gate_frames.gate_width",
"check_crossing": "inert since 0.2.0.dev5: the bars of every env that has one "
"are checked",
}
min_gates: int = static(4, lo=2, doc="Fewest real gates of a valid env (>= 2).")
gate_radius: float = static(0.025, lo=0.0, doc="Gate sphere radius: real gates "
"closer than 2 * gate_radius fail.")
gate_width: float = static(0.0, lo=0.0, doc="Inert since 0.2.0.dev5 (the crossing "
"check runs per env on non-zero bars); "
"if set, equals gate_frames' initial "
"width.")
z_valid_grade: float = static(0.0, lo=0.0, doc="Maximum |dz|/ds chord grade (0: "
"off).")
check_crossing: str = static("width", choices=("width", "always"),
doc="Inert since 0.2.0.dev5: both values check the bars "
"of every env that has one (removed in PR16).")
def validate(self, layout) -> None:
if int(self.min_gates) < 2:
raise ValueError(f"min_gates must be >= 2, got {self.min_gates!r}")
if float(self.gate_radius) < 0.0:
raise ValueError(f"gate_radius must be >= 0, got {self.gate_radius!r}")
if float(self.gate_width) < 0.0:
raise ValueError(f"gate_width must be >= 0, got {self.gate_width!r}")
if float(self.z_valid_grade) < 0.0:
raise ValueError(f"z_valid_grade must be >= 0, got {self.z_valid_grade!r}")
[docs]
@dataclasses.dataclass(frozen=True, kw_only=True)
class StationsParams(_StageFlatParams):
"""``stations.raw`` / ``stations.relaxed``: ``gates`` anchors at equal arc length
along a centerline loop: plan-view (``spacing_metric="xy"``) or 3D (``"xyz"``, which
needs the loop's altitude: a generator that provides ``z``, or an elevation run on
the relaxed centerline)."""
gates: int = static(12, lo=3, doc="Stations (anchors) per env (>= 3).")
spacing_metric: str = static("xy", choices=("xy", "xyz"),
doc="Arc length the stations are equally spaced in: "
"plan view (xy) or 3D (xyz).")
def validate(self, layout) -> None:
if self.spacing_metric not in ("xy", "xyz"):
raise ValueError(f"spacing_metric must be 'xy' or 'xyz', got "
f"{self.spacing_metric!r}")