Skip to content

Cost and capacity plan from real demand data

  • Case study 1
  • 120 min
  • Optional · 1 case study needed for the certificate

What you will be able to do

Turn a real public demand series into a cost and capacity plan for an assistant that does not exist yet. You will measure the daily pattern, the busiest minute and the growth. You will choose between three ways to describe the peaks, on a year that you kept aside. Then you feed the result to the course’s design pack, unchanged. You finish with a cost range, the first limit, how far away each limit is, and a growth plan with triggers.

Case studyPortfolio piece

Time
About 2 hours
Runs in
Colab, Kaggle or VS Code
Skills used
  • Demand model from real data
  • Data checks
  • Peak shape against an average day
  • Held-out check
  • Assumption ranges
  • Cost range
  • First limit and headroom
  • Growth signals

Colab and Kaggle open your own copy. To use VS Code, download the notebook from GitHub. Stuck? See the solution notebook.

This case study is a notebook. The work is analysis: count, compare, and feed the result to a calculator. The notebook runs every step of this page in about a minute, on free Colab or on your computer. It downloads the data and the design pack’s calculator. You need no account, no key and no money.

Before you startWhat to know and to have readyAbout 120 minutes

What you need to know

This case study uses the whole course. These lessons matter most:

The page teaches two ideas that the course did not: how to measure a peak shape for the pack from real data, and headroom.

What you need on your computer

  • For the notebook: nothing. It opens in Colab or Kaggle, installs pandas 3.0.6, and downloads 13 files (about 1 MB). In VS Code or Jupyter, use a virtual environment with pandas 3.0.6 and matplotlib. The notebook works in a new folder cost-plan; to start again, delete that folder.
  • For the optional run in your own pack: your folder design-pack from the end of Module 6, or the snapshot end-of-m06.

Tested on macOS (Apple silicon) with Python 3.12.13, pandas 3.0.6, numpy 2.5.3 and matplotlib 3.11.2, in Jupyter from a fresh start. Windows and Linux are not tested.

A city’s 311 line answers residents. They ask about bulky-item pickup, a missed collection, a broken street light, or the parking rules on a snow day. Rosa leads the city’s digital services. She wants a public question-answering assistant on the 311 website and app. It answers from the city’s own service pages, with citations, like the course’s document assistant.

Before she asks for a budget, Rosa asks Jamal, an architect, three questions:

  • “What will it cost per month, as a range?”
  • “Which limit do we hit first, and when?”
  • “When must we change the design, and how will we know?”

The assistant does not exist yet, so it has no traffic. But a city’s 311 requests are public. Jamal uses Boston’s as the demand, and says so: Rosa’s city must check its own numbers. Rosa, Jamal and the assistant are a constructed scenario. Boston publishes the data; it did not ask for this plan.

You will build a cost and capacity plan from real demand data. The design is the course’s design at the end of Module 6, unchanged. It has one modular application with one always-on API replica, and managed PostgreSQL for everything. Chat-small writes every answer, and an answer cache saves repeats. Only the demand changes. You write it as demand.toml, and the design pack’s calculator does the rest.

Two rows of boxes. First row: 311 requests per hour, 2022 to 2025, residents only; then checks for repeats, staff requests, empty hours and the new system; then a month table with the average day, busiest hour and busiest minute. An arrow labelled low, base, high leads to the second row: demand.toml, ranges with evidence for volume, shape and growth; then the design pack at the end of Module 6, unchanged; then the plan, with a thick border: cost range, first limit, headroom and signals.
The case study in one picture. The data becomes ranges in demand.toml; the course's design pack turns them into the plan.
Text description of the diagram

The picture has two rows. In the first row, the residents’ hourly 311 requests pass four checks. Then they become a month table: the average day, the busiest hour and the busiest minute of each month. The low, base and high values go from the month table to the second row. There they become demand.toml, with evidence for each range. The design pack, unchanged since Module 6, reads it. It gives the plan: a cost range, the first limit, the headroom to each limit, and the signals to watch.

The data is Boston’s 311 Service Requests, published on Analyze Boston, the city’s open-data portal. It has one row per request, and its yearly files are 150 to 185 MB each. So Nextia Learning asked the city’s data store for counts per hour and the busiest minute of each hour, and froze the result. The file covers January 2022 to July 2025.

