FabMotion is a software simulation of a Cartesian gantry wafer handler: path planning, jerk-limited trajectory generation, a fixed-period Rust control loop, and a simulated motor-controller interface (PD-servo plant + encoder/current/limit-switch feedback), with fault injection and a supervisory state machine.
What this is not: FabMotion does not talk to real motors, encoders, or field buses (EtherCAT/CANopen/etc.), and it makes no hard real-time guarantee. It runs as an ordinary process on stock (non-PREEMPT_RT) Linux, where the OS scheduler can and does preempt it. Every timing number below is a real measurement from this repo's own test hardware, not a spec claim — see Measured results.
Vision/localization (Python + OpenCV, synthetic images -> pixel->mm target)
|
Path planner (Rust: grid-based A* around virtual obstacles)
|
Trajectory generator (Rust: jerk-limited real-time stepper, or closed-form trapezoidal)
|
Rust Linux control loop (fixed-period, monotonic-clock scheduled, jitter/WCET measured)
|
Simulated motor controllers (PD-servo plant per axis: position/velocity cmd, enable/disable)
|
Encoder/current/limit-switch feedback (noisy, delayable, dropout-able — all fault-injectable)
Supervisory states: Homing -> Idle -> Running -> Fault -> Recovery -> Idle.
src/
main.rs orchestrates a full wafer-transfer demo scenario
motor.rs simulated motor-controller interface (plant + feedback + faults)
trajectory.rs TrapezoidalProfile (closed-form) + JerkLimitedStepper (real-time)
planner.rs A* grid planner with line-of-sight path simplification
state_machine.rs Homing/Idle/Running/Fault/Recovery
fault_injection.rs JSON-scheduled fault scenarios
control_loop.rs fixed-period loop + jitter/deadline-miss/WCET measurement
logging.rs CSV per-cycle telemetry + JSON run summary
tests/
integration_tests.rs runs the actual compiled binary end-to-end
vision/
detect_wafer.py OpenCV fiducial detection on synthetic images (Python, stretch goal)
analysis/
analyze_logs.py turns CSV logs into jitter/tracking-error/position plots
faults/
scenario.json the default fault-injection schedule used by `cargo run`
.github/workflows/ci.yml build, test, clippy, vision smoke test
cargo build --release
cargo test --release # 15 unit tests + 2 integration tests
# Full demo: homes 3 axes, then 3 round trips between a load and a process
# station, routing around a virtual obstacle, with 4 scheduled faults
# (encoder dropout, overcurrent, comm delay, stale feedback).
./target/release/fabmotion
# --duration-s <f64> default 45
# --period-us <u64> default 1000 (1kHz)
# --faults <path> default faults/scenario.json
# --no-faults disable fault injection
# --log-csv <path> default logs/run.csv
# --summary-json <path> default logs/summary.json
# --seed <u64> RNG seed for encoder noise
python3 analysis/analyze_logs.py --csv logs/run.csv --summary logs/summary.json
python3 vision/detect_wafer.py --seed 1Each of the 3 axes (X, Y, Z) is a simulated motor-controller interface: a PD-servo driving a point-mass plant, with force/current saturated at a configurable rated current (so a controller error can't produce unphysical instantaneous torque), plus:
- Position and velocity command inputs
- Noisy encoder position feedback (independent of the "true" simulated position)
- A current/force estimate derived from the saturated servo effort
- Enable/disable state (faults force-disable the axis)
- Limit switches evaluated against true position
- Fault codes:
Overcurrent,EncoderDropout,StaleFeedback,LimitSwitchMin/Max,CommDelayTimeout,PositionError
Fault injection (faults/scenario.json, or --faults <path>) schedules any
of: encoder dropout, stale feedback, overcurrent, limit-switch-stuck, and
communication delay, each with a start time and duration. See
fault_injection.rs for the schema (tagged JSON, one object per fault).
TrapezoidalProfile: exact closed-form trapezoidal velocity profile (instantaneous acceleration changes).JerkLimitedStepper: the generator actually used by the demo scenario — a real-time, per-control-cycle generator using a critically-damped, amax-saturated acceleration command (bang-bang-like far from target, smoothly damped near it), then jerk-limited by ramping acceleration at a bounded rate. This is evaluated incrementally each cycle, the way an embedded motion controller would do it, rather than solved in closed form.
planner.rs runs 8-connected A* on a 5mm grid around rectangular virtual
obstacles, then applies line-of-sight string-pulling to collapse the
grid staircase into the minimal set of straight-line waypoints that still
clears every obstacle. (An earlier version skipped this step and used the
raw grid path — the gantry braked to near-zero speed at every single
5mm grid step and never reached cruise velocity. Worth knowing if you
modify the planner.)
From a representative 45s run (cargo run --release, default scenario, 1kHz
loop, on the CI/dev container's shared vCPU — not a dedicated or
PREEMPT_RT machine):
| Metric | Value |
|---|---|
| Median loop jitter | ~9 us |
| p99 loop jitter | ~190 us |
| Worst-case observed jitter | ~21 ms |
| Worst-case observed cycle exec time | ~4.4 ms |
| Deadline miss rate | ~0.5% |
| Max tracking error (commanded vs. simulated-true position) | ~3.6 mm |
| Avg settling time per point-to-point move | ~1–2 s |
| Fault detection latency | 0 cycles (this model detects synchronously within the cycle the fault manifests) |
| Recovery duration | ~0.1 s (configurable dwell) |
Re-run python3 analysis/analyze_logs.py after any cargo run to regenerate
analysis/*.png and analysis/report.md with your own numbers — jitter and
miss rate are workload- and hardware-dependent, which is the point: they're
measured, not assumed.
Example output (from logs/sample/, a 6s run committed in this repo):
Why the worst-case jitter (~21ms) is so much larger than the median (~9us): this is an ordinary process on a shared, non-realtime kernel. Rare scheduler preemptions, page faults, or CI-runner noisy-neighbor effects show up as occasional multi-millisecond stalls. A production system needing bounded worst-case latency would need PREEMPT_RT (or a genuine RTOS) and CPU isolation — neither of which this repo claims to provide.
vision/detect_wafer.py generates a synthetic top-down image of a wafer
station with a colored fiducial marker, detects it via OpenCV (HSV color
threshold + contour centroid), and converts the pixel centroid into a
simulated robot target via a fixed pixel-to-mm affine calibration — a
stand-in for "camera looking down at the gantry's calibrated coordinate
frame." It is not connected to a live camera, and it is intentionally
decoupled from the Rust control loop (it writes vision/target.json, which
nothing currently auto-consumes — wiring that in is a natural follow-up).
- Multi-axis coordinated motion is per-axis independent, not a true jointly-time-scaled multi-axis blend.
- Recovery resumes by re-planning from wherever the axes actually settled, rather than resuming the exact interrupted trajectory.
- The vision pipeline runs on synthetic, not live, camera images.
- No hard real-time guarantees, ever — see the disclaimer at the top.
MIT — see LICENSE.


