Hook system for simulation callbacks#158
Conversation
9d45af3 to
5959cf7
Compare
92f9cfe to
a3e555d
Compare
samuelgundry
left a comment
There was a problem hiding this comment.
This is such a good cleanup. As discussed offline, I strongly prefer a hook system akin to game engines -- it's a proven design pattern. I haven't approved as I think we want to use tensor/batch env_ids now in the hook system. And a future-looking suggestion (consideration, really) to further unifiy sim backends vs. components/systems.
9028a60 to
b6e1a60
Compare
…k registry
Add a lifecycle hook registry (simulator.hooks): a small ordered registry of callbacks keyed
by engine-agnostic Phase points — FRAME_BEGIN / PRE_STEP / POST_STEP / FRAME_END bracket the
outer tick and each physics substep, plus EPISODE_START / EPISODE_END / CLOSE. BaseSimulator
builds the registry in __init__ and the step loop emits phases; the video recorder, virtual
gantry, and simulator bridge register their step-loop side effects as hooks instead of being
hard-wired into the loop. Callbacks can sub-sample a phase via an int decimation or a
frequency string ("30Hz") resolved against the phase base rate. Backends (mujoco / isaacsim /
isaacgym) and train_agent are wired to the new emission points.
2457087 to
1c4d0c5
Compare
| """ | ||
| hooks.add(Phase.EPISODE_START, self.on_episode_start, name="video.on_episode_start") | ||
| hooks.add(Phase.EPISODE_END, self.on_episode_end, name="video.on_episode_end") | ||
| hooks.add(Phase.FRAME_END, self.capture_frame, name="video.capture_frame") |
There was a problem hiding this comment.
Bug? capture_frame expects env_id but FRAME_END does not pass?
How can we catch these static/typing errors without eyeballs or runtime?
| self.simulator.hooks.emit(Phase.POST_STEP) | ||
|
|
||
| if step_count % control_decimation == 0: | ||
| self.simulator.hooks.emit(Phase.FRAME_END) |
There was a problem hiding this comment.
I think very slight behavior change means VideoRecorder will now capture at step_count=0 (before step_count is incremented) whereas before it captured only after incrementing step_count (modulo decimation)? See lines 198 removed in video_recorder.py. So for control_decimation=4, FRAME_END now also fires on on step 0 vs. before only on step 4, 8, ... Not sure if this is intentionally?
Summary
Replaces the hard-coded step-loop side effects scattered across the simulator backends with a single lifecycle hook registry. Instead of each backend hand-calling
virtual_gantry.step(),_step_bridge(), andcapture_video_frame()inside itssimulate_at_each_physics_step, and overridingon_episode_start/end, the runtime participants now register callbacks against typedPhaseevents thatBaseTask(and therun_simDirectSimulationloop) emit.hooks.py— newHookRegistry+Phaseenum. Seven lifecycle phases:FRAME_BEGIN/FRAME_ENDbracket the outer control-rate tick,PRE_STEP/POST_STEPbracket each physics substep, plusEPISODE_START,EPISODE_END,CLOSE. Deterministic registration-order dispatch, per-phase payload-arity validation,enable/disable/removehandles, mutation-during-emit guards, and a reverse-orderCLOSEthat runs every hook exactly once and aggregates failures into oneHookCloseError. Covered bytests/test_hooks.py(no_sim).every.hooks.add(phase, fn, every=4)runs a callback once per N emissions of its phase. On periodic phases (FRAME_*,*_STEP),everyalso accepts a frequency string ("30Hz",">30Hz","<30Hz") resolved against the phase's base tick rate, whichBaseSimulator._hook_base_rates()supplies from the sim config:fpsfor the substep phases,fps / control_decimation_stepsfor the frame phases.HookHandle.set_every()retunes a live hook (counter resets). Event phases (episode) accept int decimation only;CLOSErejects any decimation.SimulatorBridge,VirtualGantry, and the video recorder each gain aregister_hooks()that wires their existing methods to phases. The backends callparticipant.register_hooks(self.hooks)at setup; their step methods lose the inline calls.BaseSimulator.close()(idempotent) emitsPhase.CLOSE;train_agent.pycloses the env in afinally, andDirectSimulation.cleanup()routes throughenv.close()/simulator.close(). AddsSimulatorBridge.close()(closes the clock-publisher socket) — it did not previously exist, and registering it forCLOSEis what surfaced the bug below.Behavior-preserving for every shipped run: the same callbacks fire at the same rate, in the same order.
Behavior changes by user impact
Newly present (opt-in / internal; no shipped run affected)
Phase.CLOSEteardown path.BaseSimulator.close()now emitsCLOSE, runningbridge.close(clock socket) andvideo.cleanupin reverse registration order, idempotently. Previously teardown was ad-hoc:DirectSimulation.cleanuponly calledvideo_recorder.cleanup(), and the bridge's clock socket was never explicitly closed. Bothtrain_agent.py(newfinally) andDirectSimulationnow go throughclose().HookRegistrypublic surface onBaseSimulator.hooks. New extension point — downstream code can register per-phase callbacks (with an optional cadence) without subclassing a backend or overridingon_episode_*.run_simloop emits the same phases as training.DirectSimulation.run()emitsPRE_STEP/POST_STEPevery physics step andFRAME_BEGIN/FRAME_ENDeverycontrol_decimation_steps, so hooks behave identically underrun_simandBaseTask.Removed (internal API surface)
BaseSimulator.on_episode_start/on_episode_end/capture_video_frame/_step_bridgedeleted. These were the override seams for episode/video/bridge behavior. Callers now emit phases:base_task.pyemitsEPISODE_START/EPISODE_END(replacing thehasattr(simulator, "on_episode_*")guards) and the frame/step phases;sim_utils.pyemitsEPISODE_STARTat init. Any out-of-tree subclass that overrodeon_episode_start/endto hook episode boundaries must migrate tosimulator.hooks.add(Phase.EPISODE_START, ...). All in-tree callers migrated.capture_frameno longer tracks_frame_counter % control_decimation; it registers onFRAME_END, which already fires once per frame at control rate.Result-preserving (verified)
bridge.stepandvirtual_gantry.stepregister onPRE_STEP(fires every physics substep, beforesimulate_at_each_physics_step, as before — SDK torques and gantry forces must land before the substep integrates them). Registration order — gantry before bridge (gantry registered ahead of_init_bridgein every backend) — matches the old inline call order.video.capture_frameregisters onFRAME_END: once per frame, at control frequency — the same rate the old per-substep counter decimated to. The capture point moves from "after the Nth physics substep" to "after_refresh_sim_tensorsat frame end".set_position_to_robot(nowVirtualGantry.on_episode_start,env_id == 0guarded) registers before the video recorder'son_episode_start, matching the oldBaseSimulator.on_episode_startbody.Migration guide (out-of-tree only)
Episode / video / bridge hooks — register instead of override
simulator.capture_video_frame()/_step_bridge()are gone — the video recorder and bridge register themselves; there is nothing to call. Payload arity is validated at emit:EPISODE_START/EPISODE_ENDpassenv_id; all other phases pass no args.