Jamal compared three other sources. San Francisco’s 311 data has the same licence. But the scaling course’s case study Capacity plan from a public traffic trace already uses it, for a queue. Wikimedia’s page views are public domain (CC0) and stable. But they are counted per hour, so the busiest minute cannot be measured. New York’s 311 data has terms of use, not a named open licence.

Jamal loads the file:

hours = load_hours("boston-311-hourly.csv")
print(hours.shape)
print(hours.head(3).to_string(index=False))
print(hours.hour.min(), "to", hours.hour.max())
(31392, 7)
hour requests requests_raw busiest_minute busiest_minute_raw staff_requests month
2022-01-01 00:00:00 2 2 1 1 3 2022-01
2022-01-01 01:00:00 0 0 0 0 6 2022-01
2022-01-01 02:00:00 0 0 0 0 5 2022-01
2022-01-01 00:00:00 to 2025-07-31 23:00:00

What this tells us. One row is one hour in Boston local time, 31,392 hours in all. Hours without a request have a row too. load_hours() adds the month column.

Column What it means Use
requests requests from residents in the hour (app, phone call, web form), repeated rows removed the volume
requests_raw the same, before removing the repeated rows a check
busiest_minute the most requests from residents in one minute of the hour (repeats removed) the peak
busiest_minute_raw the same, before removing the repeats a check
staff_requests requests that city staff created themselves a check

Before any model, Jamal checks who asks, the repeats, the empty hours and the time of day:

years = hours.groupby(hours.hour.dt.year)[["requests", "requests_raw", "staff_requests"]].sum()
print(years)
print("hours without a request:", (hours.requests == 0).sum())
print("repeated rows, share of the volume:", f"{1 - hours.requests.sum() / hours.requests_raw.sum():.2%}")
print("busiest minute with repeats:", hours.busiest_minute_raw.max(), "without:", hours.busiest_minute.max())
of_day = hours.hour.dt.hour
print("requests between 8:00 and 20:00:", f"{hours.requests[(of_day >= 8) & (of_day < 20)].sum() / hours.requests.sum():.1%}")
requests requests_raw staff_requests
hour
2022 131702 131826 141105
2023 163849 164155 143634
2024 145369 145566 161190
2025 70483 70699 89926
hours without a request: 2918
repeated rows, share of the volume: 0.16%
busiest minute with repeats: 51 without: 26
requests between 8:00 and 20:00: 82.7%

What this tells us. Each finding changes a later step:

  • Staff create about half of all 311 requests. City workers report what they see on their rounds. They would not use a residents’ assistant, so the file counts residents only.
  • Repeats are 0.16% of the volume, but they double the busiest minute (51 against 26). The city’s app sometimes sends one report several times. The cost follows the volume, but the provider quota follows the busiest minute. So the plan uses the counts without repeats.
  • 2,918 hours have no request. They are night hours, and they are real zeros.
  • 83% of the requests arrive between 8:00 and 20:00. The pack’s design.toml assumes 12 busy hours a day in its base case. The data agrees with that assumption.
  • The file stops in July 2025. From August 2025 the city moved requests to a new system with other columns. Two systems mixed together would look like a fall in demand.

The unit. One request from a resident stands for one resident who asks the assistant. This is the plan’s central assumption, and it is labelled as one. Some residents file without asking; many more may ask without filing. The pack’s customer_questions_per_day then says how many questions each one asks: 1, 1.5 or 3 (low, base, high). The course assumed these values for Larkfield’s customers.

The question. Which demand, with which peaks and growth, should go into demand.toml? The answer decides the cost range and the first limit.

A split in time. A plan made from the past must work in the future. So Jamal uses three periods, in order:

  • the design months, 2022 and 2023, to choose the method;
  • the check year, 2024, to test the choice once;
  • the new months, January to July 2025, to run the finished method end to end.

A random split would be wrong. Months come in seasons and trends, and the plan must face months that it has not seen.

  • Weekdays
  • Weekends
Boston 311, residents' requests, January 2022 to July 2025: the average number of requests in each hour of the day. Weekdays peak at about 39 an hour at 9:00; nights fall below 1.
Text description of the chart

