Skip to content

Fix skipped/drifting frequency-limited handlers; keep speed when a moving omni agent gets a new goal - #76

Open
yuokamoto wants to merge 17 commits into
mainfrom
yu/frequency-and-carry-velocity
Open

yuokamoto wants to merge 17 commits into
mainfrom
yu/frequency-and-carry-velocity

Conversation

@yuokamoto

@yuokamoto yuokamoto commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

Summary

Two timing fixes, one commit each.

1. Frequency-limited handlers run on a fixed grid; post-step callbacks

Callbacks and plugins with a frequency, collision_check_frequency and the DataMonitor refresh each decided whether they were due with sim_time - last >= interval, then set last to the time they ran. Two problems followed:

  • Skipped beats. sim_time accumulates one timestep at a time and carries float error. A 10 Hz callback on a 0.1 s timestep ran at gaps of 0.1 s and 0.2 s. A 1 Hz plugin accumulates 0.1 ten times, which is 0.9999999999999999, so it missed every other second.
  • Drift. Setting last to the time the handler actually ran let the phase drift whenever the interval was not a whole number of timesteps.

The fix is a new FrequencyGate (pybullet_fleet/_frequency.py), now used by all four:

  • Due times lie on a grid, k / frequency from the start of the run.
  • The comparison tolerates float error far below one timestep.
  • A handler runs at most once per step. After a gap (a pause, a late registration) it runs once at the next due time, not once for every due time it missed.
  • The first run is still one interval into the run, and dt is the simulated time since the previous run.
  • A DataMonitor with a GUI now refreshes every step, as documented. It used a strict > against one timestep before, so in practice it refreshed less often. Its default rate follows the final GUI mode: RECORD turns the GUI off in setup_pybullet(), and the monitor then refreshes once a second.

New API:

  • register_callback(cb, frequency=None, phase="step" | "post_step"). "post_step" runs right after the post_step event: poses are written, the active collision pairs are those of the new poses when the step ran a collision check, and the same frequency option applies, scheduled on the poses' time (step_end_time). This is the point at which a consumer that publishes or records each step's poses at a fixed rate wants to run. Before, it had to subscribe to POST_STEP and rate-limit by itself.

  • MultiRobotSimulationCore.step_end_time: the simulated time the bodies' poses belong to. Once a step has updated its objects (Phase-1 callbacks, plugin hooks, post_step, post-step callbacks), sim_time is still the time the step started at, while the poses are already those at its end. So step_end_time is sim_time + timestep from the object update on, sim_time in a pre_step subscriber (which runs before the update), and the elapsed time outside a step. Anything read from the bodies should be stamped with it.

  • One time for a pose and its trajectory. Position controllers (per-agent and batched, omni and differential) evaluate their trajectories at step_end_time and start a trajectory there, as kinematic joints already did; the core raises its pose-time flag before the object update so this holds during the update too. A step's pose is the trajectory at the time it is stamped with (from rest at 4 m/s² and 0.05 s steps: x = 0.08 at 0.20 s). Before, poses were evaluated at the step's start and so lagged their stamp by a step. Consequences: a goal set in a pre_step subscriber moves in that same step, a motion completes one step sooner, and a replaced goal is judged on the speed of the pose in effect, per-agent and batched alike, from whichever phase it is set.

  • The DataMonitor's default refresh (monitor_update_interval=None) follows the monitor window: every step while it is open (enable_monitor_gui, or gui when that is unset), once a second otherwise.

Docs: docs/architecture/two-phase-step.md covers the post-step phase, which time a pose belongs to (and that controllers work in it), and the schedule. plugins-events.md covers the plugin dt.

2. A goal replaced mid-motion continues the motion (omni) -- or is refused

OmniController built every new trajectory with build_tpi(p0=0, ...), which has no initial speed. On top of that, Agent.set_path added the current pose as an approach waypoint, which is a zero-length segment. So an agent cruising at full speed that got a goal further along the same line dropped to 0 m/s in one step and accelerated again. KinematicJoint already carries a replaced target's speed into the next trajectory; the omni controllers now do the same.

