Skip to content

robot_simulation: let a control program be written in Python - #947

Draft
Akshay-2007-1 wants to merge 12 commits into
masterfrom
robot-simulation-python-control-program
Draft

robot_simulation: let a control program be written in Python#947
Akshay-2007-1 wants to merge 12 commits into
masterfrom
robot-simulation-python-control-program

Conversation

@Akshay-2007-1

Copy link
Copy Markdown
Contributor

What

Adds createPythonCSE(code), a Python-flavoured sibling of the existing Source-only createCSE(...): the string passed to it is interpreted by an embedded py-slang CSE machine, stepped in lockstep with the physics tick exactly like the existing js-slang-driven path.

Why

First step toward Python EV3 support in the simulator. robot_simulation's cooperative scheduling (ev3_pause() etc.) relies on stepping an interpreter N times per physics tick, which it already does for Source via js-slang's exposed Control/Stash/generateCSEMachineStateStream. This does the same for Python via source-academy/py-slang#457.

Scope - intentionally NOT a Conductor migration

robot_simulation still imports js-slang/context and loads exactly as before. The setup program (init_simulation, createWorld, ...) stays Source; only the robot's control program can now be Python. Making the whole module - setup included - Python-only requires migrating to the BaseModulePlugin/attachModuleMethod pattern (as csg/rune/curve/plotly already did) - separate, larger follow-up work, tracked next.