A line chart of two lines over the 24 hours of the day. On weekdays, requests rise from under 1 an hour at 3:00 to about 39 an hour at 9:00. They stay above 30 an hour until 16:00, and fall to about 4 an hour by 23:00. On weekends, the line is lower and later: it peaks at about 22 an hour at 11:00.

What this tells us. A weekday hour at 9:00 holds about 50 times the requests of an hour at 3:00. An average minute describes no real minute. The plan must be sized for the busiest one, as in Demand assumptions.

Boston 311, residents' requests: the average day of each month from January 2022 to July 2025. The volume rose by about a quarter in 2023, then fell for two years.
Text description of the chart

A line chart of one line, one point per month. In 2022 the average day is between about 290 and 410 requests, with a summer high. In 2023 it climbs to 566 in August and 558 in September. In 2024 it stays between about 370 and 460, and falls to 281 in December. In 2025 it starts at 274 in January and rises to about 370 in June.

What this tells us. The volume has a season, with a summer high. It also has no steady trend: +24% from 2022 to 2023, −11% in 2024, and lower again in 2025. Growth is the least certain number of this plan.

A cook has a recipe for an average dinner of 4 people. The busiest evening of the month brings 10. “The busiest evening has 2.5 times an average dinner” is a useful note: it scales the recipe. “The main course is 30% of the busiest evening’s food” is also true. But it only works when you multiply it by the busiest evening, not by an average one. A ratio is only useful if you know what it is a ratio of.

The design pack goes from a day to its busiest minute in two steps. An average day × the busiest hour’s share gives the busiest hour. The busiest hour ÷ 60 × the busiest minute factor gives the busiest minute. The pack multiplies the questions of an average day, so the share must be a share of an average day. Here the busiest day of a month has about 1.5 times the requests of its average day. A share measured against the busiest day would make the plan’s peak about a third too low. The course’s shape for Larkfield is measured the right way: Demand assumptions divides the help desk’s busiest hour by its average day (101 ÷ 822 = 0.123). But it is another workload’s shape. Boston needs its own.

The idea: measure both numbers against an average day

Section titled “The idea: measure both numbers against an average day”

For each month, Jamal measures two numbers:

  • the busiest hour’s share = the busiest hour ÷ an average day of that month;
  • the busiest minute factor = the busiest minute × 60 ÷ the busiest hour.

Multiplied back, the two numbers rebuild the month’s busiest minute exactly. A range then comes from the months: the quietest month (low), the median month (base) and the busiest month (high).

In March 2023, an average day had 383.5 requests, the busiest hour had 62, and the busiest minute had 6.

march = months.loc["2023-03"]
share = march.busiest_hour / march.mean_day
factor = march.busiest_minute * 60 / march.busiest_hour
print(f"share {share:.3f}, factor {factor:.2f}, busiest minute {busiest_minute(march.mean_day, share, factor):.2f}")
share 0.162, factor 5.81, busiest minute 6.00
A chain of four boxes for March 2023. An average day of 383.5 requests, times 0.162, the busiest hour's share (62 divided by 383.5), gives the busiest hour: 62 requests. Divided by 60 minutes gives an average minute in that hour: 1.03 requests. Times 5.81, the busiest minute factor (6 times 60 divided by 62), gives the busiest minute: 6 requests, with a thick border.
One month by hand. Both numbers are measured against an average day, the day that the pack multiplies, so the pack rebuilds the real busiest minute.
Text description of the diagram

The chain starts with an average day of March 2023: 383.5 requests. The busiest hour’s share is 62 divided by 383.5, which is 0.162, so the busiest hour has 62 requests. Spread over 60 minutes, that is 1.03 requests a minute. The busiest minute factor is 6 times 60 divided by 62, which is 5.81. Times 1.03, that gives the busiest minute: 6 requests, in the last box.

Use it whenever real data replaces an assumed shape. Measure each ratio against the quantity that the calculator multiplies. It costs one table per month and no extra tools. Do not use a single month: one month is one sample. And do not trust the high value alone. The busiest month may hold an event that will not come again, or one that will come bigger.