The rule: build the straight-line trajectory to the first waypoint from the agent's current state; take the goal if that trajectory exists within the configured limits, refuse it otherwise. The check and the motion use the same solver call, so they cannot disagree.

  • v0 is the signed component of the current velocity along the new line (magnitude clamped to vmax). A goal ahead keeps the speed; a goal behind is reached by slowing, reversing and driving back, with the velocity running continuously through zero. The slowing before a reversal is the solver's first phase and uses max_linear_accel.
  • Refused (no trajectory within the limits): a goal closer than the braking distance at max_linear_decel; a goal where the moving agent is; a goal off the line of motion, whose sideways velocity a straight-line trajectory cannot carry (a small documented tolerance absorbs numerical noise). Nothing ever brakes harder than configured and nothing restarts from rest mid-motion.
  • What a refusal does: nothing changes -- the current motion and path continue. Agent.set_path() / set_goal_pose() return False (True when taken; the AgentManager.set_goal_pose* wrappers pass it on) and the controller logs the reason at INFO. Agent.path_rejection(path) returns the reason without acting, as a PathRejection(reason, temporary): temporary is a refusal that lasts only while the agent moves; a fixed-base robot and an empty path are permanent. At rest every goal is taken, as before.
  • Callers inside the library retry a temporarily refused goal on their next update (a permanent refusal behaves as before): MoveAction goes back to NOT_STARTED, PatrolController advances only once a goal is taken, RandomWalkController picks again, and the behaviour tree's NavigateTo stays RUNNING (NavigationAdapter.start_navigation() may now return False; None still means started). FleetCommandDispatcher.navigate() checks first and reports a refusal in the ack's rejected map, before the command event, so nothing is mutated for it.
  • BatchOmniController applies the same rule through synchronized_set_path(), taking the velocity from the row's own profile rather than the reported (step-averaged) one. trapezoid_distance takes a per-row signed v0, and the evaluator no longer clips progress at the start, so a row that starts moving away from its goal runs behind its start before turning. Tests check per-agent and batch tracks against each other.
  • The initial velocity is used only while the agent is under way (a translation in progress or a velocity command), so the arrival step's leftover velocity never feeds a trajectory. Agent.set_path skips the approach waypoint in the same situation.
  • Solver fix: for a body starting away from its target, two-point-interpolation 1.3.1 can choose the smaller of two positive first-phase roots, which yields a trajectory that does not end at the target. try_build_tpi() now checks that every trajectory ends at its target and, if not, uses the larger root. Checkpoint restore goes through the same function. (Worth fixing in the library itself too.)
  • Navigation checkpoints record the forward trajectory's v0. The kinematic profile's controller state is now version 2.
  • DifferentialController turns in place before it drives, so it still starts from rest.

Validation

  • Full suite: 2202 passed, 12 skipped, 0 failed.
  • tests/test_pose_time.py (new, 14 tests): every pose stamped with step_end_time is the trajectory at that time, per-agent and batch, in post-step callbacks and outside a step; a goal replaced from each phase (outside, pre_step, Phase-1 callback, post_step event, post-step callback) is accepted or refused on the speed at step_end_time alike by both controllers; and the reported pre_step case (0.1 m ahead after five steps is inside the 0.125 m braking distance of 1.0 m/s; 0.13 m is taken). Evaluating at sim_time again fails 8 of them, raising the flag after the update fails 8, and the batch check on the next step's speed fails the 2 pre_step batch cases.
  • Follow-ons: the omni checkpoint proof checks the pose against the trajectory at the checkpoint's elapsed time and ends at 1.3 s (the arrival step); its evidence docs are updated, and the manipulation scenario's checkpoints move one step earlier. The per-agent/batch equivalence tests now compare the same path (the reference no longer gets the zero-length approach waypoint bc.set_path() does not add), and the batch buffered-write tests read the PyBullet pose by body_id.
  • tests/test_frequency_gate.py (new, 23 tests): the gate itself (no skipped beats, no drift at 3 Hz on a 0.1 s timestep, first run one interval in, one run after a gap, faster than the step rate), callbacks, plugins and collision_check_frequency through the core, the post-step phase order and rate, and step_end_time (including in a pre_step subscriber). The three core tests fail on main.
  • tests/test_goal_replacement_speed.py (new, 40 tests, per-agent and batch). Every motion test checks, per step, that the velocity vector never changes faster than the configured ramps:
    • speed kept toward a goal further ahead, and across repeated replacements;
    • stopping within reach at the configured deceleration, with no overshoot, reversal or lateral motion;
    • a goal behind is reached by reversing, continuously through zero (also from a velocity command);
    • refused, with the path and motion unchanged and the previous goal still reached: a goal inside the braking distance, at right angles, and where the agent is (also off a velocity command);
    • at rest any goal is taken; a goal set in the arrival step starts from rest;
    • MoveAction retries a refused path and completes it once the motion ends;
    • per-agent and batch produce the same track;
    • checkpoints keep the initial speed, including a reversal's negative one;
    • the solver's reversal trajectory ends at its target, and a reversal's checkpoint passes profile validation;
    • permanent refusals (fixed base) are not retried: MoveAction completes, NavigateTo counts as started.
      Turning off the rejection, the signed v0, the root fix or the MoveAction retry each fails tests here.
  • tests/test_fleet_api.py: a refused goal is reported in the ack and the robot is not touched. A goal refused only after the command event (a handler of it changed the robot) is not acknowledged and not recorded as accepted. tests/test_behavior_tree.py: NavigateTo retries a refused start.
  • Existing tests that read the old last_exec / _accumulator fields now check the behavior: the schedule restarts after reset() / initialize_simulation(). The DataMonitor cadence test now pins exact counts, which were not deterministic before.
  • Benchmark (benchmark/run_benchmark.py --agents 500 --duration 10 --repetitions 2, alternating runs): step time 5.63–6.07 ms on main, 5.68–5.79 ms here, so no measurable change.
  • pre-commit run on the changed files (black, pyright, flake8, general hooks): passed. Sphinx with -W: passed.

