Lab 6 · Obstacle Avoidance
Where Lab 5 Ended
avoid frame, the last
picture of Open-Path Centroid: open floor (green), its centroid (yellow),
offset +0.53. The arrow is new. It is the pull: from the car to the
centroid.Open-Path Centroid ended with one number per frame: the offset, -1 (open path far left) to +1 (far right). This lab turns that number into a pull on your steering, live, while YOU drive. Offset, then pull, then steer. One number, one arrow, one wheel.
- The pull adds to your stick. It never takes the wheel: at gain 0 it is raw teleop.
- One function drives the car (the Racer API). Your code calls it; the robot plumbing lives behind it.
The Same Idea, In The Toy Sim
The sim has no camera. Its lidar fan is the ROI: 60 beams, 6 m range. A beam that reads far is open floor. A beam that reads short is a box or a wall. The range-weighted mean bearing of the fan is the offset, and the gain turns it into the pull. Same pipeline, three sensors fewer.
Try it with no boxes first. Hold the throttle and touch nothing: the pull alone steers the ring, because the open path always bends with the road. That is lane following, and it is the same one number. Then shuffle the boxes in: a box shortens a few beams, the centroid moves off it, and the pull goes around. Small boxes on purpose: at full throttle the pull comes late, so lift when one is close.
The Controller Ladder
The assist is one number: a pull on your steering, from the offset of the open path. Three terms build it, and you add them in order.
- P - pull = KP x offset. A bigger KP pulls harder. Too big, and the car swings past the open path and back: that wobble is oscillation.
- D - watch how fast the offset changes, and subtract a small multiple of that change. D damps the swing. Keep the last offset in a global and you have it.
- I - a running sum of the offset. A wall that leans on you a little every tick adds up; I cancels a steady lean. Too much, and it winds up and drags you the other way.
One more knob is not a term: FAN, the beams that count.
A wide fan lets side walls pull. A narrow fan sees only ahead. Try FAN
before I when the car hugs one side.
Where It Runs, And What Counts
- Here in your browser, against the simulated car. The same code works on the live car later - there the physics closes the loop for free.
- The Play tab has a built-in assist on the sim's lidar. The
Code tab replaces it with YOUR controller: real
Python, called ~15 times a second while you throttle -
assist(ranges, bearings) → pull. Apply it, drive, tune, repeat. A crash hands control back to the built-in and prints the error. - The check: shuffle the boxes, touch the gain (or Apply your code), and drive one lap with hits 0. The HUD counts every box you touch. A clean lap after a touched knob finishes the lab.
Follow the White Rabbit
The controller ladder (P, then D, then I) is the oldest trick in control. It runs your thermostat, your drone, and every VESC.
- PID controller - the three terms, what each one fixes, and why you tune them in that order.
- Brian Douglas: PID control, a brief introduction - the ten-minute version with plots. Watch the overshoot; that is your oscillation.
Have an F710? Plug it into this computer, switch it to X, hold LB and drive the sticks.
// Drive with arrow keys / WASD (or the F710, LB held). While you throttle, the assist reads the lidar fan, finds the open path, and pulls your steering toward it. First clear the boxes, hold the throttle and touch nothing: the pull alone drives the ring. That is lane following. Then shuffle the boxes: the same pull goes around them. Set the gain to 0 and drive into one: hits counts it. Raise the gain, shuffle, and drive one lap at hits 0. That clean lap finishes the lab.
- Assist is a pull on YOUR steering, not a takeover - gain 0 is raw teleop.
- The pull comes from the lidar fan: open path wins, scaled by the gain.
- No boxes plus a held throttle is lane following: the open path bends with the road.
- P first. Oscillates? Add D. Hugs one side? Add I.
- The check is a clean lap: shuffle the boxes, touch the gain or Apply, drive one lap with hits 0.
// what you should be able to do now. Each one traces to a knob or a view in this lab.
- Clear the boxes, hold the throttle, touch nothing. Say why the car turns.
- Set the gain to 0 and drive into a box (hits goes to 1). Raise the gain, shuffle, and drive a lap at hits 0. Say what the pull did to your steering.
- Make the assist oscillate on purpose. Then fix it, and say which term you added.
- Make the car hug one wall, and fix it with FAN or with I. Say which one you used and why.
- Name the three terms of the ladder in the order you tune them.
// Your controller, called ~15x a second while you throttle in the Play tab. Apply it, then drive.
// the rest of the lab, file by file. Your file is the editor above; these are the exact files that run it. Enough to reproduce the lab outside this page.
app/static/js/simcar.js
// simcar.js - Hello Racer: teleop the simulated car in the browser.
// Same kinematic bicycle + stick feel as drive.js, plus the sensor suite:
// synthetic lidar (raycast vs the track walls and obstacle boxes) and an
// IMU readout (longitudinal accel + yaw rate). The readout speaks the ONE
// language: racer.drive(steer, throttle) in [-1, 1] - real car, sim, and
// trained policy all use it.
// ponytail: toy physics on purpose - this lab teaches driving + sensors,
// the graded pipelines run real Python via Pyodide elsewhere.
(function () {
const canvas = document.getElementById("sim-canvas");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const W = canvas.width, H = canvas.height;
const css = () => getComputedStyle(document.documentElement);
const SCALE = 40; // px per meter (wheelbase = 1 m below)
// track: an oval ring; walls are the inner/outer ellipse edges
const cx = W / 2, cy = H / 2, rx = W * 0.36, ry = H * 0.32, halfWidth = 46;
const rox = rx + halfWidth, roy = ry + halfWidth; // outer wall
const rix = rx - halfWidth, riy = ry - halfWidth; // inner wall
// obstacle boxes on the road (world px, axis-aligned)
let boxes = [
{ x: cx + rx - 14, y: cy - 55, w: 26, h: 26 },
{ x: cx - 60, y: cy - ry - 16, w: 30, h: 24 },
{ x: cx - rx - 10, y: cy + 30, w: 26, h: 26 },
];
let lidarOn = true, assistOn = true;
// HUD controls (plays/*.html): boxes come and go, lidar and assist switch off
window.goatSim = {
shuffle(n = 3) {
boxes = [];
for (let i = 0; i < n; i++) {
// somewhere on the road, never on the start line
const th = Math.PI / 2 + 0.6 + Math.random() * (2 * Math.PI - 1.2);
const k = 0.35 + Math.random() * 0.3; // fraction across the road width
// crate-sized (0.35-0.5 m): the road is 2.3 m, the car 0.6 m, and the
// assist reacts late at 4 m/s - measured 2026-09-04, throttle held and
// nobody steering, the default assist clears 4 of 5 laps at this size
// and only 1 of 2 at the old 22-32 px (#252)
const w = 14 + Math.random() * 6, h = 14 + Math.random() * 6;
boxes.push({
x: cx + (rix + (rox - rix) * k) * Math.cos(th) - w / 2,
y: cy + (riy + (roy - riy) * k) * Math.sin(th) - h / 2,
w, h,
});
}
},
clear() { boxes = []; },
set(list) { boxes = list.map((b) => ({ ...b })); },
get boxes() { return boxes; },
get car() { return car; },
lidar(on) { lidarOn = !!on; },
assist(on) { assistOn = !!on; },
// Obstacle Avoidance self-check: a touched knob (gain slider or Apply)
// plus one lap that clears every box finishes the lab
touch() { touched = true; },
get hits() { return hits; },
get laps() { return laps; },
};
const car = { x: cx, y: cy + ry, psi: Math.PI, v: 0 };
const stick = { steer: 0, throttle: 0 };
const keys = {};
let laps = 0, lastAngle = null, progress = 0;
let hits = 0, lapHits = 0, touched = false, touching = false;
let ax = 0, yawRate = 0; // the IMU
addEventListener("keydown", (e) => {
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "w", "a", "s", "d"].includes(e.key)) {
keys[e.key] = true;
if (e.key.startsWith("Arrow")) e.preventDefault();
}
});
addEventListener("keyup", (e) => (keys[e.key] = false));
// ray vs ellipse: map to the unit circle (linear, so t survives), solve
// the quadratic, smallest positive root. Inside → exit hit, outside → entry.
function rayEllipse(px, py, dx, dy, ex, ey) {
const ox = (px - cx) / ex, oy = (py - cy) / ey;
const ux = dx / ex, uy = dy / ey;
const a = ux * ux + uy * uy;
const b = 2 * (ox * ux + oy * uy);
const c = ox * ox + oy * oy - 1;
const disc = b * b - 4 * a * c;
if (disc < 0) return Infinity;
const s = Math.sqrt(disc);
for (const t of [(-b - s) / (2 * a), (-b + s) / (2 * a)]) if (t > 0) return t;
return Infinity;
}
// ray vs axis-aligned box: slab method
function rayBox(px, py, dx, dy, b) {
let t0 = -Infinity, t1 = Infinity;
for (const [p, d, lo, hi] of [[px, dx, b.x, b.x + b.w], [py, dy, b.y, b.y + b.h]]) {
if (Math.abs(d) < 1e-9) { if (p < lo || p > hi) return Infinity; continue; }
let ta = (lo - p) / d, tb = (hi - p) / d;
if (ta > tb) [ta, tb] = [tb, ta];
t0 = Math.max(t0, ta); t1 = Math.min(t1, tb);
}
return t0 <= t1 && t1 > 0 ? (t0 > 0 ? t0 : t1) : Infinity;
}
const LIDAR_N = 60, LIDAR_MAX = 6 * SCALE; // 60 beams, 6 m range
function scan() {
if (!lidarOn) return [];
const out = [];
for (let i = 0; i < LIDAR_N; i++) {
const a = car.psi + (2 * Math.PI * i) / LIDAR_N;
const dx = Math.cos(a), dy = Math.sin(a);
let t = Math.min(
rayEllipse(car.x, car.y, dx, dy, rox, roy),
rayEllipse(car.x, car.y, dx, dy, rix, riy)
);
for (const b of boxes) t = Math.min(t, rayBox(car.x, car.y, dx, dy, b));
out.push({ a, t: Math.min(t, LIDAR_MAX), hit: t <= LIDAR_MAX });
}
return out;
}
// USB gamepad (F710 in XInput, sticks preset - same map as the car):
// LB is the deadman, left stick Y throttle, right stick X steering.
// Keyboard and pad coexist: the pad owns the car only while LB is held
// (deadman semantics, like goat-teleop); keyboard drives any other time.
let padOn = false;
function padInput() {
const pads = navigator.getGamepads ? navigator.getGamepads() : [];
const pad = Array.from(pads).find((p) => p && p.connected);
padOn = !!pad;
if (!pad || !(pad.buttons[4] && pad.buttons[4].pressed)) return null;
const dz = (x) => (Math.abs(x) < 0.12 ? 0 : x);
return { steer: dz(pad.axes[2]), throttle: -dz(pad.axes[1]) };
}
// teleop assist (Obstacle Avoidance lab): the lidar version of the
// open-path centroid - the range-weighted mean bearing of the forward fan
// points at open space; the assist pulls steering toward it while YOU
// keep the wheel. Enabled by the #sim-assist-gain slider being present.
const assistGain = document.getElementById("sim-assist-gain");
assistGain?.addEventListener("input", () => (touched = true));
const FAN_HALF = Math.PI / 3; // ±60°
let _pyTick = 0, _pyPull = 0;
function assistPull(beams) {
let wsum = 0, asum = 0;
for (const s of beams) {
let rel = s.a - car.psi;
while (rel > Math.PI) rel -= 2 * Math.PI;
while (rel < -Math.PI) rel += 2 * Math.PI;
if (Math.abs(rel) > FAN_HALF) continue;
wsum += s.t;
asum += s.t * rel;
}
return wsum ? (asum / wsum) / FAN_HALF : 0; // open-path offset in [-1, 1]
}
let _padActive = false;
function step(dt) {
// sticks: springy steer, ramped throttle - gamepad feel
const pad = padInput();
_padActive = !!pad;
let sTarget = pad
? pad.steer
: (keys.ArrowLeft || keys.a ? -1 : 0) + (keys.ArrowRight || keys.d ? 1 : 0);
const tTarget = pad
? pad.throttle
: (keys.ArrowUp || keys.w ? 1 : 0) + (keys.ArrowDown || keys.s ? -0.6 : 0);
let pull = 0;
let pyDrove = false;
if (assistGain && assistOn && lidarOn && Math.abs(stick.throttle) > 0.05) {
const ga = window.goatAssist;
if (ga && ga.fn) {
// student python (Code tab) owns the assist - called ~15 Hz, held
// between calls; a crash hands control back to the built-in
pyDrove = true;
if (++_pyTick >= 4) {
_pyTick = 0;
try {
const beams = scan();
const ranges = [], bearings = [];
for (const s of beams) {
let rel = s.a - car.psi;
while (rel > Math.PI) rel -= 2 * Math.PI;
while (rel < -Math.PI) rel += 2 * Math.PI;
ranges.push(+(s.t / SCALE).toFixed(3));
bearings.push(+rel.toFixed(4));
}
_pyPull = Math.max(-1, Math.min(1, ga.fn(ranges, bearings)));
} catch (e) {
ga.fail(e);
_pyPull = 0;
}
}
pull = _pyPull;
} else {
pull = parseFloat(assistGain.value) * assistPull(scan());
}
sTarget = Math.max(-1, Math.min(1, sTarget + pull));
}
const nEl = document.getElementById("sim-assist");
if (nEl)
nEl.textContent =
`assist ${(pull >= 0 ? "+" : "") + pull.toFixed(2)}` +
(pyDrove ? " · your code" : "");
stick.steer += (sTarget - stick.steer) * Math.min(1, dt * 10);
stick.throttle += (tTarget - stick.throttle) * Math.min(1, dt * 4);
const vMax = 170; // px/s
const v0 = car.v, psi0 = car.psi;
car.v += (stick.throttle * vMax - car.v) * Math.min(1, dt * 2);
car.psi += (car.v / SCALE) * Math.tan(stick.steer * 0.45) * dt;
car.x += car.v * Math.cos(car.psi) * dt;
car.y += car.v * Math.sin(car.psi) * dt;
ax = (car.v - v0) / Math.max(dt, 1e-6);
yawRate = (car.psi - psi0) / Math.max(dt, 1e-6);
// walls: off the road (past either ellipse) scrubs speed and pulls the
// car back toward the centerline - soft, but you cannot leave the ring
const dxn = (car.x - cx) / rx, dyn = (car.y - cy) / ry;
const fIn = (car.x - cx) ** 2 / (rix * rix) + (car.y - cy) ** 2 / (riy * riy);
const fOut = (car.x - cx) ** 2 / (rox * rox) + (car.y - cy) ** 2 / (roy * roy);
if (fIn < 1 || fOut > 1) {
car.v *= 0.85;
const a0 = Math.atan2(dyn, dxn);
car.x += (cx + rx * Math.cos(a0) - car.x) * 0.12;
car.y += (cy + ry * Math.sin(a0) - car.y) * 0.12;
}
// solid boxes: hit one and you stop, pushed back out along the contact
// normal - you cannot drive through, you back up and go around
const R = 12; // car body radius
let contact = false;
for (const b of boxes) {
const nx = Math.max(b.x, Math.min(car.x, b.x + b.w));
const ny = Math.max(b.y, Math.min(car.y, b.y + b.h));
const d = Math.hypot(car.x - nx, car.y - ny);
if (d >= R) continue;
contact = true;
let ux = car.x - nx, uy = car.y - ny;
if (d < 1e-6) { ux = -Math.cos(car.psi); uy = -Math.sin(car.psi); }
else { ux /= d; uy /= d; }
car.x = nx + ux * R;
car.y = ny + uy * R;
car.v = 0;
stick.throttle *= 0.5;
}
if (contact && !touching) {
hits += 1; lapHits += 1; // one hit per contact, not per frame
const el = document.getElementById("sim-hits");
if (el) el.textContent = hits;
}
touching = contact;
// lap counting (angle unwrap around the ring)
const a = Math.atan2(dyn, dxn);
if (lastAngle !== null) {
let da = a - lastAngle;
if (da > Math.PI) da -= 2 * Math.PI;
if (da < -Math.PI) da += 2 * Math.PI;
progress += da;
if (Math.abs(progress) >= 2 * Math.PI) {
laps += 1;
// Hello Racer: one lap driven. Obstacle Avoidance (assist slider
// present): a touched knob, then one lap that clears every box (#252)
if (!assistGain || (touched && boxes.length && lapHits === 0)) window.goatLab?.finish();
progress = 0;
lapHits = 0;
const el = document.getElementById("sim-laps");
if (el) el.textContent = laps;
}
}
lastAngle = a;
}
// rolling telemetry for the HUD graphs - same idea as the IsaacSim video
// overlays (annotate_hud.py): stacked channel panels, zero baseline,
// label + live value per panel
const HIST_N = 150;
const hist = [];
function drawGraphs(c) {
const chans = [
["steer", -1, 1, c.getPropertyValue("--ink").trim() || "#222"],
["thr", -1, 1, "#1e7a44"],
["v", 0, 4.5, c.getPropertyValue("--brand").trim() || "#3a2dc4"],
["yaw", -180, 180, "#c07822"],
];
const labels = { steer: "steer", thr: "throttle", v: "m/s", yaw: "yaw °/s" };
const gw = 176, ph = 40, pad = 7, x0 = W - gw - 14;
let y0 = 14;
for (const [key, lo, hi, col] of chans) {
ctx.fillStyle = "rgba(20, 20, 15, 0.10)";
ctx.fillRect(x0, y0, gw, ph);
ctx.strokeStyle = c.getPropertyValue("--line");
ctx.lineWidth = 1;
ctx.strokeRect(x0, y0, gw, ph);
const zy = y0 + ph - Math.max(0, Math.min(1, (0 - lo) / (hi - lo))) * ph;
ctx.beginPath();
ctx.moveTo(x0, zy);
ctx.lineTo(x0 + gw, zy);
ctx.stroke();
if (hist.length >= 2) {
ctx.strokeStyle = col;
ctx.lineWidth = 1.6;
ctx.beginPath();
hist.forEach((d, j) => {
const x = x0 + (gw * j) / (HIST_N - 1);
const frac = Math.max(0, Math.min(1, (d[key] - lo) / (hi - lo)));
const y = y0 + ph - frac * ph;
j ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
});
ctx.stroke();
}
const cur = hist.length ? hist[hist.length - 1][key] : 0;
ctx.fillStyle = col;
ctx.font = "10.5px monospace";
ctx.fillText(`${labels[key]} ${(cur >= 0 ? "+" : "") + cur.toFixed(key === "yaw" ? 0 : 2)}`, x0 + 5, y0 + 12);
y0 += ph + pad;
}
}
function draw() {
const c = css();
const ink = c.getPropertyValue("--ink"), brand = c.getPropertyValue("--brand");
ctx.clearRect(0, 0, W, H);
// road
ctx.strokeStyle = c.getPropertyValue("--bg-panel");
ctx.lineWidth = halfWidth * 2;
ctx.beginPath();
ctx.ellipse(cx, cy, rx, ry, 0, 0, 2 * Math.PI);
ctx.stroke();
// infield branding
ctx.fillStyle = c.getPropertyValue("--line");
ctx.font = "900 96px Urbanist, sans-serif";
ctx.textAlign = "center";
ctx.fillText("GOAT", cx, cy + 34);
ctx.textAlign = "start";
// walls
ctx.strokeStyle = c.getPropertyValue("--line");
ctx.lineWidth = 2;
for (const [ex, ey] of [[rox, roy], [rix, riy]]) {
ctx.beginPath();
ctx.ellipse(cx, cy, ex, ey, 0, 0, 2 * Math.PI);
ctx.stroke();
}
// obstacles
ctx.fillStyle = c.getPropertyValue("--line");
for (const b of boxes) ctx.fillRect(b.x, b.y, b.w, b.h);
// lidar: faint beams, solid hit points
for (const s of scan()) {
const hx = car.x + s.t * Math.cos(s.a), hy = car.y + s.t * Math.sin(s.a);
ctx.strokeStyle = brand;
ctx.globalAlpha = 0.08;
ctx.beginPath();
ctx.moveTo(car.x, car.y);
ctx.lineTo(hx, hy);
ctx.stroke();
ctx.globalAlpha = 1;
if (s.hit) {
ctx.fillStyle = ink;
ctx.fillRect(hx - 1.5, hy - 1.5, 3, 3);
}
}
// the car - top-down silhouette: wheels, chassis, camera mast
ctx.save();
ctx.translate(car.x, car.y);
ctx.rotate(car.psi);
ctx.fillStyle = ink;
// the front wheels turn with the stick - the same angle the physics uses
for (const [wx, wy] of [[10, -9], [10, 9], [-10, -9], [-10, 9]]) {
ctx.save();
ctx.translate(wx, wy);
if (wx > 0) ctx.rotate(stick.steer * 0.45);
ctx.beginPath();
ctx.roundRect(-4, -2.5, 8, 5, 2);
ctx.fill();
ctx.restore();
}
ctx.fillStyle = brand;
ctx.beginPath();
ctx.moveTo(17, 0);
ctx.lineTo(12, -5.5);
ctx.lineTo(-12, -6.5);
ctx.lineTo(-14, -3);
ctx.lineTo(-14, 3);
ctx.lineTo(-12, 6.5);
ctx.lineTo(12, 5.5);
ctx.closePath();
ctx.fill();
ctx.fillStyle = "#f4f4ec";
ctx.fillRect(1, -2.5, 5, 5); // the camera mast
ctx.restore();
// telemetry graphs (IsaacSim overlay style)
drawGraphs(c);
// HUD - the one language
const fmt = (x) => (x >= 0 ? "+" : "") + x.toFixed(2);
const ro = document.getElementById("sim-readout");
if (ro) ro.textContent = `racer.drive(steer=${fmt(stick.steer)}, throttle=${fmt(stick.throttle)})`;
const pc = document.getElementById("sim-pad");
if (pc) {
pc.hidden = !padOn;
pc.textContent = _padActive ? "pad: driving" : "pad: hold LB to drive";
}
}
let last = performance.now();
(function loop(now) {
step(Math.min(0.05, (now - last) / 1000));
last = now;
hist.push({
steer: stick.steer,
thr: stick.throttle,
v: car.v / SCALE,
yaw: (yawRate * 180) / Math.PI,
});
if (hist.length > HIST_N) hist.shift();
draw();
requestAnimationFrame(loop);
})(last);
})();
app/static/js/obstassist.js
// obstassist.js - Obstacle Avoidance Code tab: the student's Python assist
// controller, run in the browser (core Pyodide via pyboot.js) and called by
// the sim (~15x a second) while the driver throttles. Apply publishes
// window.goatAssist.fn; simcar.js calls it with (ranges_m, bearings_rad)
// and uses the returned pull INSTEAD of the built-in assist.
// Calls go through runPython + JSON to dodge PyProxy lifetime bookkeeping
// at 15 Hz.
(function () {
const badge = document.getElementById("pybadge");
const codeEl = document.getElementById("assistcode");
const out = document.getElementById("assist-log");
const applyBtn = document.getElementById("assistapply");
if (!badge || !codeEl) return;
const emit = (s) => {
document.getElementById("assist-outputwrap").hidden = false;
out.textContent += s + "\n";
};
window.goatAssist = {
fn: null,
// one loud failure, then the built-in assist takes back over
fail(e) {
this.fn = null;
emit(`assist crashed while driving - built-in assist restored:\n${e}`);
},
};
const pb = goatPyBoot({ badge, size: "14" });
function apply() {
const py = pb.py;
if (!py) return;
document.getElementById("assist-outputwrap").hidden = false;
out.textContent = "";
py.setStdout({ batched: emit });
py.setStderr({ batched: emit });
try {
py.runPython(codeEl.value);
const probe = py.runPython("callable(globals().get('assist'))");
if (!probe) {
emit("no assist(ranges, bearings) function found - define one.");
window.goatAssist.fn = null;
return;
}
window.goatAssist.fn = (ranges, bearings) =>
py.runPython(
`float(assist(${JSON.stringify(ranges)}, ${JSON.stringify(bearings)}))`
);
emit("assist applied - drive it in the Play tab.");
} catch (e) {
window.goatAssist.fn = null;
emit(String(e));
}
}
badge.addEventListener("pyready", () => { applyBtn.hidden = false; apply(); });
applyBtn.addEventListener("click", () => { window.goatLab?.ev("apply"); window.goatSim?.touch(); apply(); });
})();
Hints
Hint 1: the assist does nothing
Hit Apply after every edit. The output pane must say the code loaded. The
function must be named assist(ranges, bearings) and return a
float. The pull is clamped to [-1, 1] before it reaches the stick.
Hint 2: it oscillates
P is too high. Halve KP. Still wobbling? Keep the last offset
in a global and subtract a small multiple of the change (that is D).
Hint 3: it hugs one side
The range-weighted mean bearing leans toward long beams. Narrow
FAN so side walls stop pulling. If it still drifts, add a small
running sum of the offset (that is I) and feed it back.