New York's 311 line receives 17.4 million calls a year. This page takes its Spanish-language desk — 484,000 calls a year, 2.8% of the line and reported separately by the city, forecasts a week of them, works out how many people that needs, builds a roster that obeys Spanish working-time law, and puts a price on it.
01The data
Real, public, and not what it looks like.
Every contact below is a row in New York City's
311 Service Requests dataset —
8,736 hours of 52 whole weeks, fetched from the
city's own API. Nothing here is generated.
These rows are service requests, not contacts. New York took 17.4M calls in Fiscal 2025 and closed 3.8M service requests. Only 6.1% of calls leave a phone-originated request behind, so sizing a phone floor straight from the open data would understaff it by about 16×. The hourly shape below is real; the voice level is scaled to the city's own published call count, and the web and app rows are self-service submissions that reach an agent only as deferred work, never as a queue.
The desk rostered here is the
Spanish-language line, which New York reports separately:
484,000 calls a year, 2.8% of the whole operation.
It is chosen because it is real, named, published and about twenty-five people —
where the full 311 floor is over a thousand, which is a different kind of problem
and not one a laptop should pretend to solve.
Rows3,167,94952 weeks of 2024
Calls (FY25)17.4Mcity's published figure
Request rate6.1%of calls leave a request
Answered in 30s80%NYC target and actual
Voice — calls per hourweek 40 of 52, starting 2024-01-01Web and app — requests per hourweek 40 of 52, starting 2024-01-01
The two shapes are genuinely different, which is the reason
to keep them apart. Voice peaks hard on Mon mid-morning and again
after lunch; self-service spreads flatter and runs later, because nobody has to
wait for an agent to be awake.
02The forecast
A ridge regression on a seasonal basis, scored the way the operation feels it rather than the way a paper would.
Calls per hour, forecast against actualweek 40 of 52, starting 2024-01-01
what actually arrived forecast, unbiased forecast staffed to, +20%calls/hour
Trained on6,384hourly observations, 38 weeks
Features32columns in the design matrix
R² in log space0.834residual sd 0.273
Hourly error15.6%8.0 calls/hour on a mean of 51
sMAPE30.7%symmetric, over hours with traffic
Week total+28.7%11,124 staffed vs 8,642 arrived
Uplift+20%chosen from the history
Three percentages because they answer three questions. 15.6% is how far the unbiased forecast sits from a typical hour — that is the model's accuracy, and the number to compare against anyone else's. 30.7% is the same thing scored symmetrically over hours that actually had traffic, so a quiet 3am hour cannot flatter or wreck it. +28.7% is the week's total once the uplift is added, and it is deliberately positive: that is the cushion being bought, not an error. Read the first as quality and the third as policy.
The model, written out
It is one equation. h is the hour of the day, w the
hour of the week, t the hour index since the history starts:
log1p(callst) = β0 + Σk=1..4 [ ak sin(2πk·h/24) + bk cos(2πk·h/24) ]
— the shape of a day, four harmonics + weekendt · Σk=1..4 [ ck sin(2πk·h/24) + dk cos(2πk·h/24) ]
— Saturday is a different shape, not a smaller one + Σk=1..3 [ ek sin(2πk·w/168) + fk cos(2πk·w/168) ]
— the slide from Monday to Friday + Σd=Tue..Sun gd·1[dowt=d]
— six day levels, against Monday + τ·(t/168)
— trend, read as growth per week + λ1 log1p(callst−168) + λ2 log1p(callst−336)
— same hour one and two weeks ago
32 coefficients, fitted in closed form by ridge — one
np.linalg.solve, no iteration, no gradient descent, no random seed.
Given the same history it returns the same numbers every time.
Is it "pure ML"?
No, and the distinction is worth being honest about. Nothing here
discovers that call volume has a daily rhythm: the sines and cosines are
written into the design matrix by hand, and so are the weekend interaction, the
day dummies and the two lags. What is learned is 32 numbers — the
weights on features somebody already decided were the right ones.
A gradient-boosted tree or a neural net would be handed raw timestamps and
expected to find the structure itself. On 38 weeks of one queue
that trade is a bad one: there are 6,384 rows, the structure is
genuinely sinusoidal, and a model that finds seasonality on its own needs far
more data to match a model that was told. The payoff for the small model is that
every coefficient is inspectable — the table below is not a feature-importance
approximation, it is the actual parameters — and that it cannot invent a pattern
that was never encoded.
What it does not know
There are no external regressors. No public-holiday flag, no weather, no
marketing calendar, no outage feed. The model sees its own past and the clock,
nothing else. That is a real limit and it shows up in a specific place: a day
that is anomalous for a reason outside the data is absorbed into the lag terms
and then echoes for two weeks, because callst−168 and
callst−336 are inputs. A holiday is forecast as if it
were an ordinary Tuesday, and the two Tuesdays after it inherit the dent.
Adding a holiday dummy is the cheapest real improvement available here, and it
is not done: the calendar is jurisdiction-specific and this report is built from
one city's data, so a flag fitted on it would not transfer. Worth stating plainly
rather than leaving a reader to assume the model handles days it has never been
told about.
Why it is this small
The fit happens on log1p rather than on calls, which buys two
things: the seasonality becomes multiplicative — a Monday peak is a
ratio above the week's level, not a fixed number of calls — and a
prediction can never come out negative, which an additive fit on a queue that
idles near zero will happily do.
It is deliberately small. 6,384 observations against
32 parameters is about 199 rows per
column, and anything heavier would be fitting the noise in the shoulders of the
morning peak. The penalty leaves the intercept alone so the level stays free,
which is also why the residuals come out centred to
6e-15 — and that in turn is why the smearing factor is a
clean 1.0350 rather than something that has absorbed a bias.
What the model leans oncoefficient × the spread of its own column, which is the only way to compare a dummy with a harmonic
feature
standardised effect
day cos×1
-0.408
day sin×1
-0.205
weekend day cos×1
+0.180
day sin×2
-0.160
is Sat
-0.145
is Sun
-0.122
day cos×3
+0.119
day cos×2
+0.118
same hour last week
+0.097
is Fri
-0.076
The first daily harmonic alone carries more weight than every day-of-week level put together. That is the two-humped day — morning rush, lunch dip, evening rush — and it is why a model with no seasonal basis at all has to learn the shape from the lag features and never quite does.
Error week by week, not just on averagemean absolute error in calls per hour, 44 weeks scored, refitting before each one
seasonal naive 4-week mean ridge seasonalcalls/hour
A pooled MAE hides whether a model is steadily better or merely better on average. Here the ridge line sits under the naive one in almost every week rather than winning a few by a lot — which is the version worth having, because a forecast that is reliably slightly better is schedulable and one that is wildly better in some weeks is not.
Would more history help?test error against weeks of training data, each model refit on only that much history and every one scored on the same final weeks
It stops helping. Error bottoms out around 32 weeks of history and flattens after that: the weekly shape is learned quickly and the extra months mostly add drift the trend term already handles. Worth knowing before anyone is asked to warehouse three years of interval data.
The uplift is deliberate. An unbiased forecast is wrong in
the expensive direction half the time, because a missing agent costs a queue and
a spare one costs an hour of salary. How far above the mean to staff is settled
by backtesting rather than by taste:
What each point of service level costsRecovered is measured against perfect foresight, not against 100%.
uplift
rostered hours
service recovered
hours short
+0%
77,424
84.6%
5,797
+5%
80,166
89.8%
4,539
+10%
82,926
94.2%
3,489
+15%
85,480
97.9%
2,675
+20%
88,065
100.0%
2,119
+30%
93,416
100.0%
1,310
03How many people that needs
Three channels, three models — because they are three different problems.
Voice goes through Erlang C at
290s handle time against 80% in 30s.
The queue is real, the caller is waiting, and the formula answers the only
question that matters: how many people keep the delay short.
Service requests do not work that way at all: nobody is on the line,
so the question is not how long a queue gets but whether enough agent-hours
exist inside the 24-hour cycle time to clear the work. That
is conservation, not queueing, and it is measured as COPC's On Time
rather than as a service level in seconds. Running email through Erlang C is the
most common mistake in this field and it errs in both directions.
Agents needed — voiceErlang C, with shrinkageAgents needed — service requestsbacklog inside the cycle time
Peak hour24agents, Mon 10:00
Offered load12erlangs at the peak
√-staffing check16N = R + √R
Erlang A abandon0.0%at that staffing
The square-root check is not decoration. N = R + β√R tracks the exact models across four orders of magnitude, and it catches the class of error a black-box Erlang routine absorbs silently — an interval length confused, seconds typed where minutes were meant, a daily forecast fed to an hourly model.
Agents needed — both channelsweek 40 of 52, starting 2024-01-01
04The rules the roster has to obey
Every constraint, what it means, and whether it is actually the law.
A roster is only interesting if it is legal, and "legal"
turns out to be three different things wearing the same coat. Some of these are
the Estatuto de los Trabajadores and cannot be bargained away.
Some are the sector agreement, which means they are real obligations that a
different agreement could set differently — and several of the numbers people
assume are law turn out to live here. And one or two are neither: decisions
somebody made, which are the ones worth arguing about precisely because nobody
has to keep them.
Every rule the roster obeysand whether it is law, bargaining, or a decision somebody made
Rule
spain
spain callcentre
eu minimum
Basis
Where it comes from
Hours an agent may be rostered in a week
40
39
48
Estatuto de los Trabajadores
ET art. 34.1 — forty hours averaged over the year
Longest single shift
9
10
12
Estatuto de los Trabajadores
ET art. 34.3 — nine hours of actual work, unless the agreement says otherwise
Shortest shift worth rostering
4
4
4
Modelling choice
No legal minimum. Below this, travel time dominates the shift
Between the end of one shift and the start of the next
12
12
11
Estatuto de los Trabajadores
ET art. 34.3 — twelve hours. The rule that quietly shapes the whole roster
One uninterrupted break each week
36
36
24
Estatuto de los Trabajadores
ET art. 37.1 — a day and a half
Overtime permitted on top of the contracted week
4
5
0
Estatuto de los Trabajadores
ET art. 35.2 caps overtime at eighty hours a year; this is a weekly working approximation of it
Days an agent may be rostered out of seven
5
5
6
Modelling choice
Not in the statute. Five of seven is the shape of a normal contract
Two blocks in one day with a gap between them
no
yes
no
Collective agreement
Legal in Spain and common in contact centres. Its cost is measured rather than assumed
How far an agent may start from their own anchor hour
unbounded
unbounded
unbounded
Modelling choice
No legal basis at all — a promise to the people working the roster, and the only row here that is pure preference
The row that catches people out is the twelve hours between shifts. It is statute, it is unglamorous, and it does more to shape the week than the forty-hour limit does: it is what stops a late finish being followed by an early start, which is exactly the pattern a naive optimiser reaches for when demand peaks twice a day.
The search never got close enough for these to be prices. The worst row here stopped with a 99% optimality gap: the solver had a roster in hand and a proof that no roster could be better than a bound 99% away from it, and exhausted its budget in between. Only 0 of 8 rows were proved optimal. A difference of a few hundred euros between two rows is far inside that, so it says nothing about the rules. It also shows in the ordering: split shifts allowed, start times pinned to ±3h moved the wrong way against the baseline. A relaxation only ever adds legal rosters and a tightening only ever removes them, so at optimality neither can cross the baseline in that direction. One that did means the baseline — the row every other row is measured against — is itself the weaker solve. What is exact here is the shift count: how many legal shift patterns each rule admits is enumerated, not searched, so that column is the one to read.
What each rule buys, not what it costsone solve per row at 67 agents. The solver spends freedom on coverage, not on savings — so a looser rule reads as a dearer week that covers more of the curve
If this rule were relaxed
Legal shifts
Coverage
vs baseline
Spare hours
Week costs
What it means
baseline — Spanish statute
145
99.38%
—
33h
€31,284
ET arts. 34 and 37 as written
rest 12h → 11h
145
99.82%
+0.44pp
35h
€31,388
the EU Working Time Directive floor
weekly rest 36h → 24h
145
99.56%
+0.18pp
29h
€31,269
one day off instead of a day and a half
5 → 6 working days
145
99.60%
+0.22pp
49h
€31,618
shorter shifts spread across six days
split shifts allowed
1,105
99.16%
?
118h
€32,243
covered the wrong way against the baseline, which the feasible set forbids — the search ran out of time here, so this row says nothing about its rule
max shift 9h → 12h
217
99.74%
+0.36pp
131h
€32,494
long shifts, fewer handovers
40h → 48h week
145
99.91%
+0.53pp
24h
€31,331
the directive's absolute ceiling
start times pinned to ±3h
145
99.96%
?
44h
€31,609
covered the wrong way against the baseline, which the feasible set forbids — the search ran out of time here, so this row says nothing about its rule
The ones that do bite are worth the argument: weekly rest 36h → 24h. Read it as a question about today, not about hiring: this is one solve per row at 67 agents. The other question — the smallest team that could still cover the week — is what scripts/price_rules.py answers, and it takes about an hour.
Shifts to choose from145per agent per day, under spain
Agent-days to fill46967 agents × 7 days
Possible rosters10^1,013before a single rule is applied
Rules enforced9audited from the assignment, not the model
05Who works when
A CP-SAT model over one circular week, under the rules above.
The rosterone column per hour, Monday 00:00 on the left
dayincludes night hoursclick any row for that agent’s week
on the floornight hour (22:00–06:00)off
Coverage against requirementevery cell is the signed difference from what the hour needed: +0 is exactly covered, −X is orange and short of it, +X is blue and more than it
−X short of the requirement+0 exactly covered+X more on the floor than needed
How it got there
CP-SAT does not walk to an answer, it closes on one
from both sides. A portfolio of eight workers proposes rosters from above while
a bound climbs from below, and the search is finished when the two meet. On this
week it found 23 successively better rosters in
60 seconds and never did meet the bound — which is normal, and the
gap it stopped at is the honest measure of how much is still unknown.
The search, second by secondpenalty score against wall-clock seconds — the roster it has falls, the proof it has rises, and they never touch
best roster found (incumbent)best proof so far (lower bound)each strip is framed on its own values, not on zero — the two are 224x apart and cannot share a scale
Model size68,005boolean variables, 8 search workers
Rosters found23each better than the last
First at27.0sobjective 286,195
Improvement0%off the first legal week it found
Gap left100%what the clock did not resolve
The blue line is the best week found so far and the green one is the proof that nothing cheaper than that value exists. Blue falls quickly — most of the 0% is gone early — and then crawls, which is the usual shape: the easy savings are the obvious ones. Green barely moves, and that is the real story. Proving a roster optimal is far harder than finding a good one, so the gap stays wide even though the roster stopped improving. The word optimal is not available here and is not used.
Agents67spain rules
Coverage98.8%27h short
Spare53hpaid and not needed
Rulesall respectedaudited from the assignment
06Move the numbers yourself
The same pipeline, running in your browser. Change how many people you have, or what you promise them, and watch the matrix rebuild.
Everything above is one scenario. The panel below is the
whole thing — Erlang C, the shift catalogue, the rules audit and the cost model
— ported to JavaScript and running on the page, so the question "what if we were
four people short" takes a few milliseconds instead of a terminal.
This is the greedy roster, not the solver. CP-SAT does not run in a browser, so the panel builds each week the way the warm start does: hand every agent the shift that closes the biggest remaining hole, if the rules still hold. That is instant and it is legal — the audit runs live and will say so if it ever is not — but it leaves more spare hours than the solver. On this week the solver reached 53h spare; the greedy alone lands higher, and the difference is what the sixty seconds of search above bought.
people
36–90
sec
Toronto 311 measures 290 over 1.01M calls
% in 30s
NYC commits to 80%
%
Breaks, training, sickness, holiday
What the roster is allowed to do
The distribution matrixone column per hour,
Monday 00:00 on the left — blue is daytime, amber includes night hours
dayincludes night hours
Agents neededErlang C on the forecastOn the floor against neededred short, blue spare
Every headcount from 36 to 90coverage in green,
weekly cost in amber — the line marks where the slider is
coveragecost per week
07What it costs
Priced against the Spanish sector agreement, premium by premium.
An hour of rostered agent time costs
€12.84: €17,139.58 a year over
1,764 rostered hours is €9.72 gross,
and employer social security adds 32.15%.
Premiums go on the ordinary hour, not the loaded one, which is how the
agreement writes them — and the other order quietly inflates every night shift
by a third. Night work adds a flat amount per hour between 22:00 and 06:00,
Sundays and holidays a flat amount per shift, and overtime a percentage on
top.
One week of this rosterBlended €13.63 an hour, premiums included
quantity
amount
Ordinary hours
2,296 h
€29,481
Night premium
504 h
€1,305
Sunday premium
33 shifts
€506
Holiday premium
0 shifts
€0
Overtime uplift
0 h
€0
Total
2,296 h
€31,292
Week€31,292all premiums in
Year€1.63Mat 52 weeks
Per contact€3.191over 9,807 contacts
Blended hour€13.63vs €12.84 base
Cost per agent, this weekall 67 rostered agents — every bar is the same grade, so the colour is the whole story
Everyone here is on the same grade, so the blue is identical work at an identical rate and every band above it is a scheduling decision. 50 agents carry night hours (€1,305 across the week), 33 work a Sunday (€506), and nobody goes into overtime, which is why there is no pink: the solver prices an overtime hour at a hundred times an ordinary one and will restructure the whole week to avoid a single one. The gap between the cheapest agent at €180 and the dearest at €601 is entirely those bands.
08Where every number came from
Including the two that were looked for and not found.
Every number that came from outside this repositorySorted as declared. 'assumed' means no published figure was found. Every source with a published page links straight to it.
what
confidence
source
value
average handle time on a general-purpose municipal 311 phone line
The weakest input is handling time for one deferred service request. There is no published handle time for email or ticket work in a public-sector contact centre, so eight minutes is an assumption and the whole deferred-work column moves with it. New York publishes no handle time either, which is why the voice figure is Toronto's.