Ride-Share Surge Control

Dynamic pricing stabilises rider waits—until high gain and delay turn it into surge whiplash.

Level: Intermediate

queuepricingelasticitybalancing-loopdelayoscillation

  • Stocks: Riders Waiting, Idle Drivers, Drivers on Trip
  • Flows: Passenger Arrivals, Driver Logins, Matches, Trip Completions, Driver Logouts
  • Feedback Loops: Wait-driven surge (balancing), High-gain with delay (reinforcing oscillation)
  • Probes: price_multiplier, wait_estimate, riders_waiting, idle_drivers, arrival_rate, driver_logins, matches_per_min
simulation.py

Ride-share surge control with delayed driver response

Story. Dynamic pricing moderates a queue of waiting riders while drivers log in and out with a lag. Explore how close-to-critical gains keep waits near target, no-surge freezes during a shock, and high gain tips into whiplash.

What to watch. Track the price multiplier, estimated wait, queue length, idle supply, and login/arrival rates. These traces illustrate the balancing loop and how delay can amplify or damp oscillations.


from tys import probe, progress


def simulate(cfg: dict):
    import math
    import random
    from collections import deque

    import simpy

    env = simpy.Environment()

Load scenario parameters

Pull knobs and switches from the YAML config so the Playground presets can demonstrate smooth control, no surge, and whiplash behaviour.

    horizon = int(cfg.get("time_horizon", 240))
    seed = int(cfg.get("seed", 0))
    rng = random.Random(seed)

    base_demand = float(cfg["base_demand"])
    demand_elasticity = float(cfg["demand_elasticity"])
    supply_elasticity = float(cfg["supply_elasticity"])
    trip_time = float(cfg["trip_time"])
    baseline_active = float(cfg["baseline_active_drivers"])

    target_wait = float(cfg.get("target_wait", 3.0))
    wait_window = int(cfg.get("wait_window", 10))

    price_gain = float(cfg.get("price_gain", 0.0))
    price_cap = float(cfg.get("price_cap", 3.0))
    smoothing_alpha = float(cfg.get("smoothing_alpha", 0.0))

    initial_price = float(cfg.get("initial_price", 1.0))
    price_delay = int(cfg.get("price_delay_minutes", 0))
    supply_tau = float(cfg.get("supply_time_constant", 1.0))

    shock_start = int(cfg.get("shock_start", 0))
    shock_end = int(cfg.get("shock_end", 0))
    shock_multiplier = float(cfg.get("shock_multiplier", 1.0))

    assert wait_window > 0, "wait_window must be positive"
    assert supply_tau > 0, "supply_time_constant must be positive"
    assert price_cap >= 1.0, "price_cap must be at least 1.0"

Helper: Poisson arrivals

Demand is noisy. Sampling a Poisson count keeps wait spikes from being perfectly predictable, highlighting how the controller responds.

    def poisson(lam: float) -> int:
        """Sample a Poisson random variate with mean ``lam``."""
        if lam <= 0:
            return 0
        L = math.exp(-lam)
        k = 0
        p = 1.0
        while p > L:
            k += 1
            p *= rng.random()
        return k - 1

    price = max(1.0, initial_price)
    price_history = deque([price] * (max(price_delay, 0) + 1), maxlen=max(price_delay, 0) + 1)

    queue = 0.0
    idle_drivers = baseline_active
    engaged_drivers = 0.0

    match_history = deque([base_demand] * wait_window, maxlen=wait_window)

    total_matches = 0.0
    wait_samples = []
    price_samples = []

Recorder process

Streams the core stocks to the Playground charting layer once per minute.

    def recorder():
        while True:
            probe("riders_waiting", env.now, queue)
            probe("idle_drivers", env.now, idle_drivers)
            probe("drivers_on_trip", env.now, engaged_drivers)
            yield env.timeout(1)

    env.process(recorder())
    done = env.event()

    def dynamics():
        nonlocal price, queue, idle_drivers, engaged_drivers, total_matches

        for minute in range(horizon):
            shock = shock_multiplier if shock_start <= minute < shock_end else 1.0
            arrival_rate = max(0.0, base_demand * (price ** demand_elasticity) * shock)
            arrivals = poisson(arrival_rate)
            queue += arrivals