Changelog

  • Updated CHANGELOG.md under [Unreleased] for this user-visible change.
  • No user-visible change; the reason is stated below.

🤖 Generated with Claude Code

yuokamoto and others added 2 commits October 17, 2026 11:21
Callbacks, plugins, the collision check and the DataMonitor refresh each
decided whether they were due with `sim_time - last >= interval` and then set
`last` to the time they ran. sim_time accumulates one timestep at a time and
carries float error, so a 10 Hz callback on a 0.1 s timestep ran at gaps of
0.1 s and 0.2 s, and a 1 Hz plugin (accumulating 0.1 ten times, which is
0.9999999999999999) missed every other second. Resetting `last` to the run
time also let the phase drift whenever the interval was not a whole number of
timesteps.

FrequencyGate schedules due times on a grid, k / frequency from the start of
the run, compares with a tolerance far below a timestep, runs at most once
per step, and after a gap moves on to the next due time instead of running
once per missed one. The first run is one interval in, as before, and dt is
the simulated time since the previous run.

register_callback() gains phase="post_step", for a frequency-limited callback
that runs after the post_step event, once poses are written and collisions
checked -- where a consumer publishing each step's poses wants to be.
step_end_time gives the time those poses belong to: during a step sim_time is
still the step's start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every new goal or path built its trajectory with build_tpi(p0=0, ...) and no
initial speed, and Agent.set_path put the current pose in front of it as an
approach waypoint -- a zero-length segment. An agent cruising at full speed
that received a goal further along the same line dropped to 0 m/s in one
step and accelerated again. A kinematic joint (KinematicJoint) already carries a replaced
target's speed into the next trajectory; the omni controllers now do too.

- build_tpi_from_speed() starts a trajectory at v0 (clamped to [0, vmax]).
  When the goal is closer than the braking distance at the configured
  deceleration it brakes exactly as hard as needed to stop there: a kinematic
  agent has no other way to honour a goal without overshooting and coming
  back.
- OmniController.set_path reads the agent's velocity before anything zeroes
  it and starts the forward trajectory from its component toward the new
  goal. The component across it, or away from it, is dropped at once, as
  before. Later waypoints of a path still start from rest, as they arrive at
  rest.
- BatchOmniController does the same; trapezoid_distance takes a per-row v0 so
  the batched evaluation matches the per-agent TPI.
- Agent.set_path skips the approach waypoint while the agent is moving.

DifferentialController turns in place before driving and keeps starting from
rest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 17, 2026 02:27

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved timing, scheduling-state, validation, and stale-velocity issues remain.

1 open issue
What changed in this PR

This PR fixes frequency-scheduling drift/skips and preserves omni-agent speed when goals are replaced mid-motion.

Changes:

  • Adds fixed-grid frequency gates and post-step callbacks.
  • Adds step_end_time and updates timing behavior.
  • Preserves speed in per-agent and batch omni trajectories.
  • Updates tests, documentation, and changelog.

Review findings:

  • docs/architecture/two-phase-step.md:110 — nit (1 vote): Clarify PRE_STEP timing versus post-update callbacks.
  • pybullet_fleet/controllers/batch_omni.py:221 — moderate (1 vote): Clear stale terminal velocity after the final waypoint completes.
  • pybullet_fleet/core_simulation.py:1253 — moderate (3 votes): Correct step_end_time for PRE_STEP subscribers.
  • pybullet_fleet/core_simulation.py:1203 — nit (1 vote): Qualify collision freshness for post-step callbacks.
  • pybullet_fleet/core_simulation.py:1240 — moderate (1 vote): Gate post-step callbacks using step_end_time.
  • pybullet_fleet/core_simulation.py:1164 — moderate (1 vote): Rebuild or clear plugin schedules when frequency changes to or from None.
  • pybullet_fleet/core_simulation.py:1525 — moderate (1 vote): Validate collision frequency before mutating configuration.
File Summary
tests/​test_sim_plugin.py Updates plugin frequency-state assertions.
tests/​test_monitor_log_interval.py Pins monitor cadence behavior.
tests/​test_goal_replacement_speed.py Tests speed continuity and braking.
tests/​test_frequency_gate.py Tests scheduling, callbacks, and timing.
tests/​test_core_simulation.py Updates reset scheduling tests.
pybullet_fleet/​sim_plugin.py Replaces plugin accumulators with frequency gates.
pybullet_fleet/​core_simulation.py Integrates gates, callbacks, and timestamps.
pybullet_fleet/​controllers/​batch_omni.py Adds batched speed continuity.
pybullet_fleet/​controller.py Preserves speed for omni trajectories.
pybullet_fleet/​agent.py Avoids moving approach waypoints.
pybullet_fleet/​_tpi.py Adds speed-aware trajectory construction.
pybullet_fleet/​_frequency.py Implements fixed-grid frequency scheduling.
docs/​architecture/​two-phase-step.md Documents phases and timing.
docs/​architecture/​plugins-events.md Documents plugin scheduling.
CHANGELOG.md Records the user-visible changes.

