A 0.6B model, fine-tuned and served on a 6 GB consumer GPU with Microsoft Foundry Local
Guest post by Ebenezer Isaac Veeraraju, MSc Systems Engineering for the Internet of Things, UCL. The project was developed through UCL's Industry Exchange Network (IXN), with academic supervision from UCL and industry supervision from Microsoft. I designed the system, ran every experiment and obtained the Transport for London data while using AI coding and writing tools along the way.
In short: I fine-tuned a 0.6-billion-parameter language model and served it on a 6 GB consumer GPU through Microsoft Foundry Local, and it ran the traffic signals of a nine-junction London grid in simulation. With traffic calibrated to keep flowing, it cut delay by 8.8% against a fixed-time plan, level with MaxPressure, a classic adaptive algorithm. Fine-tuning on decisions labelled by a larger Claude model is what got it there: delay fell by 18.3% against the unmodified model, in all 30 simulation runs, and the 0.6B model drew level with a fine-tuned model about six times its size. Every decision passes a safety check and goes into a tamper-evident log, which after a simulated collision maps the controller's actions to UK road-traffic rules. Finally, I tested the controllers on Euston Road with the hourly shape of real Transport for London counts, where the simulator's own vehicle-actuated plan proved a strong baseline.
About me
I've just finished the MSc in Systems Engineering for the Internet of Things at UCL. I'm drawn to deep tech and IoT problems where separate systems have to work together, and traffic signals that each decide alone, without talking to their neighbours, are a clear case of that. You can find me on LinkedIn and GitHub.
About the project
My dissertation asked whether a small language model running on a consumer GPU could choose traffic-signal phases as well as two standard baselines, and whether the controller around it could keep a record that lets you reconstruct what it did after a collision. A phase is a set of traffic movements that get green together. The two baselines:
- A default plan is the signal timing the simulator generates for each junction, not one tuned for the site. On Bloomsbury and Old Street it is a fixed-time plan, cycling through the phases on a timetable whatever the traffic does. On Euston Road my build script asked SUMO for vehicle-actuated programs instead, which stretch each green between 5 and 50 seconds while vehicles keep arriving.
- MaxPressure is a well-studied adaptive algorithm. At each step it gives green to the phase whose movements have the biggest queue imbalance between approach and exit roads.
Everything ran in Eclipse SUMO (Simulation of Urban MObility), an open-source traffic simulator, on street networks extracted from OpenStreetMap: a grid of nine junctions in Bloomsbury, a complex junction at Old Street, and the Euston Road (A501) corridor.
How the controller works
When at least two vehicles are halted at a junction, the controller runs this loop every 10 simulated seconds; with fewer, MaxPressure just picks the phase.
- The junction describes its queues in a short text prompt, and the model replies with the next phase as JSON, for example
{"phase": 2}. I used four prompt styles: a basic one with queue counts only, a delay-aware one that adds how long vehicles have waited, one that adds predictions, and one that adds notes from neighbouring junctions. - A safety shield checks the reply. The model can only pick from the junction's conflict-free phases, so as long as that phase table is right it can't give green to conflicting movements. A missing or malformed reply gets MaxPressure's choice instead, and a fairness timer forces service to any phase that has been passed over for three decisions in a row. The shield guarantees only that the phase is legal, so a valid but poor choice is still served.
- Everything is logged: the junction state, both proposals and the served phase. Each entry is digitally signed with the junction's key and carries a fingerprint (hash) of the entry before it, so any edit shows up unless the editor also holds the key and rewrites everything after it. After a simulated collision, the log produces a cited evidence pack that maps the controller's actions to UK road-traffic rules; it doesn't assign fault. In seven test scenarios the checks passed, and a log re-signed with a fake key was always caught.
The results below therefore measure the whole loop (the model plus the shield's legality check, fallback and fairness timer), not the model alone.
The model runs on Foundry Local, which downloads, caches and serves models on your own machine behind an OpenAI-compatible endpoint (so the standard openai client works). I used version 0.8.119 on Windows with an NVIDIA RTX 2060 (6 GB), and in my setup the controller made no network calls while running.
Running it yourself
The first run needs no GPU, and SUMO installs from pip. You need Git and Python 3.11 to 3.13. On Windows, clone into a short folder such as C:\src.
git clone https://github.com/ebenezer-isaac/edge-traffic-negotiator
cd edge-traffic-negotiator
python -m venv .venv
.venv\Scripts\activate # Windows cmd or PowerShell (*)
# Git Bash: source .venv/Scripts/activate; macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
python run.py # MaxPressure on the calibrated Bloomsbury grid, about 10 seconds
(*) If PowerShell refuses to run the activation script, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned once and try again.
It prints the vehicles completed and the mean delay per vehicle (the average time each vehicle that entered spent in the network, normal travel time included). It also shows how often the simulator moved a stuck vehicle out of gridlock along its route (SUMO calls this teleporting) and whether the audit log's tamper checks pass:
vehicles completed 601 / 686
mean delay 158.0 s
teleports (gridlock) 0
audit entries 441 chain ok=True signatures ok=True merkle proof ok=True
An existing SUMO install set in SUMO_HOME gives slightly different numbers; unset it to match.
For the language-model runs you need a GPU and Foundry Local. On Windows, install the version I tested with winget install Microsoft.FoundryLocal --version 0.8.119.102; without --version you get the newest preview, which renamed some commands (foundry service status became foundry server status) and which I haven't tested. The quickest check uses a model from Foundry Local's catalog:
foundry model download phi-4-mini # about 3.7 GB
foundry model load phi-4-mini
python run.py --controller slm --model phi-4-mini --end 300 # 300 simulated seconds, a few minutes
If run.py can't reach the model, it says so and stops. To use the fine-tuned 0.6B model from this post (built on Alibaba's open Qwen3-0.6B), download it from Hugging Face (about 400 MB) into Foundry Local's cache folder.
foundry cache location # prints e.g. "Cache directory path: C:\Users\you\.cache\foundry-local"
pip install -U huggingface_hub
hf download ebnezr-isaac/qwen3-0.6b-traffic-signal-ft1 --exclude "lora/*" --local-dir "<cache folder>/qwen3-0.6b-ft1"
foundry model load qwen3-0.6b-ft1
python run.py --controller slm --model qwen3-0.6b-ft1 --end 300
For <cache folder>, use the path the first command prints. This snippet, run from the repository folder, sends one decision the way the controller does:
import sys; sys.path.insert(0, "src")
from openai import OpenAI
from slm_agent import SYSTEM_DELAY_AWARE, build_delay_aware_user, discover_endpoint
base_url, model_id = discover_endpoint("qwen3-0.6b-ft1") # finds Foundry Local's port
client = OpenAI(base_url=base_url, api_key="local")
prompt = build_delay_aware_user("J1", [
{"queue": 9, "waiting": 240, "current": True}, # phase 0: 9 cars, 240 s of waiting in total
{"queue": 2, "waiting": 30}, # phase 1
])
reply = client.chat.completions.create(
model=model_id, temperature=0, max_tokens=256,
messages=[{"role": "system", "content": SYSTEM_DELAY_AWARE + " /no_think"},
{"role": "user", "content": prompt}],
)
print(reply.choices[0].message.content)
Against the released weights it printed this on every run I tried:
<think>
</think>
{"phase": 0}
The /no_think suffix switches off Qwen3's reasoning mode, which adds latency; it still leaves an empty <think> block, so the controller extracts the JSON from the reply.
For the fine-tuned 0.6B model, the median decision time across my test settings was about 1.4 to 2.6 seconds, well inside the 10-second interval. For a production deployment I'd add a hard timeout that falls back to MaxPressure, to cover the occasional slow call.
Stock models, then fine-tuning
I summarise runs two ways. The per-run measure, used in the dissertation, pairs each run with the comparison controller's run on the same seed (the same simulated drivers), takes the percentage difference, and averages those. Mean delay averages each run's delay per vehicle across runs, then compares. They can disagree. If one of 30 baseline runs jams at 1,000 s instead of 100 s, the controller can be at most 100% better in that run, which moves the per-run average by about 3 points, but the baseline's mean delay rises by 30 s.
To serve my fine-tuned model, I converted it to an int4 ONNX build (Open Neural Network Exchange format, with weights stored at 4-bit precision). The catalog's build of the unmodified (stock) Qwen3-0.6B is quantised differently, so every fine-tuning comparison uses my own build of both.
Bloomsbury's traffic is generated at random by SUMO's trip generator rather than taken from counts, so its volume is a setting, not a measurement. I set it to the heaviest level I tried at which traffic still flows (about 87% of trips finish) and call that the calibrated Bloomsbury grid.
Stock catalog models did well out of the box at Old Street: seven of the eight set-ups I tried (four models, two prompt styles each) had lower delay than the fixed-time plan per run, six of them significantly, and the best three cut it by about 9 to 12%. On the calibrated Bloomsbury grid, though, the stock 0.6B model started 11.9% behind fixed-time per run, and that was the gap fine-tuning had to close.
To close it, I used distillation: a large model labels examples and a small model learns to copy them. The labeller was a Claude Sonnet model, chosen because in a small test it agreed most often with a simple rule that serves the longest-waiting traffic, and it labelled about 47,000 junction states from simulation. I fine-tuned Qwen3-0.6B with QLoRA, which trains a small set of add-on weights on top of a 4-bit copy of the model so that it fits in 6 GB. The model behind the results below learned from 14,493 of those examples, all in the delay-aware prompt style, and trained in about 4 hours on the same RTX 2060 that serves it. For comparison, I also fine-tuned Microsoft's Phi-4-mini (3.8 billion parameters) on a larger set covering all four prompt styles, which includes about 1,400 states labelled by another Claude model, Fable.
Figure 1. Change in delay per vehicle relative to the fixed-time plan (per-run measure) on the calibrated Bloomsbury grid, 30 simulation runs (seeds) per controller. The 18.3% in the text is measured against the stock model, not this baseline.
On the calibrated Bloomsbury grid (Figure 1), fine-tuning cut the 0.6B model's delay by 18.3% per run against the stock model (18.5% on mean delay), and it won in all 30 runs. The fine-tuned 0.6B and Phi-4-mini both came out level with MaxPressure (within 0.03% of its average delay) and 8.8% better than fixed-time on either measure, and they were indistinguishable from each other (the 95% uncertainty range on their difference stays within 0.4%).
The decision logs show how it got there. Before fine-tuning, the model's proposal was served in 59% of decisions, and in more than half of those it picked something MaxPressure wouldn't have; the fairness timer served the rest. After fine-tuning, its proposal was served in 76% of decisions, 96% of those matched MaxPressure's choice, and neither model ever needed the fallback for a bad reply. The labeller was chosen for agreeing with a simple longest-waiting rule, and the 0.6B model ended up close to MaxPressure, a related established rule.
A harder test: Euston Road
Euston Road (the A501) is a busy main road running close to capacity, and here the simulator's default plan is vehicle-actuated, so it already adapts its greens to arriving traffic. That makes it a tougher baseline than a fixed timetable. I first modelled the corridor at peak hour, scaled to the busiest hour of a one-day Department for Transport survey (07:00 to 19:00), which is about 6.3% of the road's published annual average daily flow. At that peak, the actuated plan came out ahead of every adaptive controller I ran, MaxPressure included, though for most of them the gap was within run-to-run noise. To see whether that held across a whole day, I needed real hourly demand.
Re-running Euston with hourly shapes from real TfL counts
Transport for London later sent me three weekdays (12 to 14 May 2026) of hourly vehicle counts from the Euston Road junctions, recorded by the road sensors of SCOOT, TfL's own adaptive signal system. The counts are spread across the day rather than bunched into peaks (Figure 2): the busiest hour carried 5.0% of the day's counted traffic, against the 6.3% my peak-hour model used (that figure is a share of annual average flow, so the comparison is approximate).
Figure 2. Hourly share of daily traffic on the Euston Road corridor, averaged over 12 to 14 May 2026. Source: aggregate SCOOT counts supplied by Transport for London under the Environmental Information Regulations. The dashed line is the busiest-hour share behind my peak-hour model.
SCOOT loop counts are reliable as a shape but not as absolute volumes, because one vehicle can cross several detector loops. I therefore took the hourly shape from TfL and the volume from the Department for Transport survey, calibrated as on Bloomsbury so that traffic keeps flowing.
| Controller | Mean delay per vehicle, averaged over the 72 runs | Hours, of 72, with less delay than the default plan | Vehicles finished per run |
|---|---|---|---|
| Default plan (vehicle-actuated) | 98.8 s | baseline | 455 |
| Fixed timetable (same timings, never stretched) | 96.4 s | 13 | 469 |
| MaxPressure | 110.6 s | 8 | 441 |
| Fine-tuned Qwen3-0.6B | 113.9 s | 6 | 434 |
| Fine-tuned Phi-4-mini | 113.3 s | 6 | 435 |
| Claude Sonnet 5.5, same loop (cloud, not deterministic) | 103.9 s | 6 | 451 |
Each controller ran once in each of the 72 hours (24 hours × 3 days), 20 simulated minutes per run. Lower delay is better.
The actuated plan led MaxPressure in 64 of the 72 hours. Both fine-tuned models tracked MaxPressure closely: when their own choice was served, it matched MaxPressure's about 95% of the time, and in a typical hour they finished within about 1% of it. On real demand shapes, as on Bloomsbury, the 0.6B and 3.8B models behaved alike.
As a reference point, I also ran Anthropic's Claude Sonnet 5.5 once through the same loop, via the Claude Code CLI. Most of the gap between it and the 0.6B model came from six hours in which the 0.6B model jammed (Claude jammed in three of them). In the other 66 hours the two were almost identical (98.2 s against 99.7 s), so on one run each, a model that fits on a 6 GB consumer GPU gave similar delay to the cloud model.
My best explanation for the remaining gap to the actuated plan is three design choices, each of which can be changed: the fine-tuned models learned a single-junction rule, every controller in these runs saw only its own junction, and the fairness timer stepped in nearly twice as often here as on Bloomsbury.
What this shows
In simulation, a 0.6B model can run a complete, auditable signal-control loop on a consumer GPU: it was fine-tuned on the same GPU that serves it, its typical decisions arrive well inside the 10-second interval, and in my setup it made no network calls. Every decision is checked for legality and recorded in a tamper-evident log that reconstructs what the controller did after a simulated collision. On the calibrated Bloomsbury grid, fine-tuning brought this small open model level with an established adaptive algorithm and with a model about six times its size.
Matching MaxPressure is the first bar, not the goal. MaxPressure is a formula over queue counts, whereas the model reads text. One of my prompt styles already passes in notes from neighbouring junctions, which the formula has no place for, and the same channel could carry incidents, roadworks or local policy. The next step is to make that extra context pay off, starting with what the neighbouring junctions are doing.
What I'd tell myself at the start
- Check early whether each scenario can clear at all. Gridlock can hide differences between controllers.
- Compare models built the same way, and check what your baseline really is. A simulator's default plan may already be adaptive.
- Report mean delay alongside per-run averages.
- Serve a fine-tuned model with exactly the prompt format it was trained on.
- Put a deterministic check and a hard timeout around anything a model outputs.
- Start small: on the calibrated Bloomsbury grid, the 0.6B model matched one about six times its size.
Where this work stops
All of this is simulation: there was no field trial, no hardware-in-the-loop test, no signal-safety certification and no comparison with SCOOT. The baselines are SUMO's default programs (fixed-time on Bloomsbury and Old Street, vehicle-actuated on Euston Road), and turning proportions (which way vehicles turn at each junction) are assumed. The 18.3% fine-tuning gain comes from one calibrated grid with one demand pattern, and the real-demand Euston runs were one 20-minute run per controller per hour, without significance testing.
The biggest limit is how the training data was labelled. Every example was labelled once, before training, by a teacher chosen for agreeing with a simple rule, and the model can only be as good as those labels. Its choices settled close to an established rule: on Bloomsbury, 96% of its served choices matched MaxPressure's. Labelling during training should make it more accurate. That means labelling the junction states that the model's own decisions lead to, and scoring each candidate phase by the delay it actually saves in a short simulated look-ahead, rather than copying a teacher's choice. I haven't run this yet, so it is the first thing I'd do next, along with showing the model the exit roads that MaxPressure already uses and testing more junctions.
Resources
- Code, results and the dissertation: github.com/ebenezer-isaac/edge-traffic-negotiator
- Fine-tuned models and the distillation dataset: huggingface.co/ebnezr-isaac
- Foundry Local documentation
Acknowledgements
Thanks to my academic supervisor at UCL and my industry supervisor at Microsoft, through UCL's Industry Exchange Network. Views and results are my own and aren't endorsed by Microsoft, UCL or Transport for London. The SCOOT vehicle counts were supplied by Transport for London in response to an information request handled under the Environmental Information Regulations (TfL ref. 1453-2627); the repository holds only aggregates derived from them. Map data © OpenStreetMap contributors, available under the Open Database Licence. Contains public sector information from the Department for Transport road traffic statistics, licensed under the Open Government Licence v3.0. Every number in this post traces to the simulation results and the derived TfL and DfT data in the repository.