Supply feedback with delay

Drivers respond to the previous surge level; a long delay turns an otherwise stabilising loop into a potential oscillation.

            price_for_supply = price_history[0]
            target_active = baseline_active * (price_for_supply ** supply_elasticity)
            active_total = idle_drivers + engaged_drivers
            delta_active = (target_active - active_total) / supply_tau
            proposed_active = max(active_total + delta_active, 0.0)
            change_active = proposed_active - active_total

            if change_active >= 0:
                driver_logins = change_active
                driver_logouts = 0.0
                idle_drivers += driver_logins
            else:
                driver_logins = 0.0
                driver_logouts = min(-change_active, idle_drivers)
                idle_drivers -= driver_logouts
                proposed_active = idle_drivers + engaged_drivers
                change_active = proposed_active - active_total

Dispatch riders

Queueing riders pull from the idle pool until either riders or drivers run out.

            capacity = min(idle_drivers, queue)
            matches = max(0.0, capacity)
            queue -= matches
            idle_drivers -= matches
            engaged_drivers += matches
            total_matches += matches

Trip completions

Engaged drivers drift back to idle at the average trip duration, restoring capacity after the surge catches up.

            completions = engaged_drivers / trip_time if trip_time > 0 else engaged_drivers
            completions = min(completions, engaged_drivers)
            engaged_drivers -= completions
            idle_drivers += completions

Update control law

Little's Law provides a smoothed wait estimate. The proportional controller nudges surge up or down relative to the target wait.

            match_history.append(max(matches, 1e-6))
            avg_service = max(sum(match_history) / len(match_history), 1e-6)
            wait_estimate = queue / avg_service

            error = (wait_estimate - target_wait) / max(target_wait, 1e-6)
            price_raw = price + price_gain * error
            price_raw = min(max(price_raw, 1.0), price_cap)
            if 0.0 < smoothing_alpha < 1.0:
                price_next = (1 - smoothing_alpha) * price + smoothing_alpha * price_raw
            else:
                price_next = price_raw

            price_history.append(price_next)

Record telemetry

These probes power the charts suggested in the example write-up.

            probe("price_multiplier", env.now, price)
            probe("wait_estimate", env.now, wait_estimate)
            probe("arrival_rate", env.now, arrival_rate)
            probe("driver_logins", env.now, driver_logins)
            probe("driver_logouts", env.now, driver_logouts)
            probe("matches_per_min", env.now, matches)
            probe("trip_completions", env.now, completions)

            price_samples.append(price)
            wait_samples.append(wait_estimate)

            if minute == shock_start:
                progress(int(100 * minute / horizon), "🌧️ Demand shock hits")
            if minute == shock_end:
                progress(int(100 * minute / horizon), "☀️ Demand normalises")
            if price_next >= price_cap * 0.95:
                progress(int(100 * minute / horizon), "⚠️ Surge near cap")
            if wait_estimate > 2 * target_wait:
                progress(int(100 * minute / horizon), "⏱️ Waits running hot")

            price = price_next
            yield env.timeout(1)

        progress(100)

Summarise outcomes

Post-run statistics feed the sidebar so players can compare presets on average wait, surge volatility, and throughput.

        sorted_waits = sorted(wait_samples)

        def percentile(data, pct):
            if not data:
                return 0.0
            k = (len(data) - 1) * pct
            f = math.floor(k)
            c = math.ceil(k)
            if f == c:
                return data[int(k)]
            return data[f] * (c - k) + data[c] * (k - f)

        avg_wait = sum(wait_samples) / len(wait_samples) if wait_samples else 0.0
        avg_price = sum(price_samples) / len(price_samples) if price_samples else price
        price_variance = sum((p - avg_price) ** 2 for p in price_samples) / len(price_samples) if price_samples else 0.0

        done.succeed({
            "avg_wait_minutes": avg_wait,
            "p95_wait_minutes": percentile(sorted_waits, 0.95),
            "peak_wait_minutes": max(wait_samples) if wait_samples else 0.0,
            "avg_price_multiplier": avg_price,
            "price_volatility": math.sqrt(price_variance),
            "completed_trips": total_matches,
        })

    env.process(dynamics())
    env.run(until=done)
    return done.value


