Rolling Up OEE from Line to Plant with Weighted Aggregation
Two analysts given the same five machines’ shift data can produce two different plant OEE numbers, and only one of them is right — the difference is almost always that one averaged percentages and the other summed the durations and counts behind them. This page works the counterexample in full and derives the correct weighted-aggregation rule, extending the reporting contract from OEE reporting and shift rollups down to the arithmetic a spreadsheet-literate engineer can check by hand. It matters because the wrong rollup is not a rounding error; on an unbalanced line it can move the reported number by ten points or more, in either direction, and every one of those points reaches an executive dashboard looking equally plausible.
The mean-of-means fallacy, worked Permalink to this section
Take three assets on one line for an eight-hour shift. Asset A ran one hour at 90% OEE, Asset B ran four hours at 70% OEE, and Asset C ran three hours at 20% OEE — a changeover-heavy day. A naive rollup averages the three percentages:
That number gives Asset A’s single hour the same influence on the line figure as Asset C’s three hours — mathematically, it treats “OEE” as if it were a unitless score rather than a ratio built from durations that differ in size. The correct approach never touches the three percentages directly. It reconstructs the additive components — operating time, lost time, counts — sums them across the three assets, and divides once:
This time-weighted approximation (valid when each asset’s Performance and Quality contribute roughly evenly per unit of run time) already diverges from the naive mean by more than six points. The rigorous version does not weight the finished OEE ratios at all — it goes one level deeper and sums Availability’s own numerator and denominator, Quality’s own numerator and denominator, and so on, exactly as shown in the code below, which is the identity used throughout OEE reporting and shift rollups. On a real shift, where Performance and Quality also vary independently per asset rather than moving in lockstep with OEE, the fully rigorous sum-of-components result can diverge from the naive mean by considerably more than this approximation shows — the point of the shortcut here is only to demonstrate that averaging ratios is wrong in direction as well as magnitude, not to stand in for the production calculation.
from dataclasses import dataclass
from decimal import Decimal
@dataclass(frozen=True)
class AssetShiftFacts:
asset_id: str
planned_seconds: Decimal
downtime_seconds: Decimal
run_seconds: Decimal
total_count: int
good_count: int
ideal_cycle_time_sec: Decimal
def naive_mean_oee(assets: list[AssetShiftFacts], per_asset_oee: list[Decimal]) -> Decimal:
"""The wrong way: average the finished ratios. Included only to demonstrate divergence."""
return sum(per_asset_oee) / Decimal(len(per_asset_oee))
def weighted_rollup_oee(assets: list[AssetShiftFacts]) -> dict[str, Decimal]:
"""The correct way: sum additive components, divide once."""
planned = sum((a.planned_seconds for a in assets), Decimal("0"))
downtime = sum((a.downtime_seconds for a in assets), Decimal("0"))
run = sum((a.run_seconds for a in assets), Decimal("0"))
total_count = sum(a.total_count for a in assets)
good_count = sum(a.good_count for a in assets)
ideal_time = sum((a.ideal_cycle_time_sec * a.total_count for a in assets), Decimal("0"))
if planned <= 0:
return {k: Decimal("0") for k in ("availability", "performance", "quality", "oee")}
availability = (planned - downtime) / planned
performance = ideal_time / run if run > 0 else Decimal("0")
quality = Decimal(good_count) / Decimal(total_count) if total_count > 0 else Decimal("0")
def clamp(v: Decimal) -> Decimal:
return min(max(v, Decimal("0")), Decimal("1")).quantize(Decimal("0.0001"))
a, p, q = clamp(availability), clamp(performance), clamp(quality)
return {"availability": a, "performance": p, "quality": q, "oee": clamp(a * p * q)}
naive_mean_oee is retained in the codebase deliberately — as a labeled anti-pattern with a docstring that says so — because the surest way to keep a future refactor from reintroducing it is to make the wrong function impossible to reach for by accident while leaving its name searchable in code review.
Time- and count-weighted aggregation math Permalink to this section
The general rule is that every OEE factor keeps the weighting basis implied by its own definition, the same definitions pinned in OEE formula validation. Availability is time over time, so it is weighted by time (planned production seconds). Quality is count over count, so it is weighted by count (total parts). Performance mixes both — an ideal-time numerator built from each asset’s own cycle time and count, divided by a run-time denominator — so it is weighted by count on top and time on the bottom, which is exactly what ideal_time / run computes above.
A rollup that instead weights every factor by a single shared basis — say, always by run time, because it is the easiest column to find — produces a Quality figure that overweights whichever asset happened to run longest, even if a short-running asset actually produced a disproportionate share of the scrap. That failure is subtle because Quality still looks like a valid percentage; it is simply the wrong percentage, and there is no bounds check that will ever catch it, because nothing about it violates . The only defense is using the correct additive numerator and denominator for each factor, never a borrowed weight from a different factor.
Bottleneck vs parallel line topology Permalink to this section
The weighting rule above assumes every asset’s run time is a fair proxy for how much it should count toward the group figure. That assumption holds for a serial line, where one slow or down station gates the whole line’s throughput — the line genuinely can only produce what its bottleneck allows, so summing run time and downtime across every station on that line correctly reflects the line’s real output constraint.
It breaks for parallel, redundant lines. If Line 1 and Line 2 both make the same part and Line 2 absorbs demand while Line 1 sits idle for a planned reason (no orders routed to it), summing their run times and downtimes together into one “plant OEE” buries the fact that Line 2 alone determined the plant’s actual output that shift. A plant-level Availability computed by naively summing both lines’ planned and downtime seconds understates how well the plant performed against the demand it was actually asked to meet, because it charges Line 1’s idle time against a schedule Line 1 was never asked to run. The topology map below should live next to the shift-level job in automating OEE shift reports with Python and TimescaleDB, so a plant rollup reuses the same per-line classification every time it runs rather than re-deciding it per report.
from enum import Enum
class LineTopology(Enum):
SERIAL = "serial" # one path; a station's downtime gates the whole line
PARALLEL = "parallel" # redundant capacity; one line's idle time is not a plant loss
def plant_rollup(
lines: dict[str, list[AssetShiftFacts]],
topology: dict[str, LineTopology],
) -> dict[str, Decimal]:
"""Roll up to plant level, excluding parallel lines with zero scheduled demand
from the Availability denominator rather than charging their idle time as loss.
"""
included: list[AssetShiftFacts] = []
for line_id, assets in lines.items():
line_had_demand = any(a.total_count > 0 or a.run_seconds > 0 for a in assets)
if topology[line_id] is LineTopology.PARALLEL and not line_had_demand:
continue # unscheduled redundant capacity: exclude, do not penalize
included.extend(assets)
return weighted_rollup_oee(included)
The exclusion rule above is deliberately narrow: it only drops a parallel line when it shows zero demand for the whole window, not merely low output. A parallel line that ran partial hours and underperformed is a real availability loss and must stay in the sum; only a line with no scheduled work at all is a scheduling decision, not a loss, and belongs outside the plant-level Availability denominator the same way a scheduled maintenance window is subtracted from planned time in calculating OEE with overlapping maintenance windows.
Gotchas & anti-patterns Permalink to this section
- Weighting every factor by the same basis for convenience. Using run time as the weight for Quality because it is already in scope will silently misattribute scrap between assets that run different hours.
- Averaging OEE at the dashboard layer even when the pipeline computed it correctly. A
GROUP BY line_idwithAVG(oee_pct)in a BI tool reproduces the fallacy downstream of a correct batch job; only publish the additive totals, or a pre-computed weighted view, to reporting tools. - Treating an idle parallel line as a plant-wide availability loss. Summing its zero-demand hours into the plant denominator penalizes the plant for a capacity-planning decision, not a production failure.
- Silently including a zero-count asset’s Quality contribution. An asset with
total_count = 0contributes nothing toSUM(good_count)orSUM(total_count), which is correct — but verify the rollup code path does not divide by a per-asset count before summing, which would raise or (worse) get guarded into a silent zero that skews the plant figure. - Rolling up a bottleneck-gated serial line as if lines were independent. If Station 3 on a five-station serial line is the constraint, the line’s Availability must reflect Station 3’s downtime even when Stations 1, 2, 4, and 5 ran the whole shift — summing all five stations’ run and downtime seconds captures this correctly only if every station’s planned time reflects the same shared schedule.
Quick reference Permalink to this section
| Line topology | Weighting rule | Availability denominator |
|---|---|---|
| Serial (bottleneck-gated) | Sum run time and downtime across every station on the line | Full scheduled planned time for the line |
| Parallel, both lines scheduled | Sum run time and downtime across both lines | Sum of both lines’ planned time |
| Parallel, one line idle by schedule | Sum only the scheduled line’s run time and downtime | Only the scheduled line’s planned time; exclude the idle line |
| Cross-plant rollup | Sum line-level totals (already correctly weighted) one level up | Sum of all included lines’ planned time |
Related Permalink to this section
- OEE reporting and shift rollups — parent guide covering the reporting contract this page derives.
- Automate OEE shift reports with Python and TimescaleDB — the scheduled job this weighting logic plugs into.
- OEE formula validation — the per-asset additive identity this page rolls up.
- Calculating OEE with overlapping maintenance windows — merging planned-downtime ranges before they enter the sums here.