Jamal compares three ways to fill the two lines of demand.toml. All three use the same volume; only the peak shape differs.

  • Average (the baseline): no peak at all. Every minute is an average minute: share 1/24, factor 1. This is a spreadsheet’s first answer.
  • Borrowed: the shape that the course uses for Larkfield. It comes from the scaling course’s help-desk tickets, also measured against an average day: share 0.10 / 0.123 / 0.20, factor 3 / 5.35 / 8.
  • Measured: the ranges measured on the 24 design months.
measured = shape_ranges(design)
shapes = {"average": SHAPES["average"], "borrowed": SHAPES["borrowed"], "measured": measured}
for name, s in shapes.items():
print(f"{name:9} share {s['peak_hour_share']}, factor {s['peak_minute_factor']}")
average share [0.0417, 0.0417, 0.0417], factor [1.0, 1.0, 1.0]
borrowed share [0.1, 0.123, 0.2], factor [3.0, 5.35, 8.0]
measured share [0.148, 0.173, 0.403], factor [4.337, 5.424, 9.496]

What this tells us. The measured base factor is close to the borrowed one, but the measured base share is higher: 0.173 against 0.123. Residents’ requests crowd into fewer hours than help-desk tickets. And the measured high values are much larger. They come from August 2023 and August 2022, months with sudden runs of reports: up to 26 in one minute. The data cannot say whether those were residents, an app that sent a backlog, or one person. Jamal keeps them as the high case, and says so.

One measure decides. For each month of the check year, take that month’s own average day, and predict its busiest minute with each shape. The real average day keeps the test about the shape alone. A good base case covers most months without a large margin. Nothing in the check year was used to choose anything.

for name, s in shapes.items():
base = busiest_minute(check.mean_day, s["peak_hour_share"][1], s["peak_minute_factor"][1])
high = busiest_minute(check.mean_day, s["peak_hour_share"][2], s["peak_minute_factor"][2])
...
shape base covers base (median) high covers high (median)
average 0 0.29 0 0.29
borrowed 0 4.55 12 11.07
measured 9 6.49 12 26.47
observed busiest minutes, 2024: [6, 5, 6, 6, 5, 5, 6, 5, 6, 6, 5, 5]
  • Observed
  • Measured shape
  • Borrowed shape
  • Average
The check year, opened once: each month's real busiest minute, and the base case of three shapes, each given that month's real average day. The measured shape follows the observed values; the borrowed shape is below them; the average is near zero.
Text description of the chart

A line chart over the 12 months of 2024. The observed busiest minute is 5 or 6 requests every month. The measured shape’s base case runs from about 7.2 in April down to 4.4 in December. It is above the observed values in 9 months, and below them in October, November and December. The borrowed shape’s base case runs between about 3.1 and 5.0, below the observed value in every month. The average stays at about 0.3, near the bottom of the chart.

What this tells us.

  • The average describes no real minute. It predicts 0.29 requests in the busiest minute; every month had 5 or 6.
  • The borrowed shape is too low in its base case: a median of 4.55 against 5 or 6, in every month. It is measured the right way, against an average day, but on another workload. The help desk’s busiest hour holds 0.123 of an average day; Boston’s residents put 0.173 into theirs. A shape belongs to its workload.
  • The measured shape fits. Its base case is about one request above the observed values and covers 9 of 12 months. The three misses are the quiet months at the end of the year, each by less than one request.
  • Every high case except the average covers all 12 months. But the measured high is about four times the observed peak. It is a stress case: it stacks the busiest month’s share on the busiest month’s factor, and those two came from different months.

The same check for the volume. The design months give the requests per day (the quietest month, the average, the busiest month) and a growth rate per month. The notebook grows each value month by month through 2024, and compares it with the real months:

volume, growth = volume_range(design), growth_range(design)
print("requests per day:", volume, " growth per month:", growth)
...
print("months inside low..high:", inside, " base average", round(plan_2024.base.mean()), "actual", round(check.requests.sum() / check.days.sum()))
requests per day: [293.0, 405.0, 566.0] growth per month: {'measured': [-0.0014, 0.0184, 0.0278], 'pack': [0.0, 0.0184, 0.0278]}
months inside low..high: 11 base average 457 actual 397

What this tells us. The range held: 11 of 12 months fell inside it, and December was just below. But the base case expected about 457 requests a day in 2024, and the city had 397: 15% too high. The base growth (+1.84% a month) was the growth of 2023, and it did not happen again. Two years of history are not enough to forecast growth. So the plan does not forecast it; it watches signals, as in Growth plan.

Jamal’s method:

  • Volume: the requests per day of the last 12 months: the quietest month, the average and the busiest month.
  • Shape: the measured share and factor, from every month before the plan date.
  • Growth: the base from the last 12 months against the 12 before; low and high from the slowest and fastest change of one month against the same month a year before. The pack takes no negative growth, so a fall is written as 0, and the evidence says so.
  • The rest of demand.toml (questions per resident, the city’s documents) is assumed, and each value says why.

The function plan(hours, as_of) runs the method. It checks that no hour is missing, measures the ranges and writes demand.toml. Then it runs the pack for 12 months and finds the first signal of each scenario.

First, the plan that Jamal could make at the start of 2025. Then the same method with the seven new months:

before = plan(hours, "2025-01")
latest = plan(hours, "2025-08")
Plan from January 2022 to December 2024: residents' requests per day [281.0, 397.0, 457.0], growth per month [0.0, 0.0, 0.0278]
low US$ 39.95 now, US$ 39.95 in month 12; quota 3% in month 12; first signal: none in 12 months; quota line at 24.99x today's questions
base US$ 47.80 now, US$ 47.80 in month 12; quota 9% in month 12; first signal: none in 12 months; quota line at 7.86x today's questions
high US$ 59.91 now, US$ 61.93 in month 12; quota 94% in month 12; first signal: provider quota in month 2; quota line at 1.04x today's questions
Plan from January 2022 to July 2025: residents' requests per day [274.0, 347.0, 440.0], growth per month [0.0, 0.0, 0.0278]
low US$ 39.92 now, US$ 39.92 in month 12; quota 3% in month 12; first signal: none in 12 months; quota line at 27.24x today's questions
base US$ 47.45 now, US$ 47.45 in month 12; quota 8% in month 12; first signal: none in 12 months; quota line at 8.98x today's questions
high US$ 59.72 now, US$ 61.66 in month 12; quota 90% in month 12; first signal: provider quota in month 3; quota line at 1.08x today's questions

What this tells us. The new months lowered the volume from 397 to 347 requests a day. The base cost fell by only US$0.35, because almost all of it is fixed. The high case’s quota signal moved from month 2 to month 3. The method ran on data that it had not seen, and nothing broke. The demand fell, and the plan says so.

The second run leaves the city’s demand.toml in the pack. Its main lines:

[settings]
days_per_month = 30
peak_hour_share = [0.128, 0.169, 0.403]
peak_minute_factor = [4, 5.373, 9.496]
[tenants.residents]
name = "Residents"
customers_per_day = [274, 347, 440]
user_growth_per_month = [0, 0, 0.0278]
customer_questions_per_day = [1, 1.5, 3]
documents = [150, 300, 600]

Each value has its evidence in the file. The shape, the volume and the growth start with verified: and name the months they were measured on. Questions per resident, documents, words and changes start with assumed:. Nobody has measured them for a city yet.

Then the pack’s own commands, as on the command line:

$ python -m costmodel demand
Demand in month 0 (questions per day; low / base / high)
Residents 274 520 1,320
All tenants 274 520 1,320
Busiest hour 35 88 532
Evidence: 4 values verified, 6 assumed (check the assumed ones first)
$ python -m costmodel units
Unit economics, scenario base, month 0
total US$ 47.45 per month (fixed 95%, variable 5%)
per question (variable only) US$ 0.000157
per question (everything) US$ 0.003039
per document change US$ 0.000230 (embed + summary + worker)
Residents US$ 47.45 per month
$ python -m costmodel growth --scenario high
Growth (high): busiest minute against the provider quota, storage, cost
month tenants questions/day calls/quota tokens/quota storage US$/month signals
0 1 1,320 59% 65% 0% 59.72 -
1 1 1,357 61% 67% 0% 59.86 -
2 1 1,394 62% 68% 0% 60.00 -
3 1 1,433 64% 70% 1% 60.15 provider quota
...
12 1 1,834 82% 90% 1% 61.66 provider quota
First signal: provider quota in month 3

What this tells us. In the base case, 347 residents a day × 1.5 questions = 520 questions a day: about a third of Larkfield’s 1,465. At the busiest minute, 0.29 answers are in flight (python -m costmodel workload). The cost is US$47.45 a month, and 95% of it is fixed. The API replica (US$27.43) and the database server (US$13.50) cost the same with no questions at all. So “a cent per question” is wrong in both directions. One more question costs US$0.000157. An average question costs US$0.003 with everything included. That is more than twice Larkfield’s US$0.00118, because fewer questions share the same fixed costs.

Run it in your own pack (optional)

The notebook writes cost-plan/design-pack/demand.toml. Download it from the notebook’s file panel. To run the commands in your own pack, keep Larkfield’s file and copy the city’s file in. In your folder design-pack, with the environment active:

Terminal window
cp demand.toml demand-larkfield.toml
cp ~/Downloads/demand.toml demand.toml
python -m costmodel growth --scenario high

The output is the one above. When you finish, copy demand-larkfield.toml back to demand.toml. Four of the pack’s tests check Larkfield’s numbers, so they fail with the city’s file.

A lift has a sign: “8 people”. Two people ride it now. “It is not full” is true, but it is not the useful answer. The useful answer is “four times today’s riders before the limit”. That number tells you how much the building can grow before the lift matters.

Headroom is how many times today’s demand the design can take before a line is crossed. Here the lines are the pack’s three growth signals: 70% of the provider quota, 80% of the database storage, and the budget of US$150 a month. The function headroom() multiplies the questions by a factor in the pack’s calculator. It finds the smallest factor that crosses each line. It answers a question that a forecast cannot: how wrong can the demand be before it matters?

Predict. In the base case, how many times today’s questions can the design take before the quota’s line? Before the budget?

for scenario in ["base", "high"]:
print(scenario, headroom(scenario))
base {'quota': 8.98, 'storage': None, 'budget': 42.75}
high {'quota': 1.08, 'storage': None, 'budget': 17.93}
  • Base case
  • High case
Headroom: how many times today's questions the course's design takes before each line of the growth plan (month 0). Storage is not shown: it does not reach its line within 200 times today's questions.
Text description of the chart

A column chart with four columns. In the base case, the quota’s line (70% of the provider quota) is at 9.0 times today’s questions, and the budget at 42.8 times. In the high case, the quota’s line is at 1.1 times, right at today’s demand, and the budget at 17.9 times. Storage is not a column: it stays under its line even at 200 times today’s questions.

What this tells us. In the base case, the first limit is the provider quota, and it is far away. It is about 9 times today’s questions, or about 4,700 questions a day. The budget comes later, at about 43 times. The storage line is never reached. In the high case, the quota’s line is right at today’s demand: 1.08 times. Only the quota needs a plan, as in Larkfield’s case.

The headroom also changes Rosa’s question. The real unknown is not how many residents file a request. It is how many residents will ask without filing one. If nine times as many people ask as file today, the base case reaches the quota’s line. Nobody knows that ratio before launch, so it becomes the plan’s main signal.

Jamal writes it in the form of Growth plan and Implementation roadmap. Every number has its kind.

  • The cost, as a range: US$39.92 (low), US$47.45 (base) and US$59.72 (high) a month now. The high case reaches US$61.66 in month 12. These are measured demand and measured AI facts × list prices × stated assumptions. Prices change: check the current page.
  • The first limit: the provider quota. In the high case, the busiest minute’s tokens cross 70% of the quota in month 3. They reach 90% in month 12. In the base case, the line is about 9 times today’s questions away. Storage stays at 1% or less of the 32 GB. The cost stays far under the budget.
  • The design: the course’s design, unchanged. Nothing in the data asks for more replicas, another database or a second region.
  • The growth plan:
Signal Line Step Kind
tokens in the busiest minute (growth) 70% of the provider quota ask the provider for a higher quota; queue residents’ questions at the peak quota measured, line chosen
questions a day, from the assistant’s own log 9 × today’s base, about 4,700 replace the 311 demand with the log, and run the plan again headroom, computed
the city’s 311 requests a day above the plan’s high (440) for a whole month run plan() again with the new months measured
monthly cost (growth) the budget, US$150 (the course’s budget line, assumed for the city) change the biggest driver first: the API replica price × design
  • If the city puts the assistant on its home page, plan for the high case: ask for the higher quota before launch. A request takes time, and the high case crosses the line in month 3.
  • What it does not cover: another city’s traffic, a launch campaign, or a storm that brings a bigger run of reports than August 2023’s.

What if Jamal had skipped the repeat check? The notebook rebuilds the shape with the raw busiest minutes. Then it computes the high case’s busiest minute: 440 residents who ask 3 questions each.

raw_hours = hours.assign(busiest_minute=hours.busiest_minute_raw)
raw_shape = shape_ranges(month_table(raw_hours).loc["2022-01":"2025-07"])
clean_shape = shape_ranges(months)
for name, s in [("without repeats", clean_shape), ("with repeats", raw_shape)]:
print(f"{name:16} factor {s['peak_minute_factor']}, high busiest minute for 440 residents x 3 questions:",
round(busiest_minute(440 * 3, s["peak_hour_share"][2], s["peak_minute_factor"][2]), 1))
without repeats factor [4.0, 5.373, 9.496], high busiest minute for 440 residents x 3 questions: 84.2
with repeats factor [4.337, 5.455, 48.261], high busiest minute for 440 residents x 3 questions: 427.9

What this tells us. The repeats are 0.16% of the rows. But they raise the high factor from 9.5 to 48.3. The high busiest minute rises from 84 to 428 questions: more than four times the provider quota. The plan would ask for a quota that nobody needs. Clean the data that sets the peak; the volume barely notices.

Common mistake

A falling demand as a negative growth

Symptom: you write the measured growth into demand.toml, user_growth_per_month = [-0.0307, -0.0199, 0.0278], and the pack stops with:

Input problem: 'residents.user_growth_per_month' is negative

Cause: the pack checks every range, and it takes no negative values. The demand fell by about 2% a month in the last year.

Fix: a fall lowers the cost, so plan with 0 and say so in the evidence, as plan() does. For a smaller plan, lower the volume instead.

Common mistake

A borrowed peak shape

Symptom: the plan’s base busiest minute is about 4.5 requests, but the real months have 5 or 6.

Cause: the shape came from another workload, the help desk’s tickets. Its share is right for the help desk, but residents’ requests crowd into fewer hours. The same symptom, about a third too low, comes from a share of the busiest day, because the pack multiplies an average day.

Fix: measure the share and the factor on your own data, against the quantity that the pack multiplies, as in the March 2023 example.

Insights.

  • The design does not need to change for this city. At the base demand, the quota’s line is 9 times away, and storage never reaches its line. The cost is 95% fixed, so more residents make each answer cheaper.
  • The peak, not the volume, sets the first limit. A cleaning step that changes 0.16% of the volume changes the high peak five times.
  • Growth from history failed its test. The check year was 15% below the base case. Signals with lines replace the forecast.

Limits.

  • A 311 request is a stand-in for a question. The plan’s largest assumption is that each resident who files is one resident who asks. The headroom says how wrong that can be (9 times) before it matters.
  • Questions per resident and the city’s documents are assumed. They are the course’s values or Jamal’s estimates, and the evidence in demand.toml says so.
  • One city, 43 months. Boston’s data is not Rosa’s city. Use the method for her city, not these numbers.
  • The busiest minute is a small number. With 5 or 6 requests, one more request changes the factor a lot. At the assistant’s higher volume, minutes are likely smoother, so the factor is probably too high. The plan errs on the safe side.
  • The pack’s growth signal is monthly. It does not see a single storm day. The running assistant’s quota alarm must.
  • The data is about real residents. The sample has only counts per hour: no names, addresses, types or descriptions. The method needs nothing more. Do not join it with the full dataset to study individual people. A run of 26 reports in one minute is a load pattern, not a person to investigate.
  • Say which numbers are assumed. A city budget built on “one request = one resident who asks” must carry that sentence next to the number.
  • Do not let a plan reduce a public service. If the quota is reached, residents’ questions wait in a queue; they must never be dropped in silence. A resident can always reach 311 by phone or form.
  • Reuse the method, not the numbers. Another city has another day, another season and another app.

This case study shows an employer that you can turn “what will it cost?” into a range that you can defend. It uses real data, and every assumption has a label. It also shows that you test a method once, on data that it has not seen. Make it yours with one change: another city’s open 311 data (check its licence first), or your own assumption for questions per resident.

What to put in the repository:

  • README.md (template below).
  • The notebook, run from top to bottom, with its outputs and charts.
  • The demand.toml that the plan wrote, with its evidence lines.
  • PLAN.md: the cost range, the first limit, the headroom and the growth plan, each number with its kind.
  • The data source and licence. PDDL needs no credit, but credit the city anyway, and give the checksum of your copy.

A README template:

# Cost and capacity plan for a city's 311 question assistant
## Question
What does a public question-answering assistant cost per month, which limit
comes first, and when must the design change?
## Data and licence
Boston 311 Service Requests (Analyze Boston), residents' requests per hour,
Jan 2022 - Jul 2025. ODC PDDL 1.0. Repeats and staff requests removed.
## Approach
Peak shape measured against an average day (busiest hour's share, busiest
minute factor). Three shapes (average, borrowed, measured) checked once on
2024. Ranges written to demand.toml; the course's design pack, unchanged.
## Results
Measured shape: 9 of 12 months covered (average 0, borrowed 0).
Cost: US$39.92 / 47.45 / 59.72 a month (95% fixed in the base case).
First limit: provider quota, high case month 3; base headroom 9x.
## Limits
One request = one resident who asks (assumed). Questions per resident and
documents assumed. One city.

How to describe it in an interview, in two sentences: “I sized a public AI assistant from a city’s real 311 requests: a peak shape measured against an average day fit a held-out year. The design costs about US$47 a month, almost all fixed, and its first limit is about nine times today’s demand away.”

Go further:

  • Run plan() on another city’s 311 counts, and compare the shapes.
  • Add the weekday to the shape: is the busiest minute always on a Monday?
  • Replace the high case’s stacked values with the single worst month, and compare the first signal.

Check your understanding

The borrowed shape (share 0.123, factor 5.35) is measured against an average day, as the pack needs. Why did it cover none of the 12 check months in its base case?

Show the answer

Because it is another workload’s shape. Both shapes are measured against an average day, and their minute factors are close (5.35 and 5.42). But the help desk’s busiest hour holds 0.123 of an average day, and Boston’s residents put 0.173 into theirs: their requests crowd into fewer hours. So the borrowed base case predicted about 4.5 requests in the busiest minute, against 5 or 6 in every month of 2024. The measured shape predicted about 6.5 and covered 9 of 12 months. A ratio must be measured against the right quantity, and on the right workload.

Check your understanding

The new months lowered the demand from 397 to 347 requests a day, about 13% less. Why did the base cost fall by only US$0.35?

Show the answer

95% of the base cost is fixed. The always-on API replica (US$27.43) and the database server (US$13.50) cost the same at any volume. So does the 32 GB of storage. Only the variable part follows the questions: about US$0.000157 per question, mostly model tokens. So 13% fewer questions change the total by a few tenths of a dollar. For the same reason, the cost per question falls as the volume rises.

Check your understanding

Rosa asks: 'What is the biggest risk in this plan?' Answer with the headroom.

Show the answer

The biggest risk is the assumption that one 311 request is one resident who asks. Residents may ask the assistant without ever filing a request. The headroom says how wrong this can be before it matters. The base case reaches the provider quota’s line at about 9 times today’s questions, about 4,700 a day. The budget is about 43 times away, and storage never reaches its line. So the plan watches the assistant’s own questions a day from launch, with a line at about 4,700. If the city puts the assistant on its home page, it asks for a higher quota before launch. The high case crosses the line in month 3.

Recap

  • You checked real demand data. You kept residents only and the empty hours, and avoided a change of system. Repeats were 0.16% of the volume, but half the busiest minute.
  • You measured the peak against an average day, because that is what the pack multiplies. On the check year, used once, the measured shape covered 9 of 12 months; the average and the borrowed shape covered none.
  • Growth from two years was 15% too high on the check year, so the plan watches signals instead of a forecast.
  • With the course’s design unchanged: US$39.92 to 59.72 a month, 95% fixed in the base case. The first limit is the provider quota: month 3 in the high case, about 9 times away in the base case.
  • Headroom turned the unknown “how many residents will ask?” into a signal with a line.

Next, the other case study: a design review of an open document-question service.

Next: Design review of an open document-question service