def requirements():
    """Dependencies needed for this simulation."""
    return {
        "external": ["simpy==4.1.1"],
    }
Balanced Surge.yaml
seed: 42
time_horizon: 240
initial_price: 1.0
target_wait: 3.0
wait_window: 10
base_demand: 2.2
demand_elasticity: -1.1
supply_elasticity: 0.8
trip_time: 12.0
baseline_active_drivers: 32.0
price_delay_minutes: 10
supply_time_constant: 20.0
price_gain: 0.35
price_cap: 2.5
smoothing_alpha: 0.25
shock_start: 60
shock_end: 120
shock_multiplier: 1.4
Charts (Balanced Surge)

riders_waiting

riders_waiting chart
Samples241 @ 0.00–240.00
Valuesmin 0.00, mean 1.71, median 0.00, max 12.45, σ 3.27

idle_drivers

idle_drivers chart
Samples241 @ 0.00–240.00
Valuesmin 2.67, mean 8.51, median 7.70, max 32.00, σ 5.96

drivers_on_trip

drivers_on_trip chart
Samples241 @ 0.00–240.00
Valuesmin 0.00, mean 24.15, median 24.74, max 33.38, σ 6.41

price_multiplier

price_multiplier chart
Samples240 @ 0.00–239.00
Valuesmin 1.00, mean 1.03, median 1.00, max 1.34, σ 0.07

wait_estimate

wait_estimate chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 0.61, median 0.00, max 4.67, σ 1.19

arrival_rate

arrival_rate chart
Samples240 @ 0.00–239.00
Valuesmin 2.10, mean 2.35, median 2.20, max 3.08, σ 0.30

driver_logins

driver_logins chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 0.02, median 0.00, max 0.26, σ 0.06

driver_logouts

driver_logouts chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 0.02, median 0.00, max 0.16, σ 0.04

matches_per_min

matches_per_min chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 2.33, median 2.67, max 8.00, σ 1.33

trip_completions

trip_completions chart
Samples240 @ 0.00–239.00
Valuesmin 0.08, mean 2.20, median 2.25, max 3.03, σ 0.57
Final Results (Balanced Surge)
MetricValue
avg_wait_minutes0.61
p95_wait_minutes3.60
peak_wait_minutes4.67
avg_price_multiplier1.03
price_volatility0.07
completed_trips558.50
No Surge (k=0).yaml
seed: 42
time_horizon: 240
initial_price: 1.0
target_wait: 3.0
wait_window: 10
base_demand: 2.2
demand_elasticity: -1.1
supply_elasticity: 0.8
trip_time: 12.0
baseline_active_drivers: 32.0
price_delay_minutes: 10
supply_time_constant: 20.0
price_gain: 0.0
price_cap: 2.5
smoothing_alpha: 0.0
shock_start: 60
shock_end: 120
shock_multiplier: 1.4
Charts (No Surge (k=0))

riders_waiting

riders_waiting chart
Samples241 @ 0.00–240.00
Valuesmin 0.00, mean 7.91, median 1.47, max 33.12, σ 10.67

idle_drivers

idle_drivers chart
Samples241 @ 0.00–240.00
Valuesmin 2.67, mean 6.68, median 2.67, max 32.00, σ 6.19

drivers_on_trip

drivers_on_trip chart
Samples241 @ 0.00–240.00
Valuesmin 0.00, mean 25.32, median 29.33, max 29.33, σ 6.19

price_multiplier

price_multiplier chart
Samples240 @ 0.00–239.00
Valuesmin 1.00, mean 1.00, median 1.00, max 1.00, σ 0.00

wait_estimate

wait_estimate chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 2.96, median 0.61, max 12.42, σ 4.01

