A single loop detector estimates speed by assuming every vehicle is a car. Add trucks and watch it go wrong
A single lane at a steady 72 km/h passes a 1.8 m loop. The loop flashes while a vehicle is over it. Every 30 seconds it reports what a real detector would: a count, an occupancy, and a speed estimated by assuming every vehicle is a 5 m car.
viewof loopTrucks = Inputs.range([0,40], {value:20,step:1,label:"Share of trucks (%)"})
loopLive = {const v =20, L =360, x0 =200, d =1.8, dt =1/30, rate =0.45, p = loopTrucks /100, period =30;let veh = [], t =0, nextArrival =0, occTime =0, count =0, periodStart =0;const reports = [];while (true) {for (let s =0; s <4; s++) {if (t >= nextArrival) {const len =Math.random() < p ?18:5;const last = veh[veh.length-1];if (!last || last.x- last.len>8) veh.push({x:0, len,truck: len >5}); nextArrival = t -Math.log(1-Math.random()) / rate; }let over =false;for (const c of veh) {const xn = c.x+ v * dt;if (c.x< x0 && xn >= x0) count++; c.x= xn;if (c.x>= x0 && c.x- c.len<= x0 + d) over =true; }if (over) occTime += dt; veh = veh.filter(c => c.x- c.len< L); t += dt;if (t - periodStart >= period) {const o = occTime / period, q = count / period *3600, kEst = o / (5+ d) *1000;// veh/h and veh/km, so q / kEst is in km/h reports.unshift({time:Math.round(t), count,occ: o, q,vEst: kEst >0? q / kEst :NaN});if (reports.length>5) reports.pop(); occTime =0; count =0; periodStart = t; } }yield {veh: veh.map(c => ({...c})),over: veh.some(c => c.x>= x0 && c.x- c.len<= x0 + d),reports: reports.slice(), L, x0}; }}
loopLive.reports.length?md`| Report at | Occupancy | Flow (veh/h) | Estimated speed (true: 72 km/h) ||---:|---:|---:|---:|${loopLive.reports.map(r =>`| ${r.time} s | ${(r.occ*100).toFixed(1)}% | ${r.q.toFixed(0)} | **${isNaN(r.vEst) ?"—": r.vEst.toFixed(0) +" km/h"}** |`).join("\n")}`:md`*The first 30-second report is on its way…*`
What it shows
Nobody slows down, yet the reported speed falls as the share of trucks rises. Real systems use pairs of loops or radar to measure speed directly. Lecture 2 shows where the error comes from.
theme = Generators.observe(notify => {const read = () =>notify(document.body.classList.contains("quarto-dark") ?"dark":"light");read();const watcher =newMutationObserver(read); watcher.observe(document.body, {attributes:true,attributeFilter: ["class"]});return () => watcher.disconnect();})accent = theme ==="dark"?"#c9a0ff":"#57068c"accentRange = theme ==="dark"? ["#4b3a5c","#ecdcff"] : ["#dccbee","#3a0463"]ink = theme ==="dark"?"#f2f2f2":"black"// Plot width: Plot's default 640 px, except on a phone, where a plot is drawn to fit its card// instead of being shrunk (and its text with it). Changes only when the width actually changes.plotW = Generators.observe(notify => {let last;const read = () => {const main =document.querySelector("main"), fit = main ? main.clientWidth-50:640;const w =!document.querySelector(".reveal") && fit <640?Math.max(240,Math.floor(fit)) :640;if (w !== last) notify(last = w); };read();window.addEventListener("resize", read);return () =>window.removeEventListener("resize", read);})