Add trajectory variants between fixed waypoints - #651
Yuan-Xinyi wants to merge 1 commit into
Conversation
9e5f087 to
34535e8
Compare
|
34535e8 to
3da4783
Compare
3da4783 to
f04a45e
Compare
f04a45e to
b47f5d7
Compare
| """ | ||
| if type(count) is not int or count < 1: | ||
| raise ValueError("count must be a positive integer") | ||
| cfg = default_variant_factors() if cfg is None else cfg |
There was a problem hiding this comment.
Default variants cannot be applied
Calling plan_trajectory_variants(count) without a configuration enables nullspace_residual, but calling apply_trajectory_variant(...) with its defaults provides no task Jacobians. Applying one of those planned null-space variants therefore raises ValueError, so the documented default plan-then-apply workflow fails. Planning must omit redundancy when its required input is unavailable, or application must receive the resolved configuration and inputs.
Prompt To Fix With AI
This is a comment left during a code review.
Path: embodichain/lab/sim/motion/expansion/variants.py
Line: 246
Comment:
**Default variants cannot be applied**
Calling `plan_trajectory_variants(count)` without a configuration enables `nullspace_residual`, but calling `apply_trajectory_variant(...)` with its defaults provides no task Jacobians. Applying one of those planned null-space variants therefore raises `ValueError`, so the documented default plan-then-apply workflow fails. Planning must omit redundancy when its required input is unavailable, or application must receive the resolved configuration and inputs.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.b47f5d7 to
fb38d5e
Compare
| if member is str and isinstance(value, str): | ||
| value = (value,) |
There was a problem hiding this comment.
Timing Scalars Bypass Validation
This scalar-to-tuple conversion applies to every tuple[str, ...] field, not only spatial.method. As a result, a malformed value such as timing.profiles: "uniform" is converted into a tuple and accepted even though timing profiles require a nonempty sequence. Restricting this compatibility behavior to spatial.method would preserve strict validation for timing profiles.
Prompt To Fix With AI
This is a comment left during a code review.
Path: embodichain/lab/sim/motion/expansion/cfg.py
Line: 104-105
Comment:
**Timing Scalars Bypass Validation**
This scalar-to-tuple conversion applies to every `tuple[str, ...]` field, not only `spatial.method`. As a result, a malformed value such as `timing.profiles: "uniform"` is converted into a tuple and accepted even though timing profiles require a nonempty sequence. Restricting this compatibility behavior to `spatial.method` would preserve strict validation for timing profiles.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.b12a595 to
33bb47a
Compare
| if factors.approach.enabled: | ||
| # The approach factor moves a Cartesian standoff pose, which has to be | ||
| # replanned through IK before it is a trajectory. Enumerating qpos | ||
| # variants from a configuration that requests it would drop it without | ||
| # a trace. | ||
| raise ValueError( | ||
| "the approach factor produces Cartesian standoff poses and cannot " | ||
| "be applied to a qpos reference; consume it with " | ||
| "perturb_approach_direction before planning, then disable it here" | ||
| ) |
There was a problem hiding this comment.
Approach factor silently ignored
A caller can pass an approach-enabled configuration directly to the public apply_trajectory_variant entry point. Unlike planning and expansion, this path neither applies nor rejects the approach factor, so it returns a qpos trajectory without the requested approach variation. Apply the same fail-fast validation in the direct application path.
Prompt To Fix With AI
This is a comment left during a code review.
Path: embodichain/lab/sim/motion/expansion/variants.py
Line: 249-258
Comment:
**Approach factor silently ignored**
A caller can pass an approach-enabled configuration directly to the public `apply_trajectory_variant` entry point. Unlike planning and expansion, this path neither applies nor rejects the approach factor, so it returns a qpos trajectory without the requested approach variation. Apply the same fail-fast validation in the direct application path.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.| scores = episode.observations.get("manipulability") | ||
| if ( | ||
| scores is None | ||
| or scores.ndim != 1 | ||
| or scores.shape != episode.timestamps.shape | ||
| or bool((scores < 0).any()) | ||
| ): | ||
| raise ValueError( | ||
| "Banded coverage requires one non-negative measured manipulability " | ||
| "value per observation." | ||
| ) | ||
| return bands.band_of(float(scores.min())) |
There was a problem hiding this comment.
Invalid Evidence Leaks Capacity
When banded coverage is enabled, a missing, malformed, negative, or non-finite manipulability observation raises during coverage classification without releasing the running attempt. Because running attempts retain their episode-byte reservation, one bad rollout can permanently consume pending capacity and block later candidates. Release the attempt before propagating this evidence-validation failure, as is already done for other rejected episodes.
Prompt To Fix With AI
This is a comment left during a code review.
Path: embodichain/lab/sim/motion/expansion/session.py
Line: 533-544
Comment:
**Invalid Evidence Leaks Capacity**
When banded coverage is enabled, a missing, malformed, negative, or non-finite `manipulability` observation raises during coverage classification without releasing the running attempt. Because running attempts retain their episode-byte reservation, one bad rollout can permanently consume pending capacity and block later candidates. Release the attempt before propagating this evidence-validation failure, as is already done for other rejected episodes.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.c3774af to
9c5d452
Compare
| __all__ = [ | ||
| "rotate_grasp_about_object_axis", | ||
| "perturb_approach_direction", | ||
| "joint_residual", | ||
| "via_points", | ||
| "nullspace_residual", | ||
| "retime", | ||
| "validate_motion_limits", | ||
| ] |
There was a problem hiding this comment.
TIMING_PROFILES is documented and re-exported as public API, but the module that defines it omits it from __all__. This violates the repository directive that public modules define their public exports, so the requirement must be satisfied before merging.
| __all__ = [ | |
| "rotate_grasp_about_object_axis", | |
| "perturb_approach_direction", | |
| "joint_residual", | |
| "via_points", | |
| "nullspace_residual", | |
| "retime", | |
| "validate_motion_limits", | |
| ] | |
| __all__ = [ | |
| "TIMING_PROFILES", | |
| "rotate_grasp_about_object_axis", | |
| "perturb_approach_direction", | |
| "joint_residual", | |
| "via_points", | |
| "nullspace_residual", | |
| "retime", | |
| "validate_motion_limits", | |
| ] |
Context Used: CLAUDE.md (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: embodichain/lab/sim/motion/expansion/operators.py
Line: 36-44
Comment:
**Public export is missing**
`TIMING_PROFILES` is documented and re-exported as public API, but the module that defines it omits it from `__all__`. This violates the repository directive that public modules define their public exports, so the requirement must be satisfied before merging.
```suggestion
__all__ = [
"TIMING_PROFILES",
"rotate_grasp_about_object_axis",
"perturb_approach_direction",
"joint_residual",
"via_points",
"nullspace_residual",
"retime",
"validate_motion_limits",
]
```
**Context Used:** CLAUDE.md ([source](https://github.com/dexforce/embodichain/blob/main/CLAUDE.md))
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.c7bcd6f to
1091c7b
Compare
Affordance sampling (#644) varies where the robot makes contact. This adds the other half: once a plan's waypoints are settled, produce several different ways to execute them, so imitation learning and reinforcement-learning post-training see more than one solution per task. Operators (expansion/operators.py): - via_points routes a free phase through sampled interior knots using clamped cubic Hermite segments, so several knots change the shape of the path rather than only its amplitude. - nullspace_residual projects a residual onto the null space of task Jacobians supplied by the caller, changing arm posture while holding the declared task rows to first order. A fully constrained task raises instead of silently returning the reference. - retime gains a bounded within-phase profile (uniform, ease_in, ease_out) that changes the velocity profile without changing the path or the phase duration. The uniform path is arithmetically unchanged. - perturb_approach_direction places standoff poses on a cone while leaving the contact transform exact. - Joint-limit rejection now covers only the joints an operator actually moved. An observed reference can hold an untouched joint microradians outside its range, and checking it rejected every proposal while blaming the residual. Every qpos operator uses an envelope that is zero with zero derivative at both phase endpoints, and contact and hold phases are never touched, so annotated waypoints stay bit-identical. expansion/variants.py gives the existing expansion contracts their first caller. Asking for a number of variants is the whole interface: omitting the configuration resolves default_variant_factors, which enables every implemented factor the supplied inputs support at measured magnitudes and leaves the null-space factor off when no Jacobians are given. An explicit configuration is never overridden, and an explicitly enabled factor that cannot produce a variant raises rather than disappearing. spatial.method now names one or more joint-path methods so "every joint-path operator" is expressible, and a bare string still works. The Place tutorial is the demonstration host, hooked the way #644 hooked its tutorials: shared helpers in tutorial_utils.py, a --trajectory_variants flag mapping one variant per simulation row, and advanced overrides in their own argument group. Phases come from the action's own named trajectory segments, and only motion at or after the lift is retimed so the shared clear_dynamics() step index stays aligned. --variant_plot_dir writes a joint-trajectory figure and a rendered tool-path overlay. Task Program, Gym lifecycle, dataset persistence and GenerationSession collection bookkeeping remain deliberately out of scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1091c7b to
bca626c
Compare
| operator = variant.spatial_operator | ||
| label = None if operator in seen else operator | ||
| seen.add(operator) | ||
| styles.append( | ||
| { | ||
| "color": OPERATOR_COLOURS.get(operator), | ||
| "linewidth": 2.4 if nominal else 0.9, | ||
| "linestyle": "--" if nominal else "-", | ||
| "alpha": 1.0 if nominal else 0.35, | ||
| "zorder": 3 if nominal else 2, | ||
| "label": "reference" if nominal else label, |
There was a problem hiding this comment.
When more than twelve variants are plotted, this code groups them only by spatial_operator. In a timing-only expansion, the reference claims the none group first, so variants with different duration scales or timing profiles receive no legend label. The resulting validation figures do not explain what the remaining black curves represent.
Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/tutorials/atomic_action/tutorial_utils.py
Line: 846-856
Comment:
**Timing Variants Lose Labels**
When more than twelve variants are plotted, this code groups them only by `spatial_operator`. In a timing-only expansion, the reference claims the `none` group first, so variants with different duration scales or timing profiles receive no legend label. The resulting validation figures do not explain what the remaining black curves represent.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| enabled: bool = False | ||
| method: str = "nullspace_residual" | ||
| normalized_scale: float = 0.05 | ||
| task_rows: tuple[int, ...] = (0, 1, 2, 3, 4) |
There was a problem hiding this comment.
[P1] Apply ik.task_rows or remove the public knob — This field is validated and documented as a configuration option, but no code in the variant application path reads it; the full task_jacobians tensor is passed straight to nullspace_residual. A caller selecting a subset of spatial rows therefore gets the same projection as the default, with no warning. Select these rows at one clearly owned boundary (and test a full six-row input with a custom subset), or remove the field and document that callers must pre-reduce the Jacobian.
| task_jacobians=task_jacobians, | ||
| generator=generator, | ||
| ) | ||
| except ValueError as error: |
There was a problem hiding this comment.
[P1] Do not swallow malformed inputs as proposal rejections — apply_trajectory_variant raises ValueError for structural or caller errors as well as for an operator rejecting a sampled proposal (for example invalid Jacobian or joint-limit shapes, or an invalid control period). Catching every ValueError here converts those programming/configuration errors into rejected-count bookkeeping and can return a partial result after the nominal variant has already succeeded. Prevalidate inputs before the attempt loop or introduce a dedicated rejection exception and catch only that type.
| q[phase.start_index : phase.stop_index, indices] += ( | ||
| envelope[:, None] * window | ||
| ).to(q.dtype) | ||
| if largest <= 0: |
There was a problem hiding this comment.
[P2] Use a numerical rank test instead of largest <= 0 — For a generic full-rank, non-orthogonal Jacobian, I - pinv(J) @ J contains floating-point residuals even though the null space is mathematically zero, so largest will usually be positive. The current check therefore only reliably rejects special cases such as an identity Jacobian and may emit a near-unchanged variant when no redundancy exists. Use the singular values/rank with the existing rank_tolerance, or compare the projector effect against a scale-aware tolerance, and add a non-diagonal full-rank regression test.
Description
Affordance sampling (#644) varies where the robot makes contact. This PR covers the other half: once a plan's waypoints are settled, produce several different ways to execute them, so downstream imitation learning and reinforcement-learning post-training see more than one solution per task instead of many copies of one.
The two compose. A sampled affordance produces a new set of waypoints, and variant expansion then produces several ways of executing that set.
This also gives
motion/expansion/its first caller. The package already shipped candidate contracts, coverage bookkeeping andGenerationSession, but nothing in the repository used them, and only two of its seven declared augmentation factors were implemented.It follows #644's structure: the algorithms live at the simulation layer, a tutorial is the demonstration host, and Task Program, Gym lifecycle, dataset persistence and
GenerationSessioncollection bookkeeping are explicitly deferred. Noembodichain_tasks/file is touched.Refs #644
Main changes
Operators (
expansion/operators.py)via_pointsroutes an allowed free phase through interior knots, interpolated with clamped cubic Hermite segments.joint_residualadds one fixed-shape bump per phase; with two or more knots this changes the shape of the path, not only its amplitude. The knots' signed magnitudes vary along one sampled joint direction: sampling every joint of every knot independently decorrelates the joints, and the tool path that forward kinematics produces then wanders — measured at 1.51x the reference arc length on the Place reference, against 1.11x once the knots share a direction.nullspace_residualprojects a residual onto the null space of task Jacobians supplied by the caller, changing arm posture while holding the declared task rows. A fully constrained task raises rather than silently returning the reference.retimegains a bounded within-phaseprofile(uniform,ease_in,ease_out) that redistributes time inside a phase without changing the path or the phase's total duration. Theuniformpath is arithmetically unchanged from the existing operator.perturb_approach_directionplaces standoff poses on a cone around a nominal approach direction while leaving the contact transform exact.gripper_finger2_joint_1at −8e−6, just under its0.0lower bound, which rejected 23 of 24 proposals while reporting "Sampled residual violates joint limits". The check now covers only the joints an operator actually moved. This also affected the pre-existingjoint_residual.Every qpos operator uses an envelope that is zero, with zero derivative, at both endpoints of the phase it modifies, and
contactandholdphases are never touched. Annotated waypoints, contact windows and dwell durations stay bit-identical to the reference.Variant generation (
expansion/variants.py, new)spatial.methodnames one or more joint-path methods. It was a single-choice string, so "enumerate every joint-path operator" could not be expressed at all. It now accepts a sequence, each requested method becomes its own variant, and a bare string is still accepted where a sequence is expected, so the existing spelling keeps working.expand_trajectory_variants(template, case=..., count=8, joint_limits=..., control_dt=...)is the whole interface, with every implemented method enabled by default. Everything else is an advanced option:default_variant_factorsto read or adjust the policy,cfg=for exact control,plan_trajectory_variants/apply_trajectory_variantto separate enumeration from application, and the optional motion-limit and attempt-budget arguments. The tutorial mirrors the split:--trajectory_variantsis the only variant flag an ordinary run needs, and the remaining eight sit in anadvanced variant optionsgroup so--helpshows the simple path first. With no advanced flag set the tutorial calls the helpers with no configuration at all, so the documented simple path is the one actually exercised.expand_trajectory_variants(template, case=..., count=6, ...)is the whole API for "give me six different ways to run this". Omittingcfgresolvesdefault_variant_factors, which enables every implemented factor the supplied inputs support at the magnitudes measured below, and drops the null-space factor when no Jacobians are given rather than proposing variants that would be rejected. An explicitcfgis never overridden, and an explicitly enabled factor that cannot produce a variant now raises instead of disappearing. That coversikwithouttask_jacobians, andapproach, whose Cartesian standoff poses have to be replanned through IK first: the configuration schema still accepts the factor so an upstream planning stage can declare it, but the qpos entry points refuse it rather than silently producing no approach variation. The resolved settings come back onTrajectoryVariantSet.cfg, so an auto-configured run stays reproducible.plan_trajectory_variantslists the enabled factor combinations. Ordinal zero is always the unmodified reference; later ordinals cycle through the enabled joint-path operators, then the duration scales, then the time warps.apply_trajectory_variantapplies one combination, running at most one joint-path operator so a null-space projection is never stacked on an already displaced path.expand_trajectory_variantscollects variants for one fixed scene, rejecting proposals that an operator refuses, that fail sampled motion limits, or whose measured geometry and timing duplicate an accepted variant. Each rejection is counted under a key naming its reason, so a caller can retune the offending setting instead of guessing.CoverageIndex.family_ofexposes the resolved geometry family, sogeometry_family_idreflects measured grouping rather than a fresh digest.Configuration (
expansion/cfg.py)ikandapproachwere_DisabledFactorCfgstubs that raised when enabled; they are now real settings.spatialgainsvia_countandtiminggainsprofiles.contact,contact_timingandrecoveryremain unimplemented and are still rejected when enabled.Tutorial hook (
scripts/tutorials/atomic_action/place.py,tutorial_utils.py)No new tutorial. Following #644, the shared logic lives in
tutorial_utils.pyand the existing Place tutorial grows a small hook (+40/-3):--trajectory_variantsmaps one variant per simulation row, the same shape--affordance_branchesuses. Row zero always replays the plan unchanged.The tutorial declares only which of the action's own named
TrajectorySegmentranges may move — gripper-close and release are contact phases, and only motion at or after the lift is retimed so the sharedclear_dynamics()step index stays aligned. Everything else comes from the default policy. Advanced overrides sit in their ownadvanced variant optionsargument group.--variant_plot_dir <dir>writes two figures:0.00e+00 rad, so contact windows are preserved exactly rather than approximately.0.0e+00 m, together with the lowest tool centre (2.8 cm) so ground clearance is stated rather than implied by a viewing angle.A junction handler removes the zero-duration sample each concatenated action repeats at a join, after verifying it is a duplicate; a join that actually moves the robot is refused rather than discarded.
What this does and does not guarantee
nullspace_residualholds the declared task rows to first order only. Phase endpoints stay exact because the envelope vanishes there, but interior samples drift with linearization error and need forward-kinematics verification by the host.BaseSolver.get_jacobianreturns a base-frame Jacobian, so dropping its angular-z row removes rotation about base z, not about the tool axis. Those coincide for a top-down grasp, which is why the tutorial keeps rows(0, 1, 2, 3, 4). Declaringcontrolled_joint_indicesin solver order keeps the columns aligned without any permutation.Figures
Both are committed under
docs/source/_static/trajectory_variants/and embedded in the overview page, so they survive the branch. Produced by the command below with a hundred variants.Arm joints of 100 variants against time. Curves separate between waypoints and rejoin inside the shaded contact phases. The title carries the largest contact-phase deviation from the reference:
0.00e+00 rad. The wide green band onjoint6isnullspace_residual— for a grasp taken from directly above, wrist roll is the freedom the declared task rows leave open.Tool paths of the same 100 variants. Stars are the annotated waypoints; every variant draws its own, so they would scatter if an operator had moved one. They spread by
0.0e+00 m. The arm is drawn at its starting configuration and the cube to scale on the ground.Measured over those hundred: all accepted, no rejected proposals, a hundred distinct geometry families, median tool-path length 1.09x the reference and 1.38x at worst, and three variants whose lowest tool centre dips under 2 cm against a median of 2.8 cm. Sampling more variants reaches further into those tails, which argues for a host-side clearance or collision check rather than trusting the generator alone.
Calling it
Configuration is optional; every implemented factor is enabled by default.
The tutorial does exactly this behind one flag:
Type of change
Validation
Automated checks:
black --check ./python docs/scripts/check_api_docs.pycontext.py checkand routingpytest tests/sim/motion/expansionpy_compileon the touched and neighbouring tutorialspytest tests/test_agent_context_*.py tests/docspython -m py_compileon the tutorialThe one
tests/docsfailure istest_project_myst_configuration_remains_valid, which raisesModuleNotFoundError: No module named 'myst_parser'. Sphinx and MyST are not installed in this development environment, so that test and the Sphinx docs build could not run here; both are unrelated to this change and covered by the CIbuildjob.The tutorial was launched on a GPU (RTX 4090) and its results are below, unlike the affordance sampling tutorials in #644, which that PR could not launch:
Six distinct geometry families covering all three joint-path operators, no rejected proposals, and exact preservation of every contact phase. The two figures are written to
--variant_plot_dir; they are not committed, sinceoutputs/is gitignored. This run exercised the real UR5 solver Jacobian throughnullspace_residual, which unit tests alone could not.Measured tuning
joint_offset_scaleis a fraction of each joint's declared range, and this arm declares ±2π per joint, so small fractions are large in absolute terms:joint_offset_scaleThe usable window is narrow on a task this constrained, and it interacts with deduplication: at the shared default of 0.01 for both
joint_offset_scaleandcoverage.joint_dedup_normalized_tol, thevia_pointsvariant collapses into the reference's geometry family. The tutorial therefore defaults--dedup_toleranceto 0.004. Both numbers are documented rather than silently tuned.Default configuration
The schema's own defaults leave every factor off, set
joint_offset_scaleto 0.05,via_countto 1 andtarget_per_cellto 1. Enabling factors one at a time from those values reproduces every problem measured above: a silent single-variant result, offsets past the point where the grasp survives,via_pointsdegenerating to a single bump, and timing variants deduplicated away. Those schema defaults are left untouched, since an all-off configuration is a meaningful explicit state; the working values live indefault_variant_factorsand apply when a caller does not supply a configuration at all.Naming
An earlier revision called these "trajectory modes". That word was invented for this change; the expansion package already says "variant" (
CoverageIndexdocuments "timing variants per geometry") and #637 says "variations". The API, configuration, module, docs and tests usevariantso the change reuses the repository's existing vocabulary instead of adding a parallel term.Checklist
black .command to format the code base.python docs/scripts/check_api_docs.py), if applicable🤖 Generated with Claude Code