Review effort: Lite

Comment thread pybullet_fleet/core_simulation.py Outdated
PRE_STEP is emitted before the object update, so its subscribers still read
the poses of the step's start; step_end_time returned the end for anything
inside step_once(). A flag set after the update loop now tells the two apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 17, 2026 02:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Two moderate frequency-gate transition issues remain unresolved.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Frequency gate state persists across every-step transitions

pybullet_fleet/​core_simulation.py:1171

When a plugin is changed from a positive frequency to None and then back to the same positive value, this branch leaves the old FrequencyGate attached. The positive-frequency branch only rebuilds when gate.frequency != freq, so it reuses the stale _next_index/_last_time and can invoke on_step immediately with the whole intervening interval instead of starting a fresh schedule. Clear _frequency_gate when entering the every-step branch (or track every frequency transition) before reusing it.

1 resolved since last review

Review effort: Lite

From review on the PR:

- A plugin switched from a frequency to every step and back kept its old
  FrequencyGate and ran at once with the whole gap as dt. Every-step now
  drops the gate, so a later frequency starts a fresh schedule.
- BatchOmniController carried the arrival step's average speed into a goal
  set in the step the agent arrived (e.g. from a post-step callback); the
  per-agent controller zeroes it on arrival. A new path now continues the
  motion only while a translation is under way.
- set_collision_check_frequency() built the gate after storing the value, so
  a negative frequency raised half-way. Non-positive values disable checks,
  as they did before the gate, and nothing is stored until the gate is built.
- Post-step callbacks are scheduled on step_end_time, the time of the poses
  they see, so a 10 Hz one runs when the poses reach 0.1 s, 0.2 s, ... The
  first run of any schedule reports one interval (at least one step) as dt.
- The register_callback docstring says post-step callbacks see the new
  poses' collisions only when the step ran a collision check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 17, 2026 03:11
@yuokamoto

Copy link
Copy Markdown
Owner Author

Addressed the findings from the review summaries in 05dcbc1:

  • Plugin schedule across None (core_simulation.py:1164/1171): every-step now drops the plugin's gate, so a frequency set again later starts a fresh schedule. test_plugin_back_from_every_step_starts_a_fresh_schedule fails without it.
  • Stale terminal velocity in BatchOmniController (batch_omni.py:221): a new path continues the motion only while a translation is under way. A row that has just arrived starts from rest, as the per-agent controller does. test_a_goal_set_on_arrival_starts_from_rest[batch] sets the next goal from a post-step callback in the arrival step, and fails without the fix.
  • Collision frequency validation (core_simulation.py:1525): the gate is built before anything is stored. Non-positive values disable checks, as before the gate. Parametrised test for 0 and -1.
  • Gate post-step callbacks on step_end_time (core_simulation.py:1240): done. A 10 Hz post-step callback runs when the poses reach 0.1 s, 0.2 s, and so on. Phase-1 callbacks keep their start-time schedule. The first run of any schedule reports one interval (at least one step) as dt.
  • Collision freshness for post-step callbacks (nit): the docstring says the active pairs are the new poses' only when the step ran a collision check.
  • PRE_STEP timing in the docs (nit): covered by 182f107 (step_end_time is sim_time in a pre_step subscriber), in the docstring and in two-phase-step.md.

Full suite: 2158 passed, 12 skipped.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Moderate issues remain in frequency-gate initialization and validation, plus restoration of nonzero trajectory speed.

No open issues 🎉

2 previously missed issues

In code that hasn't changed since last review

Medium severity First callback skips an already-due grid point

pybullet_fleet/​_frequency.py:64

The first-call baseline skips an already-due grid point because it always adds one after floor((now + slack) / interval). For example, after restore_completed_step_boundary() at 10.0 s, the first post-step callback sees now == step_end_time == 10.0 but initializes its next index to 11, so it misses the 10 Hz-grid point at 10.0 and does not run until 11.0; the same happens for a callback registered exactly on a due boundary. Initialize to the first index at or just before now (for example, ceil((now - slack) / interval), clamped to 1) so due() can consume the current grid point.

Medium severity Navigation checkpoints lose nonzero initial velocity

pybullet_fleet/​controller.py:487

The new nonzero-v0 trajectory is not preserved by the existing navigation checkpoint path. capture_navigation_state() only copies t0, limits, and origin from capture_tpi_state() and restore_navigation_state() reconstructs the forward TPI with v0=0, so a checkpoint taken after a moving omni goal replacement resumes from rest with a different trajectory (and the profile validator currently rejects any added field). Include the initial speed in the checkpoint schema and restore it so this new behavior survives recording/restore.

Review effort: Lite

From review:

- A schedule's first call took the grid point after the current time, so a
  first call already on a due time skipped it: after a restore at 10.0 s a
  10 Hz post-step callback skipped 10.0 s and first ran at 10.1 s, and a
  10 Hz post-step callback in a fresh run (whose first time is one step in) first ran at 0.2 s. The first
  call now takes the first grid point at or after now, never time zero, and
  runs if it is due.
- Navigation checkpoints rebuilt the forward trajectory with v0 = 0, so a
  checkpoint taken after a goal replaced mid-motion resumed on a different
  trajectory. The forward state records v0 and restores it; the kinematic
  profile's controller state is version 2 and validates the new field, and
  the omni checkpoint proof expects v0 = 0 for its from-rest profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 17, 2026 03:29
@yuokamoto

Copy link
Copy Markdown
Owner Author

Addressed the two items from the latest review summary in 05da9a9:

  • First callback skips an already-due grid point (_frequency.py:64): the first call now takes the first grid point at or after now, never time zero, and runs if it is due. A 10 Hz post-step callback runs from the first step (pose time 0.1 s), and a schedule whose first call lands on a due time after a restore runs there. Phase-1 callbacks still first run one interval into a run. New tests test_a_first_call_on_a_due_time_runs_at_once and test_a_post_step_schedule_at_the_step_rate_runs_from_the_first_step both fail with the old baseline.
  • Navigation checkpoints lose nonzero initial velocity (controller.py:487): the forward state records v0 and restore_navigation_state rebuilds the trajectory with it. The kinematic profile's controller state is now version 2 and validates the field (finite, non-negative). The omni checkpoint proof expects v0 == 0 for its from-rest profile. test_a_checkpoint_keeps_the_initial_speed captures right after a goal is replaced at full speed and compares the restored trajectory; it fails when restoring with v0 = 0.

Full suite: 2161 passed, 12 skipped.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Resolve the active-rotation goal replacement behavior and recompute monitor scheduling after final GUI/headless mode is established.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Recompute monitor cadence after final GUI mode is set

pybullet_fleet/​core_simulation.py:466

This gate is configured before setup_pybullet() runs, but that method can force self._params.gui from True to False when RECORD is set. With monitor_update_interval=None, the gate therefore remains in the GUI/every-step mode even though the simulation is now headless, regressing the documented 1 Hz headless DataMonitor cadence. Recompute this gate after the final GUI mode is established (or otherwise derive the mode at setup time).

Review effort: Lite

The DataMonitor's default refresh rate depends on the GUI (every step with
one, once a second without), and its schedule was built in __init__ before
setup_pybullet(), which turns the GUI off when RECORD is set. A recording run
then refreshed the monitor every step. setup_pybullet() and
initialize_simulation() now rebuild the schedule from the final mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yuokamoto

Copy link
Copy Markdown
Owner Author

Addressed "Recompute monitor cadence after final GUI mode is set" (core_simulation.py:466) in 6c23733. setup_pybullet(), where RECORD turns the GUI off, and initialize_simulation() both rebuild the DataMonitor schedule from the final GUI mode. test_record_forced_headless_keeps_the_headless_monitor_rate requests a GUI with RECORD set and expects the headless 1 Hz refresh. It fails without the rebuild. Full suite: 2162 passed.

The summary also mentions an "active-rotation goal replacement behavior" but lists no finding for it. For reference: a goal replaced during a final in-place rotation starts the translation from rest, because the velocity during rotation is zero and only the component toward the new goal is carried. DifferentialController always rotates first and keeps starting from rest.

Copilot AI lite review requested due to automatic review settings October 17, 2026 03:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Three unresolved moderate findings must be addressed before approval.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Stale batch velocity suppresses waypoint at path completion

pybullet_fleet/​agent.py:1393

This predicate is not reliable for batch agents: BatchKinematicController._apply_phase1() reports the previous step's average velocity, and a completed batch path changes _phase/is_moving to idle only after writing the final pose, so _current_velocity can still be nonzero immediately after arrival. Calling set_path() at that boundary therefore suppresses the approach waypoint even though the agent is no longer moving. Base this decision on the navigation state (self.is_moving) or clear the batch velocity when the path completes.

Review effort: Lite

@yuokamoto

Copy link
Copy Markdown
Owner Author

Addressed "Stale batch velocity suppresses waypoint at path completion" (agent.py:1393) in 9763736. Agent.set_path now decides on the navigation state (is_moving), not the reported velocity. test_a_path_set_on_arrival_keeps_its_approach_waypoint (per-agent and batch) sets the next path from a post-step callback in the arrival step and expects the approach waypoint. The batch case fails with the velocity check. Full suite: 2164 passed.

Agent.set_path skipped the approach waypoint when the reported velocity was
non-zero. A batch controller reports the arrival step's average speed until
its next flush, so a path set in the step the agent arrived lost its approach
waypoint although the agent had stopped. The decision now reads is_moving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 17, 2026 04:04

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved moderate findings affect trajectory timing, batch velocity continuity, input validation, and checkpoint compatibility.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Use instantaneous endpoint velocity for batch replacements