What's in here

  • pythonRuntime.ts (new) - builds a py-slang Context seeded with SICPy builtins + the ev3_* robot API (1:1 wrapping of ev3_functions.ts), output routed to the Robot Console
  • evaluate.ts - runPythonECEvaluator, async-generator mirror of the existing runECEvaluator
  • Program.ts - language/pyContext constructor params; an async pump drives the Python generator once per tick
  • helper_functions.ts - createPythonCSE export, plus unwrapCallbackResult(): a defensive fix for an unrelated pre-existing js-slang bug (closureToJS leaves a tail-call wrapper on native-callback return values) that broke init_simulation for every program, Python or Source, before this change. Worth reporting upstream to js-slang separately.
  • buildtools: acorn ecmaVersion 6 -> 2020 (py-slang's BigInt literals otherwise crash the post-esbuild AST pass)
  • Program.python.test.ts (new) - real, non-mocked test proving multi-tick Python stepping mutates real py-slang state

Testing

123/123 tests pass (existing js-slang-path tests unchanged). Manually confirmed end-to-end in a local frontend + language-directory + locally-built stack: a Python control program (print + two ev3_runToRelativePosition calls) rendered the 3D scene and visibly drove the robot forward, with print() reaching the Robot Console.

Depends on

source-academy/py-slang#457 (draft, unpublished). package.json currently points @sourceacademy/py-slang at a portal: path local to this machine as a result - will need to become a real registry reference once that PR publishes.

Status

Draft - checkpoint of local proof-of-concept work, not requesting review yet.

Akshay-2007-1 and others added 12 commits September 3, 2026 22:08
Adds createPythonCSE(code), a Python-flavoured sibling of the
existing Source-only createCSE(...): the string passed to it is
interpreted by an embedded py-slang CSE machine (Control/Stash/
generateCSEMachineStateStream, imported as a plain library dependency
- see companion PR on source-academy/py-slang), stepped in lockstep
with the physics tick exactly like the existing js-slang-driven path.

- pythonRuntime.ts (new): builds a py-slang Context seeded with SICPy
  builtins plus the ev3_* robot API (1:1 wrapping of ev3_functions.ts,
  so a Python program and a Source one drive the simulation
  identically), output routed to the Robot Console.
- evaluate.ts: runPythonECEvaluator, the async-generator mirror of the
  existing runECEvaluator.
- Program.ts: language/pyContext constructor params; an async "pump"
  drives the Python generator once per tick, since fixedUpdate is
  called synchronously but py-slang's stepper is async.
- helper_functions.ts: createPythonCSE export, plus
  unwrapCallbackResult() - a defensive fix for an unrelated
  pre-existing js-slang bug (closureToJS leaves a tail-call wrapper
  {isTail, value} on values returned from native callbacks) that
  broke robot_simulation's init_simulation for every program, Python
  or Source, before any of this change. Worth reporting upstream to
  js-slang separately.
- lib/buildtools/.../commons.ts: acorn ecmaVersion 6 -> 2020, needed
  because py-slang's BigInt literals otherwise crash buildtools' post-
  esbuild AST pass (py-slang isn't in the external:['js-slang*']
  esbuild list, so it gets fully inlined).
- Program.python.test.ts (new): real (non-mocked) test proving
  multi-tick Python stepping mutates real py-slang state correctly.

Scope note: this is intentionally NOT a Conductor migration.
robot_simulation still imports js-slang/context and loads exactly as
before; the *setup* program (init_simulation, createWorld, ...) stays
Source. Only the robot's *control* program can now be Python. Making
the whole module - setup included - Python-only requires migrating
robot_simulation to the BaseModulePlugin/attachModuleMethod pattern
(as csg/rune/curve/plotly already did), which is separate, larger,
follow-up work.

Local proof: 123/123 tests pass, including the new Python-path tests
alongside the unchanged existing js-slang-path tests. Manually
confirmed end-to-end in a local frontend + language-directory +
locally-built modules/py-slang stack: a Python control program
(print + two ev3_runToRelativePosition calls) rendered the 3D scene
and visibly drove the robot forward, with print() output reaching the
Robot Console.

Depends on source-academy/py-slang#457 (draft, not yet published) -
robot_simulation/package.json currently points @sourceacademy/py-slang
at a portal: path local to this machine as a result; that'll need to
become a real registry reference once py-slang#457 publishes.

Draft: checkpoint of local proof-of-concept work. Not requesting
review yet.
Replaces the js-slang/context workaround (Step 1) with the same
BaseModulePlugin/attachModuleMethod pattern csg/rune/curve use, so the
whole program - setup and control program alike - can be written in
any Conductor language, not just Source-for-setup.

Architecture: the module runs inside Conductor's runner Worker, which
has no DOM/WebGL, so it can no longer own THREE.WebGLRenderer/
OrbitControls/the canvas the way it used to. Split by concern
(pix_n_flix's precedent, not a "who renders" split): the module keeps
physics (rapier3d-compat, worker-safe) and the robot control program's
CSE stepping, and streams entity transforms to a new RobotSimulation
tab plugin once per physics tick over a dedicated state channel
(entity descriptors once per entity, then transferable Float32Array
snapshots every tick) plus an RPC control channel for console/state/
sensor pushes - see protocol.ts and SceneRegistry's doc comment.

Renderer.ts is gone from the bundle entirely; the tab now owns the
live 3D view, built from engine helpers (getCamera/loadGLTF/
MeshFactory) that were already DOM-safe. ColorSensor's sensing moved
from a GPU render-and-readback (no WebGL in a Worker) to a physics
raycast against a small collider-color registry - documented as a
narrowing versus the original (a colored `create_paper` overlay, which
has no collider, is now invisible to the sensor).

createCSE (a Source-flavoured control program) is deliberately not
wired up: buildtools' `external: ['js-slang*']` esbuild rule means any
js-slang import anywhere in a bundle - even unreachable - compiles to
a top-level `require('js-slang/...')` with nothing to resolve it
inside a Worker. All remaining js-slang-backed code (ProgramError,
GeneralRuntimeError et al.) is replaced with conductor/common's
evaluator error types or plain Error, and js-slang is dropped from
package.json. createPythonCSE is unaffected - py-slang is bundled
normally, same as before.

Also bumps the @sourceacademy/conductor catalog pin to ^0.8.3 to match
what the portal-linked local py-slang checkout requires (was ^0.8.2),
fixing a duplicate-package TS error from two physical copies of
conductor's types; and adds ProgramError to the throw-runtime-error
lint rule's ignoredNames, since it never crosses the evaluator
boundary and no longer extends js-slang's RuntimeSourceError.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dji2eG7jb4tww8LowuSs7n
World.setState() dispatched worldStateChange before assigning this.state,
so every listener (including the tab's $worldStateChanged RPC) always
observed the previous state - the tab's "World: ..." readout got stuck on
"ready" forever, even once the world was actually "running". Assign the
new state before dispatching the event.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dji2eG7jb4tww8LowuSs7n
…nd a saved view

The default camera sat 2.8m away from an EV3 that's only ~0.25m across,
rendering the robot as a barely-visible speck. Default to a close-in,
mostly-overhead ("bird's eye") framing instead - ~0.33m out, elevated to
a steep angle - so the robot fills a comfortable fraction of the frame
by default while still reading as a 3D object rather than a flat
silhouette.

Also, in the RobotSimulation tab:
- "F to focus" (Unity/Blender-style): press F while the canvas has focus
  to recenter OrbitControls on the EV3's current (live) position and
  reframe based on its actual bounding box, preserving whatever orbit
  angle the viewer had set up. Scoped to the canvas only (not the whole
  page) so it doesn't steal "f" keystrokes from the code editor.
- The viewer's camera position/orbit target now survives a reload or a
  re-run of the program (persisted to localStorage, keyed per-browser)
  instead of resetting to the default every time.
- OrbitControls damping/zoom-speed tuned, and a short on-canvas hint
  line ("drag to orbit, scroll to zoom... press F to focus").

Verified with a headless Playwright run against a local rebuild: default
framing screenshot, before/after F-focus after orbiting away, and a
camera-position screenshot surviving a program re-run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dji2eG7jb4tww8LowuSs7n
Investigated whether the full Joel-faithful "instructor prepend / student
control-code" split can be reached at the module level under Conductor.
It can't yet: frontend's evalEditorSaga Conductor branch concatenates
`${prepend}\n${studentCode}` into one plain string before it ever reaches
an evaluator, and py-slang's PyCseEvaluator.evaluateChunk(chunk: string)
only ever receives that single opaque string - no delimiter, chunk
boundary, or line-count is forwarded into the evaluator or exposed to a
running module (the frontend's own preludeLineOffset is saga-local
bookkeeping for error line numbers, never sent over the wire). Reaching
the real split needs new plumbing above this module (e.g. evaluateChunk
taking structured {prepend, studentCode}) - out of scope here.

As the documented middle ground, add init_default_simulation(control_code):
one call that builds default physics/world/floor/ev3 and wires up a Python
control program, collapsing the createPhysics/createWorld/createFloor/
createEv3/createPythonCSE/addControllerToWorld/saveToContext/init_simulation
boilerplate a student previously had to write by hand. init_simulation
still exists unchanged for anyone who needs a customised World.
init_default_simulation() now only builds the default scene (no more
control_code string arg). Two new pieces support the split:

- add_wall/add_paper: friendly wrappers so a student can customise the
  live scene from the main pane without touching physics/world handles.
- run_robot_code(code): pass this to repl's set_evaluator so the robot's
  own code runs from a separate, rerunnable REPL tab instead of a string
  baked into setup. Reuses one py-slang Context across runs (REPL-style
  variable persistence) and stops the previous run's Program before
  starting a new one so they don't stomp the shared context mid-flight.

World gains addLiveController for adding a controller to a world that's
already running (addController's start() hookup only fires on the
worldStart event, which already happened by the time a student calls
add_wall/run_robot_code).
…camera

- run_robot_code (robot_simulation) now asks its own tab to come to the
  front once a run is successfully handed to the physics loop, so driving
  the robot from the Repl tab brings the 3D view into focus automatically.
- set_evaluator (repl) now explicitly asks the Repl tab to come to the
  front on success, so finishing setup in the main pane lands the student
  on the Repl tab next instead of leaving them on the editor.
  Both are no-ops on failure (thrown before reaching the focus call), so
  an error stays wherever it's already shown rather than yanking focus.
- RobotSimulation tab: the camera defaults to the same framing "F to
  focus" produces (centered/scaled to the EV3's live bounding box) the
  first time the EV3 actually appears, instead of leaving new viewers
  staring at whatever getCamera()'s hardcoded default happens to show.
  Skipped once the viewer has a deliberate view (a saved one, or a
  manual F-focus/drag already this session).
set_evaluator's 'focus' send raced loadTab()'s async, cross-thread tab
bootstrap - sent before the Repl tab had subscribed, it was silently
dropped. Now deferred to the tab's own 'request' handshake (sent right
after it subscribes) when the tab hasn't connected yet, so the message
can no longer go out before anyone's listening.

Also fixes the RobotSimulation tab's default-camera-focus flag being
sticky for the tab's whole lifetime instead of per Run: re-running the
program left the camera wherever a previous run's auto-focus (or the
viewer's own drag) had settled, even though the new run's World/EV3 may
not be there any more. Reset on every fresh World ($worldStateChanged
'loading'), re-checking localStorage instead of unconditionally clearing
so a saved view still survives a re-run as before.
The frontend only ever shows one side-content tab at a time, and won't
switch tabs away from wherever a student navigated (SideContentManager's
"don't yank the student's focus" guard) - so a separate repl tab and the
RobotSimulation tab can never actually sit on screen together, no matter
what a module does. Rather than fight that guard, this puts both halves
in one tab instead: a small Ace-based editor + Run button now lives right
next to the 3D canvas in RobotSimulation's own tab body, wired to a new
tab -> module RPC ($runReplCode) that has the exact same effect as
run_robot_code/the repl module's Run button.

- protocol.ts: RobotSimulationModuleRpc, the tab -> module half of the
  existing makeRpc pairing (previously module -> tab only).
- index.ts: run_robot_code and the new $runReplCode handler now share one
  __runReplCode implementation; run_robot_code still throws synchronously
  on a missing World (repl displays that as an error), while the RPC
  handler catches and logs instead, since there's no caller boundary to
  surface a throw to there.
- RobotSimulation tab: two-column layout (3D view left, embedded editor
  right); editor code persists to localStorage like the camera view does.

run_robot_code/set_evaluator's own $focusTab/'focus' sends are left as-is
(still correct, still occasionally useful pre-first-navigation) but their
doc comments now say plainly that they rarely do anything once a student
has looked at any tab - this embedded editor is the actual fix for
wanting the view and the code on screen together.
Adds robot_simulation to conductor-modules.json (the allowlist
filterDocsVisibleBundles reads) - without it the module was silently
excluded from the generated documentation site, same as any
not-yet-migrated bundle.

Also fills in JSDoc (@param/@category) on every exported method that was
missing it - mainly the raw createX/addControllerToWorld/saveToContext
setup API and all ev3_* functions, which previously had no doc comment
at all. @category groups the sidebar into Scene Setup / Control Program /
EV3 instead of one flat list, matching repl's existing convention.

No behavior change - doc comments and one allowlist entry only.
…d_paper

add_paper's overlay has no physics collider (documented, known follow-up
in ColorSensor.ts), so the color sensor's raycast can never detect it -
the color sensor demo the module's own docs point to has never actually
worked with add_paper. add_color_patch adds a thin, flat, fixed cuboid
instead (a real collider, registered with Physics.registerColor like any
other cuboid), so ev3_colorSensor can actually read it.

Verified live: init_default_simulation() + add_color_patch('red', 0, 0,
1, 1) immediately shows Color: rgb(255, 0, 0) in the RobotSimulation
tab's sensor readout.

add_paper's doc comment now points to this for anything that needs to be
sensed, not just seen.
…erun

Every run_robot_code/$runReplCode call (each REPL/embedded-editor Run)
adds a fresh Program controller rather than replacing one in place, and
never removes superseded ones from world.controllers.controllers - only
marks them isStopped. ev3_pause() looked up "the" Program controller by
name via .find(), which always returns the FIRST one ever added - so on
any run after the first, it silently paused a dead, already-stopped
Program instead of the one actually executing, making ev3_pause() a
no-op. This is what looked like consecutive motor commands (e.g. a
forward call immediately followed by a reverse one from a separate Run)
"canceling out" instantly regardless of how large a pause was inserted -
the pause never touched the program actually running.

Fixed by searching from the end of the controllers list for the last
non-stopped Program - the one currently driving the run - and exposing
Program.isStopped (was private) so ev3_functions.ts can tell them apart.

Verified live: a long-running forward command stayed at its original
motor velocity throughout a second run's ev3_pause(2500) before finally
reversing once the pause elapsed, instead of reversing instantly.
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.

1 participant