Individual Moving Range (I-MR) Charts: Automated n=1 Process Monitoring in Python
The Individual Moving Range (I-MR) chart is the control chart of choice when you can only measure one unit at a time and rational subgroups cannot be formed — high-mix low-volume machining, destructive or expensive tests, single-point CMM captures, and continuous chemical streams that produce one homogeneous reading per interval. Within the broader SPC Fundamentals & Control Chart Taxonomy, it is the n=1 counterpart to subgroup-based variable charts: instead of averaging out short-term variation, it monitors process stability at the level of each individual observation and derives dispersion from the moving range between consecutive points. Automating it correctly means treating the individuals (X) chart and the moving-range (MR) chart as one locked baseline, not two independently drifting calculations.
What Breaks in Production Without a Locked I-MR Baseline
The failure mode that ruins automated I-MR deployments is recomputing limits on every incoming point. Because $\hat{\sigma}$ is estimated from the average moving range, appending live data quietly widens or narrows the band, so a real shift gets absorbed into a moving baseline and the chart signals nothing. The mirror-image failure is autocorrelation: in thermal loops, tank levels, and slow chemical reactions consecutive readings are serially correlated, the moving range collapses toward zero, $\hat{\sigma}$ is understated, and the individuals limits pinch so tightly that common-cause noise trips the chart on nearly every point. Both produce the same operational outcome — an SPC layer nobody trusts, alarms that get muted, and out-of-specification product shipping while the chart reads "in control."
A third production trap is span misuse. The whole I-MR method is calibrated to a moving range of span 2 (the absolute difference of adjacent points), which fixes the bias-correction constant $d_2 = 1.128$. Change the span, resample the series, or interleave two process streams into one column and that constant is silently wrong, so every limit on the page is wrong with it. A robust automated engine therefore validates input shape, computes the baseline once in Phase I, freezes it, and evaluates Phase II data against the frozen limits.
Statistical Specification
The I-MR system is a pair of coupled charts driven by one dispersion estimate.
The moving range at point $i$ is the absolute first difference:
$$MR_i = |x_i - x_{i-1}|$$
Averaging the $k-1$ moving ranges over the $k$ baseline points gives the process-sigma estimate through the span-2 bias constant $d_2 = 1.128$:
$$\overline{MR} = \frac{1}{k-1}\sum_{i=2}^{k} MR_i \qquad \hat{\sigma} = \frac{\overline{MR}}{d_2} = \frac{\overline{MR}}{1.128}$$
Individuals (X) chart — centered on the mean of the observations, $\bar{x}$:
$$CL_X = \bar{x} \qquad UCL_X = \bar{x} + 3\hat{\sigma} = \bar{x} + 2.660\,\overline{MR} \qquad LCL_X = \bar{x} - 2.660\,\overline{MR}$$
Moving Range (MR) chart — centered on $\overline{MR}$, using the range constants for $n=2$:
$$CL_{MR} = \overline{MR} \qquad UCL_{MR} = D_4\,\overline{MR} = 3.267\,\overline{MR} \qquad LCL_{MR} = D_3\,\overline{MR} = 0$$
The three constants below are the only ones the method needs, and every one of them is fixed by the span-2 moving range. Store them as reference values, not as a table indexed by subgroup size — unlike the X-Bar R chart, an I-MR chart never varies $n$.
| Constant | Value (span = 2) | Role |
|---|---|---|
| $d_2$ | 1.128 | Unbiases $\overline{MR}$ to estimate $\hat{\sigma}$ |
| $D_3$ | 0.000 | MR-chart lower control limit multiplier |
| $D_4$ | 3.267 | MR-chart upper control limit multiplier |
| $2.660$ | $3/d_2$ | Direct multiplier for the individuals-chart limits |
Carry these to at least three decimal places. Rounding $2.660$ to $2.66$ is harmless on a display, but rounding $d_2$ to $1.13$ shifts every individuals limit by roughly 0.2 % — enough to flip a marginal point across the boundary in a validated report.
When to Use I-MR vs. the Alternatives
Reach for an I-MR chart when the subgroup size is genuinely one and the data are (approximately) independent and identically distributed. If you can economically collect small rational subgroups of two to nine parts, prefer the X-Bar R chart, whose within-subgroup range gives a cleaner, more sensitive dispersion estimate; once subgroups grow past nine, move to the X-Bar S chart for large subgroups, which uses the standard deviation with $c_4$ bias correction. For pass/fail or count data — defective units, defects per unit — variable charts do not apply at all; use the attribute control charts (p, np, c, u) instead. And once a stable I-MR baseline exists, feed it into process capability analysis (Cp, Cpk, Pp, Ppk) to quantify how well the centered, in-control process meets specification.
The one decision I-MR cannot make for you is what to do when the independence assumption fails. If lag-1 autocorrelation exceeds roughly 0.25–0.30, the moving range no longer estimates common-cause sigma. In that case, difference or model the series first, or switch to an EWMA or CUSUM chart, which are designed for autocorrelated and small-shift detection. Do not "fix" a pinched I-MR chart by widening its multiplier — that hides the modeling problem.
Production-Ready Python Implementation
The engine below is built for headless execution inside a quality pipeline or an edge gateway. It separates validation, Phase I limit computation, and Phase II rule evaluation so behavior is deterministic under real MES data. Missing-value handling here is a defensive last resort; the correct place to resolve nulls is upstream in the missing-value handling stage, and irregular timestamps should be resolved in the time-series alignment pipeline before a single moving range is computed.
import warnings
from typing import Dict
import numpy as np
import pandas as pd
class IMRChartEngine:
"""Individuals & Moving-Range control chart for n=1 process monitoring.
Phase I establishes and freezes limits from a stable baseline; Phase II
evaluates new observations against those frozen limits. Designed for
deterministic, headless execution in an automated SPC pipeline.
"""
# Span-2 moving-range constants (AIAG SPC Reference Manual, Table of
# Control Chart Constants). These are fixed for I-MR; do NOT index by n.
D2 = 1.128 # unbiases MR-bar -> sigma-hat
D3 = 0.0 # MR lower-limit multiplier
D4 = 3.267 # MR upper-limit multiplier
def __init__(self, z_sigma: float = 3.0) -> None:
self.z_sigma = z_sigma
self.limits: Dict[str, float] = {}
def _validate_input(self, data: pd.Series) -> pd.Series:
"""Enforce shape/quality constraints and coerce to float64."""
if data.empty:
raise ValueError("Input series cannot be empty.")
if len(data) < 2:
raise ValueError(
"I-MR requires at least two consecutive observations "
"to form a moving range."
)
if data.isnull().any():
# Forward-fill is a fail-safe, not a strategy. Log it loudly so a
# dropped PLC/SCADA packet is triaged, not silently imputed.
warnings.warn(
"Missing values detected; forward-filling as a fail-safe. "
"Resolve nulls upstream before trusting these limits."
)
data = data.ffill().bfill()
return data.astype(np.float64)
def calculate_limits(self, baseline: pd.Series) -> Dict[str, float]:
"""Compute Phase I limits from >= 20-30 stable observations."""
clean = self._validate_input(baseline)
if len(clean) < 20:
warnings.warn(
f"Baseline has {len(clean)} points; AIAG guidance is >= 20-30 "
"stable observations for valid I-MR limits."
)
x_bar = clean.mean()
mr_bar = clean.diff().abs().dropna().mean()
sigma_hat = mr_bar / self.D2
self.limits = {
"x_center": x_bar,
"x_ucl": x_bar + self.z_sigma * sigma_hat,
"x_lcl": x_bar - self.z_sigma * sigma_hat,
"mr_center": mr_bar,
"mr_ucl": self.D4 * mr_bar,
"mr_lcl": self.D3 * mr_bar,
"sigma_hat": sigma_hat,
}
return self.limits
def evaluate_rules(self, data: pd.Series) -> pd.DataFrame:
"""Apply Western Electric rules to Phase II data vs. frozen limits."""
if not self.limits:
raise RuntimeError("Call calculate_limits() before evaluate_rules().")
clean = self._validate_input(data)
mr = clean.diff().abs()
center = self.limits["x_center"]
sigma = self.limits["sigma_hat"]
df = pd.DataFrame({"observation": clean.values, "moving_range": mr.values})
# Rule 1: one point beyond the 3-sigma individuals limits.
df["rule_1_x"] = (df["observation"] > self.limits["x_ucl"]) | (
df["observation"] < self.limits["x_lcl"]
)
# Rule 1 on the MR chart: excessive short-term variation.
df["rule_1_mr"] = df["moving_range"] > self.limits["mr_ucl"]
# Rule 2: 2 of 3 consecutive points beyond 2-sigma on the same side.
upper_2s, lower_2s = center + 2 * sigma, center - 2 * sigma
above_2s = (df["observation"] > upper_2s).astype(int)
below_2s = (df["observation"] < lower_2s).astype(int)
df["rule_2"] = (
above_2s.rolling(3).sum().ge(2) | below_2s.rolling(3).sum().ge(2)
).fillna(False)
# Rule 4: 8 consecutive points on one side of the centerline.
above_c = (df["observation"] > center).astype(int)
df["rule_4"] = (
above_c.rolling(8).sum().eq(8) | above_c.rolling(8).sum().eq(0)
).fillna(False)
rule_cols = ["rule_1_x", "rule_1_mr", "rule_2", "rule_4"]
df["out_of_control"] = df[rule_cols].any(axis=1)
return df
def run_pipeline(
self, baseline: pd.Series, new_data: pd.Series
) -> pd.DataFrame:
"""End-to-end: freeze baseline limits, then score the live stream."""
self.calculate_limits(baseline)
return self.evaluate_rules(new_data)
if __name__ == "__main__":
rng = np.random.default_rng(42)
base = pd.Series(rng.normal(100.0, 2.0, size=30)) # stable Phase I
live = pd.Series(rng.normal(100.0, 2.0, size=15)).copy()
live.iloc[10] = 112.0 # injected shift
engine = IMRChartEngine()
result = engine.run_pipeline(base, live)
print(engine.limits)
print(result[result["out_of_control"]])
For processes where each production run yields a single critical measurement, the moving range must be taken across consecutive batches, never within a batch — the full span-alignment procedure and worked example are in Step-by-step I-MR chart setup for batch processes.
Validation and Testing
Before an I-MR chart is allowed to gate production, validate three things independently. First, the measurement system: I-MR is exquisitely sensitive to gauge noise because with n=1 there is no averaging to suppress it, so complete a Gage R&R (MSA) and confirm the gauge contributes well under 10 % of total variation — otherwise the moving range is measuring your instrument, not your process. Second, normality: the 3-sigma limits assume an approximately normal individuals distribution, so run a normality check (Anderson–Darling or a probability plot) on the baseline; skewed or bounded quantities (flatness, roundness, particle counts) usually need a Box–Cox transform before charting. Third, independence: compute the lag-1 autocorrelation of the baseline and confirm it sits below ~0.25 before trusting $\hat{\sigma}$.
Regression-test the engine itself with fixtures whose answers you can derive by hand. A deterministic baseline gives a checkable $\overline{MR}$ and therefore exact limits:
import numpy as np
import pandas as pd
def test_imr_limits_match_hand_calculation():
# Constant step of 2.0 => every MR is 2.0 => MR-bar = 2.0
x = pd.Series([100, 102, 100, 102, 100, 102, 100, 102, 100, 102],
dtype=float)
eng = IMRChartEngine()
lim = eng.calculate_limits(x)
assert np.isclose(lim["mr_center"], 2.0)
assert np.isclose(lim["sigma_hat"], 2.0 / 1.128) # ~1.773
assert np.isclose(lim["x_center"], 101.0)
assert np.isclose(lim["x_ucl"], 101.0 + 2.660 * 2.0, atol=1e-2)
assert np.isclose(lim["mr_ucl"], 3.267 * 2.0)
assert lim["mr_lcl"] == 0.0 # D3 = 0
def test_injected_shift_is_flagged():
base = pd.Series(np.repeat([100.0, 101.0], 15))
live = base.copy()
live.iloc[20] = 130.0
result = IMRChartEngine().run_pipeline(base, live)
assert bool(result["rule_1_x"].iloc[20]) is True
Assert on mr_lcl == 0.0 explicitly: because $D_3 = 0$, the MR chart can never signal "too little variation" through its lower limit, and a teammate who copies the individuals-chart pattern and expects a symmetric band will introduce a silent bug.
Failure Modes and Edge Cases
| Symptom | Likely cause | Fix |
|---|---|---|
| Individuals limits far too tight; frequent false alarms | Autocorrelation collapses $\overline{MR}$, understating $\hat{\sigma}$ | Check lag-1 ACF; difference/model the series or switch to EWMA/CUSUM |
| A real shift never trips the chart | Limits recomputed on every point, absorbing the shift into the baseline | Freeze Phase I limits; evaluate Phase II against the frozen values |
| MR chart shows a giant spike after a gap | Timestamp gap treated as a genuine $ | x_i - x_ |
| Every limit slightly off vs. reference software | $d_2$ or $2.660$ rounded too aggressively | Carry constants to three decimals; use $2.660$, not $2.66$ or $2.7$ |
LCL on the MR chart shows a negative number |
Applied $D_3$ from a larger-$n$ table, or symmetric-limit copy-paste | $D_3 = 0$ for span-2; MR LCL is always $0$ |
| Baseline mixes two products/machines | Two streams interleaved into one column inflate $\overline{MR}$ | Chart each stream separately; never pool heterogeneous sources |
The negative-MR-limit trap deserves emphasis in automated code: a moving range is an absolute value, so it is bounded below by zero, and any pipeline that renders a computed mr_lcl below zero has mis-sourced its constants. Clamp with max(0.0, D3 * mr_bar) only as a display guard — the real fix is using the correct span-2 constants.
Compliance Notes
Cite the fixed constants to the AIAG SPC Reference Manual (2nd ed.), whose control-chart constants table is the standard source for $d_2$, $D_3$, and $D_4$ and which specifies the individuals-and-moving-range chart for n=1 monitoring. The ≥ 20–30 stable-observation baseline requirement and the Phase I / Phase II separation likewise trace to that manual's control-charting procedure. For the measurement-system prerequisite, follow AIAG MSA (4th ed.) and demonstrate acceptable Gage R&R before charting. Under ISO 9001:2015 clause 9.1.1, the frozen limits, the baseline dataset, and the version of the charting engine must all be retained as documented evidence of monitoring — serialize the limits to JSON or Parquet and version-control them alongside the code. The NIST/SEMATECH e-Handbook of Statistical Methods, section 6.3.2 provides the derivation of the moving-range sigma estimate and validation tables for alternative span configurations.
Frequently Asked Questions
Why is the moving-range chart's lower limit always zero?
Because for a span-2 moving range the constant $D_3$ is exactly $0$, so $LCL_{MR} = D_3 \cdot \overline{MR} = 0$. A moving range is an absolute difference and cannot go negative, and with only two points per range there is no way to signal "suspiciously low" short-term variation through a lower limit. If your code produces a negative MR lower limit, you have pulled $D_3$ from a table indexed for larger subgroups — use the span-2 value of $0$.
Where does the magic number 2.660 come from?
It is $3 / d_2 = 3 / 1.128 \approx 2.660$. The individuals-chart limits are $\bar{x} \pm 3\hat{\sigma}$, and since $\hat{\sigma} = \overline{MR}/d_2$, substituting gives $\bar{x} \pm (3/d_2)\overline{MR} = \bar{x} \pm 2.660\,\overline{MR}$. Keep three decimals: rounding $d_2$ to $1.13$ shifts every limit by about 0.2 %, which can flip a borderline point in a validated report.
My I-MR limits are so tight everything looks out of control. What went wrong?
Almost always autocorrelation. In thermal, tank-level, and slow chemical processes consecutive readings are correlated, so the moving range is small, $\hat{\sigma}$ is understated, and the individuals limits pinch inward until common-cause noise trips the chart. Compute the lag-1 autocorrelation of your baseline; if it exceeds roughly 0.25–0.30, difference or model the series, or move to an EWMA or CUSUM chart. Never compensate by widening the multiplier — that hides the real problem.
How many observations do I need before I can freeze the limits?
At least 20, and 25–30 is better. Fewer points make $\overline{MR}$ a noisy estimator, so the limits themselves are uncertain and will move once you collect more data. The baseline must come from a period with no known assignable causes — if a tool change or material-lot shift happened inside the window, split it and use only the stable segment.
Should I recompute the limits as new data arrives?
No — freeze the Phase I limits and evaluate all new data against them. Recomputing on every point lets a genuine shift bleed into the baseline, so the chart widens to accommodate the very drift it should be catching. Recalibrate only after a verified, intentional process change, and do it deliberately through a rolling-window recalibration step rather than on autopilot.
Related
- Step-by-step I-MR chart setup for batch processes — batch-to-batch span alignment and a full worked example
- X-Bar R chart implementation — the subgroup alternative to use once rational subgroups of 2–9 are available
- X-Bar S chart for large subgroups — standard-deviation-based charting when n exceeds nine
- Process capability analysis (Cp, Cpk, Pp, Ppk) — quantify capability once the I-MR chart shows the process is stable
- Rolling window limit recalibration — the disciplined way to move frozen limits after a verified process change
For chart selection criteria across every variable and attribute chart, see SPC Fundamentals & Control Chart Taxonomy.