pybullet_fleet/​controllers/​batch_omni.py:227

agent._current_velocity is not the same quantity here as in OmniController: BatchKinematicController._apply_phase1() derives it from the previous step's position delta divided by dt (an average velocity), whereas the per-agent controller stores the TPI's instantaneous velocity at the endpoint. If a goal is replaced during acceleration or braking, this v0 is therefore too low and the batch trajectory diverges from the per-agent trajectory (and does not actually continue the current speed). Derive the row's instantaneous profile speed from the batch trajectory, or retain that endpoint speed separately, before projecting it onto the new direction.`

Review effort: Lite

BatchOmniController took a replaced goal's initial speed from the agent's
reported velocity, which the batch base derives from the last step's
displacement: an average, low while accelerating and high while braking. The
per-agent controller keeps the trajectory's instantaneous speed, so the two
diverged when a goal was replaced mid-ramp. The row's own trapezoid now gives
the speed at the time it was last evaluated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Resolve the pose timestamp contract mismatch and allow negative signed velocities in checkpoint validation.

2 open issues

Review effort: Lite

Comment thread pybullet_fleet/replay/kinematic_profile.py Outdated
- Agent.path_rejection() returns a PathRejection(reason, temporary).
  Temporary is a refusal that lasts only while the agent is moving (no
  trajectory from its current velocity); a fixed-base robot and an empty path
  are permanent. MoveAction, PatrolController, RandomWalkController and the
  behaviour tree's navigation adapter retry only a temporary refusal, through
  _replan.refused_for_now(); a permanent one behaves as before (MoveAction
  completes, the adapter reports started). FleetCommandDispatcher reports the
  reason string.
- The kinematic profile's checkpoint validation accepted only v0 >= 0; v0 is
  signed now (a reversal records a negative one), so it checks it is finite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 17, 2026 10:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Unresolved timing consistency and invalid-frequency handling issues remain.

No open issues 🎉

1 previously missed issue

In code that hasn't changed since last review

Medium severity Preserve zero frequency validation for callbacks

pybullet_fleet/​core_simulation.py:1242

frequency or None silently turns an explicit frequency=0 into an every-step callback, even though FrequencyGate rejects non-positive frequencies and the documented opt-in for every-step execution is None (the same argument is passed directly for plugins). This makes invalid callback configuration behave differently from the shared gate and can hide a bad rate; pass frequency through unchanged so zero is rejected consistently, or explicitly document zero as an alias and handle it in the gate.

2 resolved since last review

Review effort: Lite

two-point-interpolation 1.3.2 solves for the peak velocity directly, so a
body starting away from its target always gets a trajectory that ends there
(or a ValueError). try_build_tpi() no longer checks the end state and rebuilds
with the larger root; that copy of the solver is removed. With 1.3.1 installed
the reversal tests fail again, which is why the floor moves to 1.3.2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 17, 2026 16:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved trajectory validation and timing/atomicity issues remain.

1 open issue

Review effort: Lite

Comment thread pybullet_fleet/_tpi.py
@yuokamoto
yuokamoto requested a lite review from Copilot October 17, 2026 17:17

@yuokamoto yuokamoto left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #76(レビュー対象: 22f2d19)を確認し、修正が必要な点を2件確認しました。

  1. [P2] step_end_timeが軌道上の姿勢時刻より1ステップ先になります
    core_simulation.py:1271–1273

    単体・バッチの位置制御は軌道をsim_timeで評価していますが、新APIはその姿勢の時刻としてsim_time + timestepを返します。刻み0.05秒、加速度4 m/s²で開始すると、0.20秒相当の位置x=0.08に0.25秒の時刻が付きました。このAPIで記録・配信すると姿勢と時刻がずれます。軌道の評価時刻とAPIの時刻を統一する必要があります。

  2. [P2] PRE_STEPでのゴール変更時、バッチ制御が未来の速度で拒否判定します
    batch_omni.py:208–214

    PRE_STEPでは姿勢は前回の評価結果ですが、_row_velocity()は更新済みのsim_timeで速度を計算します。刻み0.05秒、加減速度4 m/s²で、auto_approach=Falseで10 m先へ移動を開始し、5ステップ後のPRE_STEPで現在位置の0.1 m先へ変更すると、単体制御は受理、バッチ制御は拒否しました。実際のプロファイル速度0.8 m/sでは停止距離0.08 mですが、バッチは次の時刻の1.0 m/sを使い、停止距離を0.125 mと判断します。最後に姿勢を評価した時刻の速度を引き継ぐ必要があります。

検証: PRが要求するtwo-point-interpolation==1.3.2を一時ディレクトリに導入し、以下を実行して289件通過しました。上記2件は別途追加スクリプトで再現しました。make verifyは未実行です。

