A loading bay count is a static attribute; the time a driver actually spends waiting for one is not. The difference between them is queueing, and it is the single largest unmodelled component of urban delivery dwell time. This page covers turning bay counts into a usable dwell profile, as part of mapping node attributes for urban delivery zones within OSM Graph Architecture & Network Modeling.
The behaviour to capture is non-linear. A bay that is comfortable at ten in the morning is a fifteen-minute wait at eleven, and the transition between the two happens over a narrow band of utilisation.
When to use this approach
Model queueing where deliveries concentrate on shared infrastructure — retail streets, office districts, any site where several carriers use the same bays. Skip it where each stop has dedicated access, which covers most residential and industrial work.
The signal that it matters is a fleet whose planned service times are reliably met in the morning and reliably missed later. That pattern is utilisation, not driver behaviour, and no amount of adjusting the flat service time will fix it.
Implementation
A multi-server queue approximation is enough, and it needs three inputs per site.
# requires: math (stdlib)
import math
def expected_wait_s(bays: int, arrivals_per_hour: float,
service_s: float) -> float:
"""Approximate mean queue wait for a multi-bay site."""
if bays <= 0 or arrivals_per_hour <= 0:
return 0.0
service_rate = 3600.0 / service_s # vehicles per hour per bay
utilisation = arrivals_per_hour / (bays * service_rate)
if utilisation >= 0.99:
return 30 * 60.0 # saturated; cap rather than diverge
# Erlang-C style approximation: adequate for dispatch planning
a = arrivals_per_hour / service_rate
num = (a ** bays) / math.factorial(bays) * (1.0 / (1.0 - utilisation))
den = sum(a ** k / math.factorial(k) for k in range(bays)) + num
p_wait = num / den
return p_wait * service_s / (bays * (1.0 - utilisation))
Capping at saturation rather than letting the formula diverge is the practical necessity. Utilisation above one is physically meaningful — more vehicles arrive than the bays can serve — but the mean wait is then unbounded, and a dispatch model needs a finite number. Thirty minutes is a defensible cap because beyond it drivers stop waiting and double-park instead, which is a different behaviour the model is not trying to capture.
Arrival rate is the input most often got wrong, because the obvious source is your own manifest:
# requires: pandas (pip install pandas)
import pandas as pd
def arrival_profile(own_manifest: pd.DataFrame, site_id: str,
*, market_share: float = 0.3) -> dict[int, float]:
"""Hourly arrival rate at a shared site, scaled beyond one's own fleet."""
own = (
own_manifest[own_manifest["site_id"] == site_id]
.groupby(own_manifest["planned_arrival"].dt.hour)
.size()
)
# A shared bay is contended by every carrier, not only this one
return {int(h): float(n) / market_share for h, n in own.items()}
The market_share divisor is the whole point of the function. A carrier with thirty percent of deliveries on a street sees thirty percent of the arrivals, and modelling the bay as though only its own vehicles used it understates utilisation by more than a factor of three — which lands squarely in the steep part of the curve.
Storing the result as an hourly profile rather than a scalar is what lets a dispatch model use it:
# requires: none beyond the standard library
def dwell_profile(bays: int, arrivals: dict[int, float],
service_s: float) -> dict[int, int]:
"""Per-hour total dwell: service plus expected queue wait."""
return {
hour: int(round(service_s + expected_wait_s(bays, rate, service_s)))
for hour, rate in arrivals.items()
}
The profile also needs a rule for hours with no observations, which every real site has — overnight, on Sundays, and during whatever weeks the telemetry was incomplete. Interpolating across those gaps invents a queue that was never measured; carrying the nearest observed hour forward is defensible but tends to overstate quiet periods. The safer default is to fall back to the uncontended service time whenever an hour has fewer than a threshold number of observations, and to record which hours fell back. A dispatcher who can see that the eleven o’clock figure came from two hundred observations and the six o’clock figure from four will weight the two appropriately, whereas a profile that hides the distinction invites equal trust in both.
Key parameters and tuning
| Parameter | Recommended value | Notes |
|---|---|---|
bays |
operator or survey data | OSM capacity on a loading area is often absent or aspirational |
service_s |
measured from telemetry | Derive from matched dwell rather than assuming |
market_share |
measured per district | The single largest source of optimism when omitted |
| Saturation cap | 30 min | Beyond this drivers double-park, which is a different model |
| Profile granularity | hourly | Finer buckets need arrival data that rarely exists |
| Fallback for unknown sites | district median | Never zero — an unknown bay is not a free one |
| Refresh | quarterly | Arrival patterns shift with retail and office occupancy |
Integration points
Into node attributes. The hourly profile is stored on the delivery node alongside the other attributes in mapping node attributes for urban delivery zones, following the same three-state discipline: a site with no data gets a district median and an explicit unknown flag, never a zero.
Into the dispatch model. The profile becomes the service time in the VRP, indexed by the hour the vehicle is projected to arrive — which the time dimension already tracks. That is the mechanism described in modelling time windows in OR-Tools VRP, and it means a plan that schedules a contended site at eleven correctly costs more than the same site at nine.
From telemetry. Both service time and observed wait can be derived from matched traces, using the dwell column produced in filtering GPS noise before map matching. Comparing observed dwell against the model’s prediction per hour is the only honest calibration available.
Validation checklist
- Observed dwell tracks the profile by hour. Compare matched telemetry dwell against the predicted profile per hour. A constant offset means service time is miscalibrated; a shape mismatch means arrival rate is.
- Market share is measured, not assumed. Confirm the divisor came from an observation of total bay usage rather than a guess. It is the input the result is most sensitive to.
- Utilisation stays below one for most hours. A site above one for several hours is genuinely under-provisioned, and the model should surface that rather than absorb it in a cap.
- Unknown sites get a median, not a zero. Assert that no site carries a dwell of zero. A zero means a free instant delivery, which the solver will happily schedule dozens of.
- The profile reaches the solver. Confirm the dispatch model is indexing service time by projected arrival hour rather than taking the mean of the profile.
Related
- Mapping node attributes for urban delivery zones — the attribute pipeline this profile is stored in
- Optimizing node attributes for last-mile routing — normalising dwell and penalties into solver-ready cost scalars
- Modelling time windows in OR-Tools VRP — where an hourly service time is consumed by the time dimension
- Filtering GPS noise before map matching — producing the observed dwell that calibrates this model