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.

Wait time is non-linear in utilisation Expected wait for a bay is close to zero up to about sixty percent utilisation, reaches a few minutes at eighty, and exceeds twenty minutes above ninety-five. A flat service-time allowance is wrong at both ends of that curve. Expected wait for a bay, 3-bay site, 12-minute service 25 min 0 0 % 60 % 85 % 98 % flat 3-minute allowance real wait morning: allowance is generous midday: allowance is out by 15 minutes One number cannot fit both ends. The fix is an hourly profile, not a better average.

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.

An hourly dwell profile against a flat allowance Total dwell at a three-bay retail site is about 14 minutes early morning, rises to 27 by late morning as arrivals peak, falls back after two, and rises again slightly in the late afternoon. A flat 16-minute allowance is wrong for most of the day. Total dwell by hour, 3-bay retail site 30 min 0 07:00 11:00 15:00 19:00 flat 16-minute allowance late-morning peak — 27 min A plan built on the flat allowance runs early before ten and late from ten to one, every day, on the same stops. Dispatchers usually notice the pattern long before anyone suspects the service-time model. The market-share divisor decides the answer Counting only one carrier’s own deliveries gives four arrivals an hour and a comfortable utilisation of 0.44. Scaling by a thirty percent market share gives thirteen and a utilisation of 0.87, which a direct observation at the bay confirms. Where the arrival-rate estimate comes from own manifest only 4 arrivals/hour utilisation reads 0.44 scaled by market share 13 arrivals/hour utilisation reads 0.87 observed at the bay 12 arrivals/hour the scaling was close 0.44 and 0.87 sit on opposite sides of the knee — one predicts no wait at all, the other predicts several minutes. Where market share is unknown, one afternoon of counting at the busiest site is enough to calibrate the whole district.

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.