pytest tests/test_goal_replacement_speed.py tests/test_frequency_gate.py tests/test_fleet_api.py tests/test_behavior_tree.py tests/test_sim_plugin.py tests/test_core_simulation.py tests/test_monitor_log_interval.py -q --override-ini addopts=''

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved timing, synchronization, API validation, and fleet acknowledgement issues remain.

2 open issues

Review effort: Lite

Comment on lines +1271 to +1273
if self._in_step or self._in_post_step:
return self.sim_time + self._params.timestep if self._poses_advanced else self.sim_time
return self._elapsed_sim_time

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5bb3e59, the first option: the per-agent and batched omni/differential controllers now evaluate their trajectories at step_end_time and start a trajectory there (pose_time() in controller.py), and the core raises the pose-time flag before the object update, so a pose and its stamp agree in every phase. tests/test_pose_time.py checks that every pose stamped with step_end_time is the trajectory at that time, per-agent and batch; evaluating at sim_time again fails 8 of its tests.

…ped with

From review: position controllers evaluated their trajectories at sim_time,
the step's start, while step_end_time stamped the resulting pose with the
step's end -- from rest at 4 m/s^2 with 0.05 s steps, the pose x = 0.08 (the
trajectory at 0.20 s) was stamped 0.25 s. And a batch row judged a goal set in
a pre_step subscriber on the speed at the advanced sim_time, the speed of the
step about to run, so per-agent accepted a goal the batched controller refused.

There is now one time for both. The core raises its pose-time flag before the
object update instead of after it, so step_end_time is the end of the step
during the update too; the per-agent and batched omni and differential
controllers evaluate their trajectories at it and start a trajectory at it
(pose_time() in controller.py, falling back to sim_time for a stand-in core).
Kinematic joints already evaluated at the end of the step. A goal set in a
pre_step subscriber now moves in that same step, a motion completes one step
sooner, and a replaced goal is judged on the speed of the pose in effect,
whichever phase sets it.

