Spaces:
Runtime error
Runtime error
| # /// script | |
| # requires-python = ">=3.13" | |
| # dependencies = [ | |
| # "altair", | |
| # "asimpy", | |
| # "marimo", | |
| # "polars==1.24.0", | |
| # ] | |
| # /// | |
| import marimo | |
| __generated_with = "0.20.4" | |
| app = marimo.App(width="medium") | |
| def _(): | |
| import marimo as mo | |
| import random | |
| import statistics | |
| import altair as alt | |
| import polars as pl | |
| from asimpy import Environment, Process, Resource | |
| return Environment, Process, Resource, alt, mo, pl, random, statistics | |
| def _(mo): | |
| mo.md(r""" | |
| # Little's Law | |
| ## *The Universal Conservation Law of Queues* | |
| Little's Law states that in a stable system, L = λW, where: | |
| - L = mean number of customers in the system | |
| - λ = mean arrival rate | |
| - W = mean time a customer spends in the system | |
| The law holds regardless of arrival or service distributions, number of | |
| servers, or scheduling discipline. | |
| """) | |
| return | |
| def _(mo): | |
| mo.md(r""" | |
| ## Why It Is Surprising | |
| Little's Law holds without any assumptions about the distribution of arrival rates or service times. It does not matter whether arrivals are Poisson, deterministic, or correlated, whether service times are exponential, constant, or heavy-tailed, whether there is one server or a hundred, or what scheduling discipline is used (FIFO, LIFO, random, or priority). As long as the system is stable and stationary, $L = \lambda W$. This universality is remarkable because almost every other formula in queueing theory *does* depend on distributional assumptions. | |
| """) | |
| return | |
| def _(mo): | |
| mo.md(r""" | |
| ## Practical Use | |
| Because $L = \lambda W$ is universal, it can be used to measure hard-to-observe quantities from easy-to-observe ones. For example, the mean number of requests in a web server ($L$) and the observed request rate ($\lambda$) immediately give the mean response time ($W = L/\lambda$) without needing to instrument individual request latencies. | |
| """) | |
| return | |
| def _(mo): | |
| mo.md(r""" | |
| ## Proof Sketch | |
| Consider a flow diagram where time runs horizontally and each customer traces a horizontal line from arrival to departure. The area under all lines equals both: | |
| - $\sum_i W_i$ (sum of individual sojourn times), and | |
| - $\int_0^T L(t)\,dt$ (integral of instantaneous queue length). | |
| Dividing both sides by $T$ and taking $T \to \infty$: | |
| $$\bar{L} = \lambda \bar{W}$$ | |
| The argument is purely combinatorial: no probability is needed. | |
| """) | |
| return | |
| def _(mo): | |
| mo.md(r""" | |
| ## Simulation Design | |
| The simulation verifies Little's Law across three configurations: | |
| - M/M/1 (Poisson arrivals, exponential service, one server) | |
| - M/D/1 (Poisson arrivals, deterministic service, one server) | |
| - M/M/3 (Poisson arrivals, exponential service, three servers) | |
| For each configuration, $L$ is measured two ways: by direct sampling of the | |
| queue length, and by computing $\lambda W$ from observed throughput and mean | |
| sojourn time. | |
| The scenarios below sweep arrival rate $\lambda$ over (0.5, 1.0, 1.5, 2.0, 2.5) | |
| at server capacities 2, 3, and 4. The `error_%` column shows how closely | |
| $L_{\text{Little}} = \lambda W$ matches the directly sampled $L_{\text{direct}}$. | |
| """) | |
| return | |
| def _(): | |
| SEED = 192 # random seed for reproducibility | |
| SIM_TIME = 1000 # simulated time units per scenario | |
| SAMPLE_INTERVAL = 1 # sim-time units between Monitor samples | |
| SERVICE_RATE = 1.0 # exponential service rate (mu) for random service | |
| return SAMPLE_INTERVAL, SEED, SERVICE_RATE, SIM_TIME | |
| def _(mo): | |
| mo.md(r""" | |
| ## Random Processes | |
| """) | |
| return | |
| def _(Process, SERVICE_RATE, random): | |
| class RandomCustomer(Process): | |
| def init(self, server, in_system, sojourn_times): | |
| self.server = server | |
| self.in_system = in_system | |
| self.sojourn_times = sojourn_times | |
| async def run(self): | |
| arrival = self.now | |
| self.in_system[0] += 1 | |
| async with self.server: | |
| await self.timeout(random.expovariate(SERVICE_RATE)) | |
| self.in_system[0] -= 1 | |
| self.sojourn_times.append(self.now - arrival) | |
| return (RandomCustomer,) | |
| def _(Process, RandomCustomer, random): | |
| class RandomArrivals(Process): | |
| def init(self, rate, server, in_system, sojourn_times): | |
| self.rate = rate | |
| self.server = server | |
| self.in_system = in_system | |
| self.sojourn_times = sojourn_times | |
| async def run(self): | |
| while True: | |
| await self.timeout(random.expovariate(self.rate)) | |
| RandomCustomer(self._env, self.server, self.in_system, self.sojourn_times) | |
| return (RandomArrivals,) | |
| def _(mo): | |
| mo.md(r""" | |
| ## Deterministic Processes | |
| """) | |
| return | |
| def _(Process): | |
| DETERMINISTIC_SERVICE = 1.0 # fixed service time for M/D/1 scenarios | |
| class DeterministicCustomer(Process): | |
| def init(self, server, in_system, sojourn_times): | |
| self.server = server | |
| self.in_system = in_system | |
| self.sojourn_times = sojourn_times | |
| async def run(self): | |
| arrival = self.now | |
| self.in_system[0] += 1 | |
| async with self.server: | |
| await self.timeout(DETERMINISTIC_SERVICE) | |
| self.in_system[0] -= 1 | |
| self.sojourn_times.append(self.now - arrival) | |
| return DETERMINISTIC_SERVICE, DeterministicCustomer | |
| def _(DeterministicCustomer, Process, random): | |
| class DeterministicArrivals(Process): | |
| def init(self, rate, server, in_system, sojourn_times): | |
| self.rate = rate | |
| self.server = server | |
| self.in_system = in_system | |
| self.sojourn_times = sojourn_times | |
| async def run(self): | |
| while True: | |
| await self.timeout(random.expovariate(self.rate)) | |
| DeterministicCustomer(self._env, self.server, self.in_system, self.sojourn_times) | |
| return (DeterministicArrivals,) | |
| def _(Process, SAMPLE_INTERVAL): | |
| class Monitor(Process): | |
| def init(self, in_system, samples): | |
| self.in_system = in_system | |
| self.samples = samples | |
| async def run(self): | |
| while True: | |
| self.samples.append(self.in_system[0]) | |
| await self.timeout(SAMPLE_INTERVAL) | |
| return (Monitor,) | |
| def _(Environment, Monitor, Resource, SIM_TIME, statistics): | |
| def run_scenario(lam, capacity, arrivals_cls): | |
| in_system = [0] | |
| sojourns = [] | |
| samples = [] | |
| env = Environment() | |
| server = Resource(env, capacity=capacity) | |
| arrivals_cls(env, lam, server, in_system, sojourns) | |
| Monitor(env, in_system, samples) | |
| env.run(until=SIM_TIME) | |
| L_direct = statistics.mean(samples) | |
| W = statistics.mean(sojourns) | |
| lam_obs = len(sojourns) / SIM_TIME | |
| L_little = lam_obs * W | |
| error = 100.0 * (L_little - L_direct) / L_direct | |
| return { | |
| "lambda": round(lam_obs, 3), | |
| "capacity": capacity, | |
| "W": round(W, 3), | |
| "L_direct": round(L_direct, 3), | |
| "L_little": round(L_little, 3), | |
| "error_%": round(error, 2), | |
| } | |
| return (run_scenario,) | |
| def _(mo): | |
| mo.md(r""" | |
| ## Verification: L = λW | |
| """) | |
| return | |
| def _(RandomArrivals, SEED, pl, random, run_scenario): | |
| random.seed(SEED) | |
| rows = [] | |
| for lam in (0.5, 1.0, 1.5, 2.0, 2.5): | |
| for capacity in (2, 3, 4): | |
| rows.append(run_scenario(lam, capacity, RandomArrivals)) | |
| df = pl.DataFrame(rows) | |
| df | |
| return (df,) | |
| def _(alt, df, pl): | |
| points = ( | |
| alt.Chart(df) | |
| .mark_point(size=100, filled=True) | |
| .encode( | |
| x=alt.X("L_direct:Q", title="L (direct sample)"), | |
| y=alt.Y("L_little:Q", title="L = λW (Little's Law)"), | |
| color=alt.Color("capacity:O", title="Capacity"), | |
| tooltip=["lambda:Q", "capacity:O", "L_direct:Q", "L_little:Q", "error_%:Q"], | |
| ) | |
| ) | |
| max_val = max(df["L_direct"].to_list()) * 1.1 | |
| diagonal = ( | |
| alt.Chart(pl.DataFrame({"x": [0.0, max_val], "y": [0.0, max_val]})) | |
| .mark_line(color="gray", strokeDash=[4, 4]) | |
| .encode(x="x:Q", y="y:Q") | |
| ) | |
| (diagonal + points).properties(title="Little's Law: Direct Sample vs. λW") | |
| return | |
| def _(mo): | |
| mo.md(r""" | |
| ## The Error Column | |
| The large error at $\lambda = 2.5$, capacity $= 2$ is a stability problem, not a simulation bug. | |
| Little's Law only holds in steady state. For an M/M/c queue, steady state requires | |
| that the arrival rate $\lambda$ be less than capacity times service rate $\mu$. | |
| With `SERVICE_RATE = 1.0` and `capacity = 2`, | |
| the maximum sustainable throughput is $2 \times 1.0 = 2.0$. | |
| At $\lambda = 2.5$, the load exceeds service capacity, so the queue grows without bound. | |
| By the time the simulation ends, | |
| hundreds of customers are waiting in queue, and their sojourns are never recorded. | |
| ## Key Points | |
| 1. `Monitor` samples `in_system[0]` every `SAMPLE_INTERVAL` time units to | |
| estimate $L$ directly without any queueing formula. | |
| 2. The `error_%` column shows that $L_{\text{direct}}$ and $\lambda W$ agree to within | |
| less than 1% for all stable configurations, even though the service-time | |
| distributions are completely different. | |
| 3. `DeterministicCustomer` uses the fixed `DETERMINISTIC_SERVICE` constant | |
| rather than a random draw; everything else in the simulation is unchanged. | |
| The law still holds. | |
| 4. `Resource(env, capacity=3)` creates a three-slot server for M/M/3. | |
| Setting the arrival rate to 2.4 gives utilization 0.8 per server. | |
| """) | |
| return | |
| def _(mo): | |
| mo.md(r""" | |
| ## Understanding the Math | |
| ### The area argument made concrete | |
| Draw a horizontal time axis from $t = 0$ to $t = T$. Each customer gets a horizontal bar starting at their arrival time and ending at their departure time. The length of their bar is exactly their sojourn time $W_i$ — the total time they spend in the system. At any moment $t$, the number of bars that cross that vertical slice is exactly $L(t)$, the instantaneous number of customers in the system. | |
| Now compute the total area under all the bars in two different ways. First, add up the lengths of all the bars: total area $= \sum_i W_i$. Second, integrate the height of the stack over time: total area $= \int_0^T L(t)\,dt$. These are the same area, so $\sum_i W_i = \int_0^T L(t)\,dt$. | |
| Divide both sides by $T$. The right side becomes the time-average $\bar{L}$. The left side becomes $(n/T) \cdot \bar{W}$, where $n$ is the total number of customers and $\bar{W}$ is their mean sojourn time. As $T \to \infty$, $n/T \to \lambda$ (the long-run arrival rate). That gives $\bar{L} = \lambda \bar{W}$, which is Little's Law. | |
| ### No distribution required | |
| The argument above uses only geometry. There is no probability distribution, no exponential assumption, no Poisson process. The shape of each bar (i.e., how long each customer takes) can be anything. This is why the law applies to M/M/1, M/D/1, M/M/3, and every other configuration equally. | |
| ### Using it in practice | |
| Suppose you run a web service. Your monitoring dashboard shows $\lambda = 500$ requests per second and your server logs show a mean response time of $W = 20$ milliseconds. Little's Law immediately tells you that the mean number of active requests in the system is $L = \lambda W = 500 \times 0.02 = 10$ requests. Alternatively, if you observe $L$ and $\lambda$ but not individual response times, you get $W = L/\lambda$ without any per-request timing instrumentation. | |
| ### Units check | |
| $\lambda$ has units of customers per unit time; $W$ has units of time; so $L = \lambda W$ is dimensionless — a pure count of customers. Always verify units when applying Little's Law to a new problem: if your units do not cancel correctly, you have applied the law incorrectly. | |
| ### Stability condition | |
| Little's Law requires the system to reach steady state: over the long run, arrivals and departures must balance. If $\lambda > \mu$ (the arrival rate exceeds the service rate), the queue grows without bound. $L = \infty$ and $W = \infty$; the law still holds, but it tells you the system is broken, not that it is well-behaved. | |
| """) | |
| return | |
| if __name__ == "__main__": | |
| app.run() | |