How-to
Automating Reporting WorkflowsDeep dive

Build a Capacity Planning Report

Compare the work in the order book with the hours each work centre can actually deliver, week by week, with pandas: turn orders into required hours through a routing table, calculate available capacity from shifts, efficiency, holidays and planned maintenance, show utilisation as a heatmap, find overloaded weeks, and test whether pulling work forward into spare weeks clears the backlog.

A capacity plan answers a simple question that is surprisingly hard to answer from a spreadsheet of orders: can we make all of this by when it is due? It needs two numbers for every work centre and every week — the hours of work the orders require and the hours the work centre can actually deliver — and a comparison of the two. Required hours come from the order book and a routing table that says how long each product spends on each machine. Available hours come from the shift pattern, reduced by efficiency, public holidays and planned maintenance. The comparison shows which weeks are overloaded, and a simple simulation shows whether moving work into earlier weeks with spare capacity would be enough, or whether overtime, a second shift or new due dates are needed. This page builds that report with pandas and writes it to Excel with a utilisation heatmap. It belongs to Building Finance and Operations Reports.

Prerequisites

Bash
pip install pandas openpyxl holidays

The input workbook has four sheets: open orders by product and due week for the next twelve weeks, a routing table (hours per unit for each product on each work centre), the work centres with machine counts, shift hours, working days and efficiency, and planned maintenance. Public holidays come from the holidays package for England.

How the report fits together

Capacity plan data flow Orders are multiplied through the routing table into required hours, which are divided by available hours from the work centre settings, holidays and maintenance. The resulting utilisation feeds the overload list, the levelling test and the Excel heatmap. Open orders qty by due week × Routing hours per unit Required hours centre × week ÷ Available hours net of holidays Utilisation overloads and heatmap

Keeping the two sides separate until the very end matters. Demand changes every day as orders arrive and move; capacity changes when shifts, staffing or machines change. When they are calculated independently, either side can be updated or questioned without touching the other, and a planner who disagrees with a utilisation figure can see immediately whether the disagreement is about the load or about the hours.

Step 1 — Required hours per work centre and week

Python
import pandas as pd

orders = pd.read_excel("capacity_inputs.xlsx", sheet_name="Orders")
routing = pd.read_excel("capacity_inputs.xlsx", sheet_name="Routing")
centres = pd.read_excel("capacity_inputs.xlsx", sheet_name="Work centres").set_index("Work centre")
maint = pd.read_excel("capacity_inputs.xlsx", sheet_name="Maintenance")

load = orders.merge(routing, on="Product", validate="many_to_many")
load["Hours"] = load["Qty"] * load["Hours per unit"]
required = load.pivot_table(index="Work centre", columns="Due week", values="Hours", aggfunc="sum", fill_value=0)
print(required.round(0).astype(int).iloc[:, :6].to_string())

Each order line is multiplied out through the routing: a product that visits two work centres creates load on both. The merge is deliberately many-to-many — one order has several routing steps, one routing step serves many orders — which is the rare case where duplicating rows is exactly right, and saying so with validate documents the intent. The result is a grid of required hours, work centres by week. For simplicity all of an order's load falls in its due week; a finer plan would offset earlier routing steps by their lead time.

Step 2 — Available hours, net of efficiency, holidays and maintenance

Python
import holidays
import numpy as np

weeks = required.columns
uk = holidays.country_holidays("GB", subdiv="ENG", years=[2026, 2027])
def working_days(week_start, days):
    dates = pd.date_range(week_start, periods=days, freq="D")
    return sum(d.date() not in uk for d in dates)

available = pd.DataFrame(index=centres.index, columns=weeks, dtype=float)
for wc, c in centres.iterrows():
    for w in weeks:
        hours = c["Machines"] * c["Shift hours per day"] * working_days(w, int(c["Days per week"])) * c["Efficiency"]
        down = maint.loc[(maint["Work centre"] == wc) & (maint["Week"] == w), "Hours"].sum()
        available.loc[wc, w] = hours - down * c["Efficiency"]
util = required / available
print(available.round(0).astype(int).iloc[:, :6].to_string())
print((util * 100).round(0).astype(int).to_string())

Nominal capacity — machines × shift hours × working days — is never achieved: changeovers, breaks and small stops eat into it, so each work centre's efficiency scales it to the hours of useful output that can be expected. Public holidays remove days, which is why late December shows less capacity than other weeks. Planned maintenance removes a fixed number of machine hours in its week. Dividing required by available hours gives utilisation: below about 85% is comfortable, approaching 100% leaves no room for breakdowns, above 100% means the week's work cannot be done as planned.

From nominal to available hours Machines times shift hours times working days gives nominal hours. Public holidays reduce working days, planned maintenance removes machine hours, and the work centre's efficiency scales what is left to the hours of useful output expected. Machines × shift hours × days nominal capacity − Public holidays fewer working days − Planned maintenance machine hours down × Efficiency 75–85% useful = Available hours compared with load

Step 3 — Overloaded weeks, and whether levelling helps

Python
over = util.stack()
over = over[over > 1]
print("overloaded:", [(wc, f"{w:%d %b}", f"{u:.0%}") for (wc, w), u in over.items()])

def level(req, avail):
    """Pull excess hours into earlier weeks with spare capacity; report what still cannot fit."""
    req, avail = req.copy(), avail.copy()
    planned = pd.Series(0.0, index=req.index)
    spare = (avail - req).clip(lower=0)
    for i, w in enumerate(req.index):
        excess = max(req[w] - avail[w], 0)
        for earlier in reversed(req.index[:i]):
            move = min(excess, spare[earlier])
            spare[earlier] -= move
            excess -= move
        planned[w] = excess
    return planned

late = pd.DataFrame({wc: level(required.loc[wc], available.loc[wc]) for wc in required.index}).T
print("hours that cannot be made on time even after pulling work forward:")
print(late.loc[:, late.sum() > 0].round(0).to_string())

The overload list is the headline: which work centre, which week, by how much. Welding is the only bottleneck: it runs just over capacity in early November, peaks at 189% in the week of 9 November, when the November surge of frame and housing orders coincides with forty hours of planned maintenance, and stays above 150% for the following two weeks. Laser and press never pass about 75%. The levelling function asks the obvious next question: if the excess in a week is made earlier, in weeks with spare hours, how much is still left over? It walks back from each overloaded week, using spare capacity in the nearest earlier weeks first, which is what a planner would do because it keeps stock built early to a minimum. Hours that still cannot be placed are a real shortage, to be met with overtime, an extra shift, subcontracting or a conversation about due dates. In this data, levelling absorbs the whole maintenance-week overload into the quieter October weeks, but that spare capacity is then used up: about 47 hours in the week of 16 November, 52 in the week of 23 November and 4 in the week of 30 November still cannot be made on time — roughly a hundred welding hours, or two to three weeks of a Saturday overtime shift.

Utilisation by work centre and week Laser and press stay below about 75 percent in every week. Welding is just over 100 percent in the week of 2 November, peaks at 189 percent in the maintenance week of 9 November and stays above 150 percent for the next two weeks, which the heatmap shows in red. A B C D E F 1 Work centre 26 Oct 02 Nov 09 Nov 16 Nov 23 Nov 2 Laser 26% 43% 66% 50% 59% 3 Press 56% 57% 56% 71% 68% 4 Weld 75% 101% 189% 169% 158%

Step 4 — The workbook with a heatmap

Python
from openpyxl import load_workbook
from openpyxl.formatting.rule import ColorScaleRule

path = "capacity_plan_2026-q4.xlsx"
fmt = lambda df: df.set_axis(df.columns.strftime("%d %b"), axis=1)
with pd.ExcelWriter(path, engine="openpyxl") as xw:
    fmt(util).to_excel(xw, sheet_name="Utilisation")
    fmt(required.round(1)).to_excel(xw, sheet_name="Required hours")
    fmt(available.round(1)).to_excel(xw, sheet_name="Available hours")
    fmt(late.round(1)).to_excel(xw, sheet_name="Shortfall after levelling")
wb = load_workbook(path)
ws = wb["Utilisation"]
last = ws.cell(1, ws.max_column).column_letter
ws.conditional_formatting.add(f"B2:{last}{ws.max_row}", ColorScaleRule(start_type="num", start_value=0.6, start_color="63BE7B",
                              mid_type="num", mid_value=0.9, mid_color="FFEB84", end_type="num", end_value=1.1, end_color="F8696B"))
for row in ws.iter_rows(min_row=2, min_col=2):
    for cell in row:
        cell.number_format = "0%"
wb.save(path)
print(wb.sheetnames, "| peak utilisation:", f"{util.max().max():.0%}")

The Utilisation sheet is the one people look at: work centres down the side, weeks across, coloured from green below 60% through yellow at 90% to red above 110%, with fixed anchors so the colours mean the same every time the report is run. The required and available hours sheets show where the percentages come from, and the shortfall sheet shows what levelling could not solve. Colour scales with fixed thresholds are covered in Add Data Bars and Colour Scales with openpyxl.

Keeping the plan honest

A capacity plan is only as good as its two inputs, and both drift. Routing times are often set once and never revisited; compare them with actual booked hours from the shop floor every few months, because a routing that is 20% optimistic makes every week look 20% emptier than it is. Efficiency factors drift the same way. On the demand side, the order book covers firm orders only, so the later weeks look light simply because orders have not arrived yet — a forecast of likely demand, kept separate from firm orders, gives a fairer view of the weeks further out. Finally, run the plan weekly and keep each run: the history shows whether overloads are being resolved or just pushed forward a week at a time. Store each run with its date in the file name, and the trend of peak utilisation over successive runs becomes a useful early warning in its own right.

Common pitfalls

  • Nominal hours as capacity. Scale by efficiency and remove holidays and maintenance.
  • Load only on the final step. Use the routing so every work centre sees its load.
  • One utilisation figure for the quarter. Weekly peaks are what cause late orders.
  • Levelling by moving work later. Pull forward into spare earlier weeks instead.
  • Stale routings. Compare standard hours with actual hours regularly.

Conclusion

To plan capacity, multiply open orders through a routing table into required hours per work centre and week, and compute available hours from machines, shift hours, working days, efficiency, holidays and maintenance. Divide one by the other for utilisation, list the overloaded weeks, test whether pulling excess work into earlier spare weeks clears them, and write a colour-coded utilisation sheet with the hours and the remaining shortfall behind it.

Frequently asked questions

How do I calculate required capacity from orders in pandas? Merge the order lines with a routing table of hours per unit by product and work centre, multiply quantity by hours, and pivot by work centre and week.

How do I calculate available capacity? Multiply machines by shift hours by working days, subtract planned maintenance, and multiply by an efficiency factor; remove public holidays from the working days.

What utilisation is too high? Above about 85 to 90 percent leaves little room for breakdowns and rush orders; above 100 percent the planned work cannot be done in that week.

How can I test whether moving work earlier solves an overload? Walk back from each overloaded week and move excess hours into earlier weeks with spare capacity; whatever cannot be placed is a real shortfall.

More on report layouts & templates

More on report layouts & templates →