Follow-on changes:
- The omni checkpoint proof checks the checkpoint pose against the trajectory
  at k*dt (the checkpoint's elapsed time) and ends at 1.3 s, the step that
  now arrives; its evidence table and guide are updated. The manipulation
  scenario's checkpoints move one step earlier with the arrival.
- The per-agent/batch equivalence tests compared different paths: the
  reference agent got a zero-length approach waypoint from Agent.set_path that
  bc.set_path() does not add, and the old one-step lag on the batch side hid
  it. They now set the reference path with auto_approach=False.
- The batch buffered-write tests read the PyBullet pose by object_id instead
  of body_id (a different body when a floor plane exists); they only passed
  because nothing moved in the first step. They now read body_id and check the
  row moved.

tests/test_pose_time.py: every pose stamped with step_end_time is the
trajectory at that time (per-agent and batch, in post-step callbacks and
outside a step; includes the reported 0.08 m at 0.20 s), and a goal replaced
from each phase -- outside, pre_step, Phase-1 callback, post_step event,
post-step callback -- is accepted or refused on the speed at step_end_time
alike by both controllers, plus the reported pre_step case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 17, 2026 17:32
@yuokamoto

Copy link
Copy Markdown
Owner Author

レビューありがとうございます。2件とも 5bb3e59 で対応しました。

1. step_end_time と軌道上の姿勢時刻のずれ

軌道を評価する時刻と、姿勢に付く時刻を step_end_time にそろえました。案は2つありました。

  • (a) 軌道をステップの終わり(sim_time + timestep)で評価する
  • (b) 評価した時刻(ステップの始まり)を姿勢の時刻として返す

(a) を選びました。理由は次のとおりです。

  • ステップが終わったとき、世界の時刻は t + dt です。経過時間・/clock・完了ステップの時刻と一致するのは (a) だけです。
  • kinematic joint はすでにステップの終わりで評価していました(step_start, now = clock, clock + dt)。前後移動と回転だけが1ステップ遅れていました。

実装は次のとおりです。

  • core は、姿勢時刻のフラグをオブジェクト更新の後ではなく前に立てます。これで更新中も step_end_time がステップの終わりを返します。
  • 単体・バッチの omni / differential コントローラは、軌道の開始時刻 t0 と評価時刻の両方に step_end_time を使います(controller.py の pose_time())。

ご指摘の例(0.05 秒刻み、4 m/s² で静止から)では、x = 0.08 に 0.20 秒が付きます。

挙動の変化は次の3点です。CHANGELOG と docs/architecture/two-phase-step.md に書きました。

  • pre_step で設定したゴールは、そのステップから動きます。
  • 移動は1ステップ早く完了します。
  • 置き換えたゴールは、そのとき有効な姿勢の速度で判定します。

2. PRE_STEP でのバッチの拒否判定

1 で一緒に直りました。バッチの判定と速度の引き継ぎも step_end_time を使います。pre_step では、これは直前のステップの終わり、つまり今の姿勢の時刻です。

ご指摘の例は、新しい時刻のもとでは次のようになります。

  • 5ステップ後の姿勢は 0.25 秒、速度は 1.0 m/s で、停止距離は 0.125 m です。
  • 0.1 m 先のゴールは、単体・バッチとも拒否します。
  • 0.13 m 先のゴールは、両方とも受理します。

フェーズを一般化したテストも入れました。ステップの外、pre_step、Phase 1 の callback、post_step イベント、post-step callback のどこでゴールを置き換えても、step_end_time の速度による判定が単体とバッチで一致します。

テスト

tests/test_pose_time.py に 14 件を追加しました。修正を戻すと失敗することも確認しました。

  • 評価時刻を sim_time に戻すと 8 件失敗します。
  • フラグを更新の後に戻すと 8 件失敗します。
  • バッチの判定だけ次ステップの速度にすると、pre_step のバッチ 2 件が失敗します。

全体では 2200 passed です。pre-commit と Sphinx(-W)も通っています。

既存テストの変更

時刻の定義が変わったので、既存のテストも直しました。

  • omni checkpoint proof:姿勢を、チェックポイントの経過時間での軌道と比べるようにしました。終了は到着ステップの 1.3 秒です。evidence も更新しました。
  • manipulation scenario:チェックポイントが1ステップ早くなりました。
  • 単体/バッチの一致テスト:もともと別の経路を比べていました。参照側だけが Agent.set_path() の長さ 0 の approach waypoint を持っていて、以前はバッチ側の1ステップ遅れでそれが隠れていました。今は同じ経路を比べます。
  • バッチの buffered write テスト:PyBullet の姿勢を object_id で読んでいました(床があると別の body です)。1ステップ目で何も動かなかったので通っていただけでした。body_id で読むように直しました。

未対応の点

Agent.set_path() が自動で入れる長さ 0 の approach waypoint は、omni では1ステップ余計にかかります。以前からの挙動で、今回は変えていません。

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The monitor’s default frequency must use the effective GUI setting when enable_monitor_gui is enabled with headless simulation.

1 open issue
1 resolved since last review

Review effort: Lite

From review: the per-step default exists to keep the DataMonitor window
live, but it followed `gui` (the PyBullet viewer). A headless run that opens
the monitor window (enable_monitor_gui=True) refreshed it once a second, and a
viewer run that keeps it closed refreshed every step. The default now follows
the window's effective setting -- enable_monitor_gui, or gui when that is
None, the rule the DataMonitor itself uses. Docstring, configuration
reference and changelog updated; the new test fails with the old rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 17, 2026 17:45
@yuokamoto

Copy link
Copy Markdown
Owner Author

Copilot's review of 5bb3e59 lists one open point without an inline thread: the monitor's default refresh should follow the effective monitor window when enable_monitor_gui is on in a headless run. Done in ebb7ac8: the default (monitor_update_interval=None) is every step while the monitor window is open (enable_monitor_gui, or gui when that is None) and once a second otherwise. test_the_default_refresh_follows_the_monitor_window fails with the old gui-based rule. Full suite: 2201 passed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Monitor timing, fleet acknowledgement consistency, and related checkpoint documentation still require correction.

1 open issue
1 previously missed issue

In code that hasn't changed since last review

Medium severity Reconcile goal setter failure before emitting command acknowledgment

pybullet_fleet/​fleet_api.py:141

_ack() emits FLEET_COMMAND_EVENT before these calls, so an event handler (or a concurrent command) can change the agent after the preflight check and make set_goal_pose() return False. That return value is ignored, leaving the already-emitted ack and recorded effective input claiming the robot was accepted even though the goal was refused. Apply the check and mutation under the same simulation-thread/command boundary, or reconcile the setter result before constructing the ack/event.

Review effort: Lite

From review: navigate() emits the command event, then applies the goals, and
ignored set_goal_pose()'s result. A handler of that event can change a robot
so that it then refuses its goal, and the returned ack and the recorded
effective input still claimed it was accepted. The event is still emitted
before mutation (an existing contract); a goal refused after it is moved to the
ack's rejected map with its reason, left out of the recorded input, and logged
as a warning since the event already announced it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings October 18, 2026 01:15
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yuokamoto

Copy link
Copy Markdown
Owner Author

Copilot's review of ebb7ac8 lists a previously missed point in fleet_api.py: navigate() emits the command event, then applies the goals and ignored set_goal_pose()'s result, so a goal refused after the event (a handler of it changed the robot) was still acknowledged and recorded as accepted. Fixed in 800e7c6. The event is still emitted before mutation, which is an existing contract (test_dispatcher_navigate_accepts_many_and_emits_before_mutation). A goal refused after it moves to the ack's rejected map with its reason, is left out of the recorded effective input, and is logged as a warning since the event already announced it. test_a_goal_refused_after_the_command_event_is_not_acknowledged covers it and fails without the fix. Full suite: 2202 passed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Three moderate findings remain involving zero-frequency validation, headless monitor cadence, and lock-safe batch preflight.

1 open issue

Review effort: Lite

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants