Measurements
The numbers behind each decision, from the cost of one simulation to the speed of a map on a phone. For whether the simulator matches real crowds, see the validation report. The weights and the simulated bends are open: cfdu and aporia-bends on Hugging Face.
Simulation cost and corridor capacity · 25–26 Oct
One bend takes a fraction of a second at the default settings (90°, 2 m, q = 0.8: 0.24 s; straight 4 m, q = 1.3: 0.46 s), far below the 30 s budget. The final dataset's runs are longer and denser: median 22 s per run, p99 15 min, max 50 min.
Capacity is the flow a straight corridor carries before arrivals are refused. With JuPedSim's default time gap of 1 s it is about 1.0 ped/(m·s), below real corridors (≈ 1.2 in Weidmann's compilation, higher at Jülich). The calibrated T = 0.7 s brings it into range.
| Width | Free flow up to q, default | Overload outflow, default | Free flow up to q, calibrated | Overload outflow, calibrated |
|---|---|---|---|---|
| 1.5 m | 0.8 | ≈ 0.96 | 1.3 | 1.32 |
| 2.0 m | 0.9 | ≈ 1.04 | 1.4 | 1.49 |
| 4.0 m | 0.9 | ≈ 1.04 | 1.3 | 1.46 |
Capacity against time gap · 2 m corridor drained from a 3 ped/m² reservoir, exploratory
Decision. Calibrate the time gap on the measured speed–density relation, not on capacity alone (done, see the report), and accept a bend only if its own run shows zero refusals and passes every check: a straight corridor's capacity does not guarantee a bend's.
The training dataset · 27 Oct
1,500 bends drawn by Latin hypercube: angle 30–150°, width 1.5–4 m, chamfer 0–3.9 m, flow 0.1–0.9 ped/(m·s). Two seeds each, 3,000 runs. A bend is accepted when both seeds pass every check. A first version stopped each run at its first pass; the second observes every run for a fixed 5,400 s.
| v1 (superseded) | v2 (training set) | |
|---|---|---|
| Observation | 160 → 1,200 s, first pass | 5,400 s fixed, from 600 s |
| Accepted | 1,444 / 1,500 (96.3 %) | 1,466 / 1,483 in domain (98.9 %) |
| Noise floor, median | 0.0075 ped/m² | 0.0021 ped/m² |
| Peak density, median / max | 0.65 / 1.90 | 0.63 / 1.75 |
| Compute, size | 0.5 h on 10 cores, 3.4 GB | 4 h 52 on 9 workers, 18 GB |
- The 48 bends v1 could not resolve (noisy inner-corner regions) are now 47 accepted, 1 excluded.
- Tolerances were not loosened after seeing rejections. Two narrow bends (q ≈ 0.89) fail on a single refused arrival in 5,400 s; left as is.
- Three bends (θ 119–129°, w 3.1–3.8 m, q ≈ 0.8) are bistable: both seeds pass but settle in different states (peaks 1.4 vs 1.9–2.0). Their target is the mean of two regimes, and their noise floor says so.
Splits, accepted bends · by geometry, angles above 120° held out entirely · download
CFD-U accuracy · 27 Oct
A U-Net (32-64-128-256 channels, 1.9 M parameters), 300 epochs, 6.5 min on an RTX 2060. Trained three times with different seeds; the app uses the pixel-wise mean of the three. Compared with two shortcuts: a continuity formula (flow / speed, v = 1.35 m/s) and the nearest training bend.
| Method | RMSE, ped/m² | Peak error | Fruin level right | IoU, level ≥ D |
|---|---|---|---|---|
| Continuity formula | 0.200 / 0.248 | 39 % / 51 % | 23 % / 16 % | 0.00 / 0.00 |
| Nearest training bend | 0.158 / 0.307 | 10 % / 17 % | 84 % / 73 % | 0.41 / 0.11 |
| CFD-U, seed 0 only | 0.0081 / 0.108 | 1.3 % / 18.7 % | 98 % / 72 % | 0.85 / 0.48 |
| CFD-U, mean of 3 seeds (app) | 0.0080 / 0.077 | 1.2 % / 8.5 % | 99 % / 83 % | 0.87 / 0.58 |
| Simulator noise floor | 0.0023 / 0.0079 | – | – | – |
Within the training range, the map is solved to near the noise: 3.5× the floor, the Fruin level right in 99 % of bends. The error is highest at 90–120° (RMSE 0.011) against 0.005–0.006 below 90°.

