Modeling Seasonal Tire-Changeover Demand: Queueing Math and Slot-Scheduler Design for a Calgary Tire Shop
Twice a year, a tire counter in Calgary experiences something that most service businesses never see: demand that is gated not by a date, but by a weather forecast. When Environment Canada's seven-day outlook shows the first hard freeze of the fall, request volume for seasonal tire changeovers can triple within forty-eight hours. When the first real snowfall actually lands, it triples again. The spring side is gentler but still lumpy: a string of warm days pulls everyone in at once to get winter rubber off before it wears down on hot pavement.
This article is a design study. It walks through how you would model that demand mathematically, sanity-check capacity with queueing theory, replace the closed-form math with a discrete-event simulation when the assumptions break, and then design the slot-scheduling data model that turns the analysis into software. The operational grounding is real — KMJ Tire is a Calgary operation that does tire work and oil changes, and the seasonal spike described here is the actual shape of its year — but the system described is a design exercise, not a description of live production infrastructure. All numeric figures in the worked examples are illustrative and labelled as such.
One scoping note before the math: the service catalog here is deliberately narrow. Tire changeovers, mounting and balancing, flat repairs, and oil changes. No alignments, no brakes, no diagnostics. That narrowness is a modeling gift. A general repair garage has service times ranging from twenty minutes to three days, which makes capacity planning a nightmare. A tire-and-oil operation has maybe five job types, each with a tight, estimable duration distribution. If you ever get to choose a domain for your first scheduling system, choose one like this.
Why Changeover Demand Is Not a Calendar Event
A naive scheduler assumes demand follows the calendar: busy in late October, busy in late April, quiet otherwise. Calgary breaks that assumption in both directions.
First, the trigger is meteorological. The fall rush does not start on October 15th; it starts when the forecast says overnight lows will hold below zero, and it goes vertical the morning after the first snowfall that sticks. In some years that is October 2nd. In others it is November 9th. A model keyed to calendar dates will be five weeks wrong in either direction, which at peak volume means either an empty schedule grid or a two-week backlog.
Second, chinooks generate false starts. A chinook arch can take the city from minus fifteen to plus twelve in a day. Early-season cold snaps followed by two warm weeks produce a demand spike, then a dead zone, then a second spike when winter arrives for real. Drivers who held out through the first snap because "it'll melt" — and it did — show up in the second wave with more urgency. Any intensity model needs to handle multiple partial triggers per season, not one clean step function.
Third, the two seasons are asymmetric, and this matters more than most people expect. The fall changeover is deadline-driven: there is a morning when the roads are white and every all-season tire in the city becomes a liability at once. The spring changeover has no deadline. Winter tires on dry warm pavement wear faster and cost the owner money slowly, but nobody slides into an intersection because they left them on until June. Fall demand is therefore a sharp impulse with a heavy leading edge; spring demand is a wide, flat-topped hump spread over five or six weeks. A model trained on one and applied to the other will fail quietly, which is worse than failing loudly.
Fourth, product mix shifts the load. Customers running all-weather tires opt out of the changeover cycle entirely — that is much of their appeal — while dedicated winter tire owners are locked into two visits a year. As the all-weather share of the local fleet grows, the seasonal amplitude damps. Your demand model has a slow secular trend underneath the annual cycle, and if you fit only on last year you will miss it.
Demand as a Non-Homogeneous Poisson Process
The standard formalism for arrival streams whose rate varies over time is the non-homogeneous Poisson process (NHPP). Requests arrive independently, but the arrival intensity λ(t) is a function of time rather than a constant. For our problem, λ(t) is a function of time and weather state:
λ(t) = λ_base(t) · m_weather(F_t) · m_backlog(B_t)
where:
-
λ_base(t)is the baseline seasonal-and-weekly pattern: day-of-week effects (Saturdays run hot, Tuesdays run cold), plus a smooth annual curve. -
m_weather(F_t)is a multiplier driven by the forecast feature vectorF_t— things like minimum forecast temperature over the next 7 days, probability of accumulating snowfall, and days since the first freeze of the season. -
m_backlog(B_t)captures reflexive demand: when the wait to the next open slot stretches past a few days, some fraction of would-be requesters defer or go elsewhere, which suppresses observed arrivals. Ignoring this term makes you overestimate peak intensity from historical data, because the historical peaks were already demand-suppressed.
The weather multiplier is where the Calgary-specific behaviour lives. A reasonable functional form is a saturating response to a "trigger score":
import math
from dataclasses import dataclass
@dataclass
class ForecastFeatures:
min_temp_7d: float # coldest forecast overnight low, next 7 days (Celsius)
snow_prob_72h: float # probability of accumulating snow within 72h, 0..1
days_since_first_freeze: float | None # None if it has not happened yet
season: str # "fall" or "spring"
def trigger_score(f: ForecastFeatures) -> float:
"""Scalar in [0, ~3] summarizing how strongly weather is pushing demand.
Coefficients below are illustrative placeholders, not fitted values.
"""
s = 0.0
if f.season == "fall":
# Cold forecast pulls demand forward even before snow falls.
s += max(0.0, (2.0 - f.min_temp_7d) / 6.0) # ramps as lows drop below +2C
s += 1.6 * f.snow_prob_72h # imminent snow dominates
if f.days_since_first_freeze is not None:
# Urgency decays ~2 weeks after the first freeze as the herd clears.
s += 0.8 * math.exp(-f.days_since_first_freeze / 9.0)
else: # spring
# Sustained warmth, not a single warm day, drives spring swaps.
s += max(0.0, (f.min_temp_7d - 4.0) / 5.0)
return s
def weather_multiplier(f: ForecastFeatures, cap: float = 4.5) -> float:
"""Map trigger score to a demand multiplier with a saturating ceiling."""
s = trigger_score(f)
return 1.0 + (cap - 1.0) * (1.0 - math.exp(-1.1 * s))
Two design choices deserve defense. The exponential saturation reflects a hard truth: demand cannot multiply forever, because the population of vehicles needing a swap is finite. Every changeover performed depletes the pool. A proper treatment would model the susceptible population explicitly — an SIR-flavoured depletion model, where "infection" is the first snowfall — but the saturating multiplier gets you most of the way with far less machinery.
The decay term on days_since_first_freeze encodes the herd-clearing effect. Ten days after the first snow, most of the panicked demand has either been served or has given up for the moment. Historical intensity plots (in this shop's records and, anecdotally, at every tire counter in the city) show a spike with roughly exponential relaxation, punctuated by secondary spikes on each subsequent snowfall.
To generate synthetic arrivals from an NHPP for simulation, thinning (Lewis and Shedler's method) is the standard tool: simulate a homogeneous process at the maximum rate, then keep each arrival with probability λ(t)/λ_max.
import random
def nhpp_arrivals(lam, lam_max, t_end, rng=random.Random(42)):
"""Thinning: yield arrival times in [0, t_end) for intensity function lam(t)."""
t = 0.0
while True:
t += rng.expovariate(lam_max)
if t >= t_end:
return
if rng.random() < lam(t) / lam_max:
yield t
Fitting λ(t) from historical data is a Poisson regression: count requests per day, regress on the weather features and day-of-week dummies with a log link. Even a plain GLM does respectably here because the feature set is small and physically motivated. Resist the urge to reach for a gradient-boosted model with forty features; you have at most a dozen seasonal transitions in your entire dataset, and flexibility without data is just a curve-fitting liability.
Service Times: Two Distributions, Not One
The second half of any queueing model is the service process, and here the job-type split is everything. A changeover comes in two fundamentally different flavours:
- Swap on rims. The customer owns two full sets of mounted wheels. The job is: lift, off, on, torque, set pressures, verify TPMS. Fast, predictable, low variance.
- Mount and balance. One set of rims, two sets of rubber. Each tire comes off its rim and the seasonal tire goes on, followed by balancing on the spinner. Slower, and with a longer tail: corroded bead seats, stubborn TPMS relearn procedures, and the occasional stripped-thread surprise on the lug hardware all live in that tail.
Oil changes ride along as a third job type — shorter still, and usefully counter-cyclical, since they can backfill quiet slots in the off-season without competing hard for peak-season bay time.
Illustrative distribution parameters, stated as such (these are plausible for a shop of this type, not measured production values):
| Job type | Distribution | Median | p90 | Notes |
|---|---|---|---|---|
| Swap on rims | Lognormal(μ=ln 32, σ=0.22) | 32 min | ~42 min | Includes vehicle in/out and torque check |
| Mount + balance (4 tires) | Lognormal(μ=ln 62, σ=0.35) | 62 min | ~97 min | Tail driven by bead corrosion, TPMS relearn |
| Oil change | Lognormal(μ=ln 24, σ=0.18) | 24 min | ~30 min | Filler work between peak jobs |
Why lognormal rather than exponential? Because the exponential assumption — memoryless service, mode at zero — is flatly wrong for physical work. Nobody completes a four-wheel swap in ninety seconds, and the empirical histograms of hands-on automotive tasks are right-skewed with a hump well away from zero. Lognormal (or gamma) fits that shape. The distinction matters practically: the M/M/c formulas assume exponential service, and using them with lognormal reality means your closed-form answers are approximations from the start. They are still worth computing — as a sanity check, not as a forecast.
Estimating these distributions is the easiest data problem in the whole system, provided your point-of-sale or work-order timestamps are trustworthy. Take start/finish pairs per job type, filter out the obviously-broken records (the four-second mount/balance means someone closed the ticket late), and fit by maximum likelihood on the log of duration. Fifty observations per job type gives you a serviceable estimate; a full season gives you a good one. The one trap: peak-season service times are longer than off-season ones for the same job type, because techs are interrupted more, staging space is congested, and the vehicles arriving are disproportionately the annoying ones. Fit peak and off-peak separately or include a load covariate.
An M/M/c Sanity Check, and Where It Lies to You
With arrival and service rates in hand, the classical move is Erlang-C: model the operation as an M/M/c queue with c parallel servers (bays with a tech), Poisson arrivals at rate λ, exponential service at rate μ per server, and compute the probability an arriving job waits, plus the expected wait.
from math import factorial
def erlang_c(c: int, offered_load: float) -> float:
"""P(wait > 0) for M/M/c with offered load a = lambda/mu erlangs."""
a = offered_load
rho = a / c
if rho >= 1.0:
return 1.0 # unstable: queue grows without bound
b = (a ** c) / factorial(c)
denom = sum((a ** k) / factorial(k) for k in range(c)) + b / (1 - rho)
p_wait = (b / (1 - rho)) / denom
return p_wait
def mean_wait_hours(c: int, lam: float, mu: float) -> float:
a = lam / mu
return erlang_c(c, a) / (c * mu - lam)
Here is the part practitioners underestimate. Utilization of 85–90% sounds healthy — it is what a spreadsheet-driven manager will target — but in a stochastic system it is already deep into the pain zone, because waits blow up hyperbolically as ρ approaches 1. We will put concrete numbers on that in a moment.
Where the model lies, and why you should not stop here:
- Non-stationarity. M/M/c describes steady state. A changeover surge is a transient — the system never settles. Steady-state formulas applied to a two-week impulse answer a question nobody asked.
- Wrong service distribution. Exponential service understates the probability of medium-length jobs and overstates very short ones. M/M/c wait estimates are optimistic when reality is lognormal with our σ values (the coefficient of variation is below 1, which actually helps — but the tail events hurt in ways the mean does not capture).
- Heterogeneous jobs and resources. A bay with a tire machine and spinner can do everything; a flat stall can only do swaps and oil. That is not "c identical servers."
- Scheduled arrivals are not Poisson. The entire point of a reservation system is to destroy the Poisson property — to replace random arrivals with a controlled admission process. M/M/c models the walk-in-only world you are trying to escape.
So use Erlang-C the way you use a back-of-envelope structural calculation: to determine whether the design is off by a factor of three, before you spend a week building the simulation that answers the real question.
Worked Example: Four Bays Against a Freeze Warning
Everything in this section is an illustrative calculation with invented-but-plausible inputs, labelled as such. It exists to show the method, not to publish operating data.
Setup. Four equivalent bays, nine working hours per day. Job mix during the fall surge: 60% swap-on-rims, 40% mount-and-balance. Using the medians above as means (close enough for this level), the blended mean service time is:
E[S] = 0.6 × 32 min + 0.4 × 62 min = 19.2 + 24.8 = 44 min ≈ 0.733 h
μ ≈ 1.36 jobs per bay-hour
Scenario A — a moderately busy shoulder day. Suppose requests arrive at λ = 4.5 per hour. Offered load a = λ/μ = 3.31 erlangs across c = 4 bays, so utilization ρ = 0.83. Plugging into Erlang-C:
P(wait) ≈ 0.66
Wq = P(wait) / (cμ − λ) ≈ 0.66 / (5.44 − 4.50) ≈ 0.70 h ≈ 42 minutes
Read that again: at 83% utilization — a number most planning spreadsheets would color green — two-thirds of arriving vehicles wait, and the average wait is roughly three-quarters of a service time. Push λ to 5.1 per hour (ρ = 0.94) and mean wait quadruples to nearly three hours. That hyperbolic blowup near saturation is the single most important intuition queueing theory offers, and it is exactly why "we'll just work faster during the rush" is not a plan.
Scenario B — the morning after the first snowfall. Historical pattern (again, stylized): request volume equivalent to 110 changeovers in one day. Daily throughput ceiling with four bays is:
4 bays × 9 h × 1.36 jobs/bay-hour ≈ 49 jobs/day
Demand is 2.2× capacity. No queueing formula rescues this; the system is simply oversubscribed, and the interesting quantity stops being "wait in the lobby" and becomes "days until the next open slot." The 61 unserved requests roll forward, meet the next day's fresh arrivals, and the backlog integrates upward until either intensity decays (the herd-clearing effect) or capacity rises (surge policy — extended hours, overflow days, or diverting swap-on-rims jobs to a mobile service unit that handles them in customer driveways and office parking lots).
This is the pivotal reframing for the whole design: during the surge, the queue does not live in the waiting room; it lives in the schedule. The engineering objective is not minimizing lobby wait — it is shaping the multi-day backlog: keeping p90 time-to-next-open-slot bounded, protecting emergency capacity, and making sure the reservation grid, not a phone-line free-for-all, absorbs the shock.
Discrete-Event Simulation When the Formulas Run Out
Once you accept non-stationary arrivals, lognormal services, heterogeneous bays, reservations, no-shows, and walk-ins, closed forms are gone. A discrete-event simulation (DES) handles all of it and stays small enough to read in one sitting. The core is a priority queue of timestamped events:
import heapq, random
class ChangeoverSim:
"""Minimal DES: reserved arrivals + walk-ins competing for bays.
All parameters illustrative. Times in hours from day start.
"""
def __init__(self, n_bays=4, day_len=9.0, seed=7):
self.rng = random.Random(seed)
self.n_bays = n_bays
self.day_len = day_len
self.events = [] # (time, seq, kind, payload)
self.free_bays = n_bays
self.queue = [] # waiting jobs (on-site)
self.stats = {"served": 0, "waits": [], "balked": 0}
self._seq = 0
def push(self, t, kind, payload=None):
self._seq += 1
heapq.heappush(self.events, (t, self._seq, kind, payload))
def service_time(self, job_type):
med, sigma = {"swap": (32/60, 0.22),
"mount": (62/60, 0.35),
"oil": (24/60, 0.18)}[job_type]
return self.rng.lognormvariate(0, sigma) * med
def arrive(self, t, job):
if self.free_bays > 0:
self.start(t, job)
elif len(self.queue) >= 6 and job["source"] == "walkin":
self.stats["balked"] += 1 # walk-in sees a full lobby, leaves
else:
job["t_arr"] = t
self.queue.append(job)
def start(self, t, job):
self.free_bays -= 1
self.stats["waits"].append(t - job.get("t_arr", t))
self.push(t + self.service_time(job["type"]), "done")
def run(self):
while self.events:
t, _, kind, payload = heapq.heappop(self.events)
if kind == "arrive":
self.arrive(t, payload)
elif kind == "done":
self.free_bays += 1
self.stats["served"] += 1
if self.queue:
self.start(t, self.queue.pop(0))
return self.stats
Feed it two arrival streams: the reserved stream (slot times, each realized with probability 1 − p_noshow, plus a lateness jitter of a few minutes either way) and the walk-in stream (NHPP via the thinning generator above, with intensity spiking on snowfall mornings). Run a few hundred replications per scenario and look at distributions, not means — the p90 lobby wait and the count of jobs still unfinished at closing time are the numbers that decide staffing arguments.
What the simulation buys you over the algebra, concretely: you can ask policy questions. What happens to overtime hours if we admit walk-ins only into dedicated walk-in slots? How much does a 15-minute buffer after every third mount-and-balance reduce end-of-day overruns? Is one extended-hours evening worth more than a Sunday overflow day? Each of those is a one-line configuration change in the sim and an unanswerable question in Erlang-land.
Two implementation notes from the trenches. Seed your random generators explicitly and log the seed with every run, or you will chase phantom regressions between "identical" scenarios. And validate the sim against the M/M/c case first: set exponential services, constant λ, homogeneous bays, and confirm the simulated waits match Erlang-C within confidence bounds. A DES that cannot reproduce the textbook case has a bug you will otherwise discover much later, in an argument, with money on the table.
Designing the Slot Data Model
Now the software half. The reservation grid is the mechanism that converts stochastic chaos into scheduled work, and its data model determines what policies you can even express. The entities that have earned their place:
- Job types. Each with a planning duration (not the mean — typically the p75 of the fitted distribution, so that half your slots do not run over), a required-equipment flag set, and a seasonal-relevance tag.
- Resources. Bays and techs, modelled separately. A bay without a tire-machine-certified tech is storage; a tech without a bay is idle. Capacity at any instant is the min of qualified-tech count and equipped-bay count for a given job type. Small operations can collapse this to "staffed bay" as a single resource and split it later; the schema below leaves room.
- Slot templates. The pattern that generates concrete slots: "weekdays, bay 1–3, 45-minute grid from 08:00 to 17:00; Saturday adds bay 4." Templates carry an effective-date range, which is exactly the hook that surge policy will use.
- Slots. Materialized rows, generated from templates out to a rolling horizon (three weeks is a comfortable default). Materializing — rather than computing availability on the fly — makes the availability query trivial, makes capacity auditable ("show me every slot we offered in week 44"), and gives overbooking a concrete object to attach to.
-
Reservations. A customer request pinned to a slot, carrying job type, vehicle info, status lifecycle (
held → confirmed → arrived → in_service → completed | no_show | cancelled), and the channel it came through (the online reservation page, phone, or counter). - Walk-in log. Every walk-in, served or not. The unserved ones are your only direct evidence of demand exceeding supply — treasure them.
The buffer policy deserves its own paragraph because it is the most commonly botched piece. If planning durations are honest p75s, roughly one job in four still overruns its slot. Without buffers, overruns cascade: by 3 p.m. every bay is running forty minutes late and the lobby is hostile. The fix is structural slack — either a buffer minute-count appended per slot, or (cleaner) sacrificial catch-up slots: one slot per bay per half-day that is never offered for sale and exists to absorb accumulated drift. Which is better depends on your overrun distribution; the sim answers it in an afternoon.
Overbooking is the same idea mirrored. With a no-show probability of, illustratively, 8–12% on freeze-week days (people's plans change fast when a chinook rolls in and the snow melts), selling exactly one reservation per slot wastes a tenth of your scarcest capacity. Deliberately over-admitting into designated overbookable slots — at a ratio derived from the no-show rate, airline style but far lower stakes — recovers it. Critically, both buffers and overbooking ratios must live in data, not code, because you will tune them seasonally.
Schema Sketches
PostgreSQL flavour. Abbreviated to the load-bearing columns; production versions would add tenancy, audit, and soft-delete concerns.
CREATE TABLE job_types (
id SMALLSERIAL PRIMARY KEY,
code TEXT UNIQUE NOT NULL, -- 'swap_rims' | 'mount_balance' | 'oil_change'
plan_minutes SMALLINT NOT NULL, -- p75 of fitted duration, reviewed seasonally
needs_mounter BOOLEAN NOT NULL DEFAULT false,
seasonal BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE resources (
id SMALLSERIAL PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('bay','tech')),
label TEXT NOT NULL,
capability JSONB NOT NULL DEFAULT '{}'::jsonb -- {"mounter": true, "hoist": "10k"}
);
CREATE TABLE slot_templates (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL, -- 'weekday_standard', 'surge_evening'
dow_mask INT NOT NULL, -- bitmask Mon..Sun
start_local TIME NOT NULL,
end_local TIME NOT NULL,
grid_minutes SMALLINT NOT NULL,
bay_id INT NOT NULL REFERENCES resources(id),
effective DATERANGE NOT NULL, -- surge policy activates/retires templates
overbook_pct NUMERIC(4,1) NOT NULL DEFAULT 0,
buffer_min SMALLINT NOT NULL DEFAULT 0
);
CREATE TABLE slots (
id BIGSERIAL PRIMARY KEY,
template_id INT REFERENCES slot_templates(id),
bay_id INT NOT NULL REFERENCES resources(id),
span TSTZRANGE NOT NULL,
sellable BOOLEAN NOT NULL DEFAULT true, -- false for catch-up/buffer slots
max_admits SMALLINT NOT NULL DEFAULT 1, -- >1 only where overbooking applies
EXCLUDE USING gist (bay_id WITH =, span WITH &&) -- no overlapping slots per bay
);
CREATE TABLE reservations (
id BIGSERIAL PRIMARY KEY,
slot_id BIGINT NOT NULL REFERENCES slots(id),
job_type_id SMALLINT NOT NULL REFERENCES job_types(id),
vehicle_ref TEXT NOT NULL,
channel TEXT NOT NULL CHECK (channel IN ('web','phone','counter')),
status TEXT NOT NULL DEFAULT 'held'
CHECK (status IN ('held','confirmed','arrived','in_service',
'completed','no_show','cancelled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE walkin_log (
id BIGSERIAL PRIMARY KEY,
seen_at TIMESTAMPTZ NOT NULL,
job_type_id SMALLINT REFERENCES job_types(id),
outcome TEXT NOT NULL CHECK (outcome IN ('served','deferred_to_slot','left')),
est_wait_quoted_min SMALLINT
);
A few deliberate choices worth defending:
The EXCLUDE USING gist constraint on slots is PostgreSQL doing your concurrency control for you: two overlapping spans on the same bay cannot both commit, no matter how badly your slot-generation job misbehaves. Range types plus exclusion constraints are the single most underused feature in scheduling systems built on Postgres.
Admission control belongs in one place. The invariant "count of active reservations on a slot ≤ max_admits" is enforced in a single transactional path — a serializable transaction or an advisory lock keyed on slot_id — never sprinkled across application layers. Every double-admission bug in every scheduling system traces back to someone checking availability in one query and inserting in another without isolation.
Status history matters more than current status. The sketch shows only status_at; a production build appends every transition to an event table instead. The reason is the analytics, not the operations: no-show modelling requires knowing when the no-show became apparent, and demand modelling requires created_at (when did they ask?) separated from slot time (when did we serve them?). Lead-time distribution — the gap between the two — is itself a surge signal: it collapses toward zero right before the crunch, as urgency rises.
No-Shows and Walk-Ins Are Inputs, Not Noise
It is tempting to treat no-shows and walk-ins as operational irritants to be minimized. In the model they are first-class stochastic inputs, and each carries information.
No-shows during freeze week have a distinctive cause: weather whiplash. A driver reserves in a panic on Tuesday's forecast; Thursday a chinook wipes the snow out; Friday's slot quietly goes unused. This means the no-show probability is itself weather-conditional — rising when the trigger that generated the reservation reverses. A two-variable logistic model (lead time at creation, temperature delta between creation day and slot day) captures most of it. The practical payoff: on forecast-reversal days you can raise the overbooking ratio on overbookable slots with quantified confidence rather than a shrug, and release predicted-dead capacity back to the waitlist a day early.
Walk-ins are the inverse problem: unscheduled supply-seeking. The morning after the first snowfall, some fraction of the city skips the reservation system entirely and drives over. A slot grid with zero walk-in allowance turns those visits into pure friction — every one interrupts the counter, and most leave unserved. A grid that holds back a small ration of same-day capacity (two or three sellable-but-unreleased slots per day, released each morning) converts walk-in pressure into scheduled work a few hours later. The walkin_log table exists to size that ration: outcome = 'left' rows are censored demand, the people the schedule failed. During surge analysis they get added back into the intensity estimate, because the reservation stream alone systematically undercounts true demand at exactly the moments you most need to know it. The same censoring logic applies to commercial and fleet work, where a fleet coordinator who cannot get five vans in this week may quietly move all five elsewhere — high-value demand that vanishes without a trace unless logged.
Surge Policy Is Configuration, Not Code
The worst version of surge handling is a developer editing constants the night after the first snowfall. The whole point of the template/effective-range design is that surge response is a data change: activate prebuilt templates, adjust ratios, done.
A surge configuration might look like this (structure illustrative):
surge_tiers:
- name: watch # freeze forecast inside 7 days, no snow yet
activate_templates: []
overbook_pct: 8
walkin_holdback_per_day: 2
demand_multiplier_floor: 1.4
- name: freeze_rush # first freeze confirmed or snowfall prob > 60% in 72h
activate_templates: [surge_evening] # weekday 17:00-20:00 grid
overbook_pct: 12
walkin_holdback_per_day: 4
max_mount_balance_share: 0.45 # protect throughput with faster jobs
- name: snowfall_peak # accumulating snow on the ground
activate_templates: [surge_evening, overflow_sunday]
overbook_pct: 12
walkin_holdback_per_day: 6
defer_nonseasonal_jobs: true # oil changes offered next-week slots
Note the max_mount_balance_share knob. During the crunch, throughput is king, and swap-on-rims jobs move nearly twice as many vehicles per bay-hour as mount-and-balance jobs. Biasing the sellable mix toward swaps — while routing mount-and-balance requests a few days out — raises vehicles-served-per-day exactly when that metric matters most. That is a policy decision with real trade-offs (mount-and-balance customers wait longer), which is precisely why it should sit in reviewable configuration with an audit trail, not in an if statement someone forgets.
Tier transitions should be proposed automatically (the weather-feature pipeline below emits a recommended tier each morning) but applied by a human. Full automation of surge policy is how you end up with an evening shift activated by a botched forecast parse. The human is cheap; the false activation is not.
Geography enters here too. Surge templates for the mobile unit look different from bay templates — drive time between stops is the dominant cost, so its "slots" are route-clustered by quadrant rather than gridded by the hour, and the service-area definition effectively becomes part of capacity policy: tightening the served radius during peak week is another lever that trades coverage for throughput.
Weather Features Feeding the Demand Multiplier
The forecasting layer can stay almost embarrassingly simple. Inputs, refreshed each morning:
- 7-day forecast: daily highs, lows, snowfall probability and expected accumulation.
- Season state: days since first freeze (fall) or days of sustained warmth (spring).
- Trailing demand: requests per day over the last 14 days, which anchors
λ_base.
From these, compute tomorrow-through-day-7 expected intensity:
def daily_intensity_forecast(base_by_dow, features_by_day):
"""Return list of (date, expected_requests) for the next 7 days.
base_by_dow: fitted baseline requests per day-of-week (off-season anchor)
features_by_day: list of (date, ForecastFeatures)
"""
out = []
for date, feats in features_by_day:
lam = base_by_dow[date.weekday()] * weather_multiplier(feats)
out.append((date, lam))
return out
Then compare forecast intensity against materialized sellable capacity per day, and emit three numbers to whoever runs the counter: expected requests, open slots, and projected p90 days-to-next-slot if the forecast verifies. That last number is the early-warning siren. When it crosses a threshold (say, four days — illustrative), the system recommends the next surge tier, and the humans decide.
A note on data sourcing discipline: forecast features must be snapshotted at prediction time. If you later backfill weather data with what actually happened, your model evaluation silently upgrades from "what did the forecast know" to "what did the atmosphere do," and every skill metric you compute becomes a lie. Store the forecast as-of each morning, forever. Disk is cheap; unreproducible evaluations are not.
The educational content angle deserves one sentence: demand shaping is also possible upstream. Content that helps drivers decide earlier — like the explainer material in the Be Tire Smart pages or a plain-language piece on reading sidewall markings — flattens the spike slightly by converting day-after-snowfall panic into week-before-freeze planning. The effect is modest, but it is the only lever that adds capacity by moving demand instead of moving staff.
Metrics That Decide Arguments
Instrument the system around a small set of numbers, each answering a specific operational question:
| Metric | Definition | Question it answers |
|---|---|---|
| p90 time-to-next-slot | 90th percentile, per job type, of (earliest open sellable slot − now) | Are we absorbing demand or falling behind? |
| Bay utilization (busy) | In-service minutes ÷ staffed-bay minutes | Are we paying for idle capacity? |
| Bay utilization (committed) | (In-service + reserved-future) ÷ sellable | How full is the forward grid? |
| Overrun rate | Share of jobs exceeding plan_minutes | Are planning durations still honest? |
| No-show rate (weather-split) | No-shows ÷ confirmed, segmented by forecast-reversal flag | Is the overbooking ratio calibrated? |
| Walk-in loss |
walkin_log.outcome = 'left' per day |
How much demand did we visibly turn away? |
| Reservation lead time | Median (slot time − created_at) | Is the surge approaching? (It collapses first.) |
| End-of-day drift | Actual last-job finish − scheduled last-slot end | Are buffers sized right? |
Two of these earn special emphasis. The lead-time collapse is the best leading indicator in the whole system: days before request volume spikes, the gap between "when people ask" and "when they want service" shrinks from a week to a day. It is demand urgency made measurable, and it typically fires before the weather multiplier does, because drivers watch forecasts too. And walk-in loss is the only metric on the list measuring what did not happen — the abandonment that every purely reservation-based dataset is blind to. A dashboard without it will tell you the surge went fine when in fact you shed a triple-digit number of visits to competitors across the city; for a single-location independent among Calgary's local tire operations, those are exactly the first-time customers whose second visit you never get.
Resist vanity metrics. Total vehicles served per week is seasonal noise wearing a KPI costume; it mostly measures the weather. Every number on the dashboard should change someone's decision — slot ratios, staffing, surge tier — or it is decoration.
Pitfalls: The Ways This Model Fails
Overfitting to last season. One year of Calgary data contains exactly one fall trigger and one spring thaw, each with its own idiosyncratic timing. A model fit tightly to last October will confidently predict this October wrong. The defense is structural: keep the weather-response function low-dimensional and physically interpretable (cold → more demand, saturation, decay), pool multiple years even if older data is scrappier, and treat every fitted coefficient with the suspicion appropriate to n = 3 seasonal transitions. When forced to choose between a clever model and a dumb robust one, in this domain, choose dumb.
Ignoring the fall/spring asymmetry. Already discussed, but it recurs at the policy layer too: fall surge tiers built around throughput protection make no sense in spring, when the problem is a long moderate hump, not an impulse. Spring's failure mode is the opposite — weeks of slightly-elevated demand that never technically triggers a surge tier but accumulates a quiet backlog. Separate tier definitions per season; do not parameterize one set and hope.
Trusting duration data collected under load. Peak-week service times are contaminated by the peak itself. If you refit plan_minutes from surge-week data, durations inflate, slots lengthen, sellable capacity drops next season, and you have institutionalized your worst week. Fit from shoulder-season data; validate against peak.
Chinook double-triggering. A naive trigger fires on the first freeze, decays, then fires again on the next cold snap at full strength — but the second wave draws from a partially depleted population. Without a depletion term (or at minimum a season-to-date served-count discount), you will over-staff the second spike. The population of not-yet-swapped vehicles is finite and every completed changeover shrinks it; by late November the pool is mostly drained regardless of what the thermometer does. Track cumulative seasonal completions as an explicit state variable — it also quietly improves the fleet-side planning, since fleet accounts complete their changeovers in scheduled blocks that deplete pooled demand in visible chunks.
Letting the abstraction leak upward. The final trap is organizational. Queueing math and simulations produce distributions; managers want single numbers. The moment someone summarizes "p90 next-slot of 3.1 days under the 60%-snowfall scenario" as "we're fine," the modelling effort becomes theatre. Report ranges. Show the bad percentile. The entire value of the stochastic treatment is refusing to pretend the future is a point estimate.
Closing Notes From the Bay Floor
Strip away the notation and the design reduces to four moves. Model arrivals as weather-driven and non-stationary, because in this city they are. Use closed-form queueing only to bound the problem, then simulate, because every real complication — lognormal tails, mixed job types, no-shows, walk-ins — breaks the textbook assumptions in ways that matter. Put every policy knob in configuration with an effective-date range, because surge response has to move at forecast speed, not release-cycle speed. And instrument the censored demand — the visits that never happened — because that is where oversubscribed systems hide their failures.
None of this requires heavy infrastructure. The whole stack sketched here is a Postgres schema, a few hundred lines of Python, a YAML file, and a morning cron job reading a public forecast feed. What it requires instead is respect for the problem's actual structure: a finite, weather-triggered, twice-yearly stampede through a small number of bays, asymmetric between seasons, observed only partially. Get the structure right and modest tools are plenty. Get it wrong and no amount of machinery will save the second week of November — and anyone who has watched the first-snowfall morning from behind a tire counter, phone ringing over the torque wrenches, knows exactly which week that is.
Top comments (0)