arrival_rate

arrival_rate chart
Samples240 @ 0.00–239.00
Valuesmin 2.20, mean 2.42, median 2.20, max 3.08, σ 0.38

driver_logins

driver_logins chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 0.00, median 0.00, max 0.00, σ 0.00

driver_logouts

driver_logouts chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 0.00, median 0.00, max 0.00, σ 0.00

matches_per_min

matches_per_min chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 2.43, median 2.67, max 6.00, σ 1.05

trip_completions

trip_completions chart
Samples240 @ 0.00–239.00
Valuesmin 0.08, mean 2.31, median 2.67, max 2.67, σ 0.54
Final Results (No Surge (k=0))
MetricValue
avg_wait_minutes2.96
p95_wait_minutes11.18
peak_wait_minutes12.42
avg_price_multiplier1.00
price_volatility0.00
completed_trips584.04
High-Gain Surge.yaml
seed: 42
time_horizon: 240
initial_price: 1.0
target_wait: 3.0
wait_window: 10
base_demand: 2.2
demand_elasticity: -1.1
supply_elasticity: 0.8
trip_time: 12.0
baseline_active_drivers: 32.0
price_delay_minutes: 10
supply_time_constant: 20.0
price_gain: 0.7
price_cap: 3.0
smoothing_alpha: 0.25
shock_start: 60
shock_end: 120
shock_multiplier: 1.4
Charts (High-Gain Surge)

riders_waiting

riders_waiting chart
Samples241 @ 0.00–240.00
Valuesmin 0.00, mean 1.74, median 0.00, max 11.62, σ 3.15

idle_drivers

idle_drivers chart
Samples241 @ 0.00–240.00
Valuesmin 2.67, mean 8.42, median 7.61, max 32.00, σ 5.98

drivers_on_trip

drivers_on_trip chart
Samples241 @ 0.00–240.00
Valuesmin 0.00, mean 24.32, median 24.97, max 33.97, σ 6.54

price_multiplier

price_multiplier chart
Samples240 @ 0.00–239.00
Valuesmin 1.00, mean 1.03, median 1.00, max 1.39, σ 0.09

wait_estimate

wait_estimate chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 0.61, median 0.00, max 4.30, σ 1.13

arrival_rate

arrival_rate chart
Samples240 @ 0.00–239.00
Valuesmin 1.99, mean 2.34, median 2.20, max 3.08, σ 0.30

driver_logins

driver_logins chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 0.02, median 0.00, max 0.37, σ 0.07

driver_logouts

driver_logouts chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 0.02, median 0.00, max 0.18, σ 0.04

matches_per_min

matches_per_min chart
Samples240 @ 0.00–239.00
Valuesmin 0.00, mean 2.34, median 2.67, max 8.00, σ 1.33

trip_completions

trip_completions chart
Samples240 @ 0.00–239.00
Valuesmin 0.08, mean 2.22, median 2.27, max 3.09, σ 0.58
Final Results (High-Gain Surge)
MetricValue
avg_wait_minutes0.61
p95_wait_minutes3.30
peak_wait_minutes4.30
avg_price_multiplier1.03
price_volatility0.09
completed_trips562.17
FAQ
What sets the tipping point between smooth control and oscillation?
It's the product of price gain, supply delay and sensitivity. When their product grows too large the balancing loop crosses the damping boundary and the system rings.
Why do waits spike even with surge enabled?
Demand reacts immediately to price, but additional drivers arrive with a lag. During the shock the fleet is temporarily capacity constrained until supply catches up.
Does higher surge always reduce waits?
No. Beyond the edge, a higher gain over-suppresses demand, then overshoots supply, creating whiplash and higher wait variance.
What would stabilise a whiplashy system?
Lower the gain, add smoothing, cap price changes, or shorten the supply delay with tactics like pre-positioning or scheduled incentives.
What real-world analogues fit this pattern?
Any price-as-controller with delayed capacity—electricity markets with generator start-up lags, delivery platforms, even cloud spot pricing.