Beyond 120°: where it stops being right · 27 Oct
Angles above 120° were never seen in training. The error grows with the angle, and with the load within each angle. Up to about 130° the network stays reliable; beyond 135° its maps are still twice as close as the nearest bend, but it underestimates the peak, systematically, on every seed.
Map RMSE by angle · ped/m², 120–150° test
Median peak bias · CFD-U, below 0 = too low
Fruin level right · share of bends
Over the whole 120–150° test the Fruin level is off by one or two in about one bend in six. The app flags every bend above 120° as out of domain and quotes these numbers; low disagreement between seeds never clears the warning. Angles above 135° are not shown to be unrealistic, only beyond the validation's evidence (Hannun et al. stop at 135°).
More data, more seeds · 27 Oct
The same model trained on random subsets of the training set, and the full model retrained with three seeds. Every run converged (best epoch 189–277 of 300).
RMSE, 30–120° test · ped/m²
RMSE, 120–150° test · ped/m²
- Within range, more data still helps. 600 → 896 bends cut the error by a third. More training bends at 30–120° would still pay.
- Beyond 130°, it does not. The smaller runs sit within the full run's seed spread (0.082–0.108): the training seed matters more than the amount of data there.
- Averaging three seeds gives the lowest pooled error (0.0080 / 0.077) and removes the dependence on one training draw, without beating the best seed on every metric. It was chosen after seeing test results; later confirmatory claims need fresh bends.
- Seed disagreement predicts the error (correlation 0.90 within range, 0.92 beyond, 0.80–0.95 within each 5° bin), but it is not calibrated: the error is typically 1.5× the spread. The app shows it as an experimental “model disagreement”, without error bars.
The chamfer · 27 Oct
Cutting the inner corner, from 0 to 3 m, at width 3 m and q = 0.7 ped/(m·s). The network swept in 0.1 m steps; 24 check runs simulated with the dataset's protocol, all passing.

| Angle | Chamfer 0 m | 1 m | 2 m | 3 m | Effect, simulation |
|---|---|---|---|---|---|
| 60° | 0.850 / 0.838 | 0.828 / 0.820 | 0.823 / 0.817 | 0.834 / 0.821 | −3 % (0 → 2 m) |
| 90° | 0.965 / 0.948 | 0.967 / 0.960 | 0.941 / 0.939 | 0.944 / 0.930 | −2.5 % (0 → 2 m) |
| 120° | 1.064 / 1.080 | 1.130 / 1.092 | 1.106 / 1.091 | 1.105 / 1.067 | +4 to +6 % |
- The angle sets the peak, the chamfer barely moves it, and not always down: at 120°, 1–3 m of chamfer raise it by 4–6 %. CFD-U matches the check points to 1.5 % on average (max 3.4 %) and misses that rise, which is at the limit of its error.
- Across the 1,119 in-range simulations, adding the chamfer to a fit of log(peak) on angle, width and load lowers the residual only from 0.0604 to 0.0599.
- For the peak alone, a five-term regression is nearly as good: 1.85 % mean error against 1.46 % for CFD-U, on the 47 test bends of the app's range. The network's added value is the map: where the load concentrates.
In the browser · 27 Oct
The three seeds are merged into one ONNX graph (float32, 22.2 MB) run by onnxruntime-web: WebGPU when it passes a self-check against a reference map, WebAssembly otherwise. Warm median of 20 runs, all three seeds per run. The same weights are on Hugging Face.
Time per map · warm median, ms, lower is better
Bold: what the app uses on each device. Laptop: Apple Silicon, Chromium 152. iPhone: Safari 27. Galaxy: Chrome 156, which offers no WebGPU there. × n: WebAssembly threads, which need cross-origin isolation.
| Download | Raw | gzip | brotli |
|---|---|---|---|
| Model, float32 (default) | 22.2 MB | 20.5 MB | ~20 MB |
| Model, float16 weights | 11.2 MB | 10.2 MB | ~9 MB |
| Runtime, WebGPU + WASM fallback | 27.4 MB | 6.8 MB | ~3 MB |
- Same maps everywhere. On every device and backend, the browser's raster is bit-identical to Python's and the outputs are within 7 × 10⁻⁷ ped/m² of Python onnxruntime.
- Float16 stays off. It halves the download but is not faster (the arithmetic stays float32), sometimes slower, and differs by up to 3.5 × 10⁻⁴, above the 10⁻⁴ parity tolerance.
- Two overlapping runs stall onnxruntime-web on WebGPU; the worker now serialises them.
Geometry parity · 26 Oct
The app rebuilds each bend in JavaScript, independently of Python, and the network sees five raster channels of it. Distances are measured to the full wall boundary, including walls outside the 24 m crop, and window snapping uses the same rounding in both languages. 144 cases are compared as float32 bytes: identical.
uv run pytest tests/test_rasterize.py tests/test_rasterize_js.py