Python and SQL for Manufacturing: Hands-On Training¶
Demo Notebook 4B: Statistical Process Control (SPC) with SQL + Python¶
Author: Prakash Ukhalkar
Role: Assistant Professor (MCA) | Researcher in Data Science and Machine Learning
Notebook Scope: Build a dedicated SPC database, load process measurement data using SQL, and draw control charts using Python.
What is SPC?¶
SPC (Statistical Process Control) is a set of simple tools that help you detect problems in a manufacturing process early - before bad parts reach the customer.
Every process has two types of variation:
| Type | Meaning | What to do |
|---|---|---|
| Common cause | Normal random variation - always present | Leave the process alone |
| Assignable cause | A real problem - tool wear, bad material, operator error | Find it and fix it |
A control chart plots sample measurements over time. As long as all points stay inside the control limits and look random, the process is in control. A point outside the limits is a signal to investigate.
Charts we build in this notebook:
| Chart | What it monitors | Data type | Purpose in manufacturing |
|---|---|---|---|
| X-bar | Is the process mean staying on target? | Continuous measurements (diameter, weight, temperature) | Detect shifts in the process centre — e.g., a worn tool gradually moving a dimension off target |
| R | Is the process spread staying stable? | Same measurements as X-bar, used together | Detect increases in within-subgroup variability — e.g., a loose fixture causing inconsistent clamping |
| p | Is the fraction of defective units acceptable? | Pass / fail inspection (attribute data) | Monitor yield on high-volume lines where each unit is classed as good or bad |
| c | Is the number of defects per unit acceptable? | Defect count per finished part | Detect quality upsets on complex assemblies where a single unit can have multiple defects |
Step 1 - Import libraries and create the SPC database¶
We create a fresh SQLite database spc_demo.db dedicated to this notebook.
No shared state with earlier demo notebooks.
import sqlite3 # For database operations
import math # For mathematical functions
import numpy as np # For numerical operations
import matplotlib.pyplot as plt # For plotting / data visualization
# Create the SPC demo database and connect to it
conn = sqlite3.connect('spc_demo.db') # Connect to the database (or create it if it doesn't exist)
cursor = conn.cursor() # Create a cursor object to interact with the database
print('Connected to spc_demo.db') # Print a message confirming the connection to the database
Connected to spc_demo.db
Step 2 - Create SPC tables¶
Three tables, one per chart type:
| Table | Chart | Data recorded | Example in this notebook |
|---|---|---|---|
shaft_measurements |
X-bar / R | 4 raw continuous readings per subgroup | Shaft diameter in mm; each row is one subgroup of 4 measurements taken close together in time |
defect_inspection |
p-chart | Number of defective units found in a fixed-size batch | 100 shafts inspected per shift; each row records how many were rejected |
surface_defects |
c-chart | Total defect count on each individual finished part | Each completed part is inspected for surface flaws; a single part can have several defects |
Variable vs attribute data:
shaft_measurementsholds measured values (continuous).
defect_inspectionandsurface_defectshold counted values (discrete) — this distinction drives which chart type and which formula to use.
# Drop old tables so this cell can be re-run cleanly
cursor.execute('DROP TABLE IF EXISTS shaft_measurements')
cursor.execute('DROP TABLE IF EXISTS defect_inspection')
cursor.execute('DROP TABLE IF EXISTS surface_defects')
# Table 1: shaft diameter readings — 4 observations per sample
cursor.execute('''
CREATE TABLE shaft_measurements (
sample_no INTEGER PRIMARY KEY,
obs1 REAL NOT NULL,
obs2 REAL NOT NULL,
obs3 REAL NOT NULL,
obs4 REAL NOT NULL
)
''')
# Table 2: how many defective units per inspection batch
cursor.execute('''
CREATE TABLE defect_inspection (
sample_no INTEGER PRIMARY KEY,
sample_size INTEGER NOT NULL,
defectives INTEGER NOT NULL
)
''')
# Table 3: how many surface defects found on each finished part
cursor.execute('''
CREATE TABLE surface_defects (
unit_no INTEGER PRIMARY KEY,
defect_count INTEGER NOT NULL
)
''')
conn.commit()
print('Tables created: shaft_measurements, defect_inspection, surface_defects')
Tables created: shaft_measurements, defect_inspection, surface_defects
Step 3 - Insert shaft diameter data (variable measurements)¶
20 subgroups of 4 measurements each. Target diameter = 50.00 mm.
Subgroups 13 (upward shift) and 18 (downward shift) are deliberate out-of-control events.
# 20 subgroups, n=4, shaft diameter in mm
# Samples 13 and 18 simulate assignable-cause process shifts
shaft_data = [
(1, 49.85, 50.12, 50.03, 49.95),
(2, 50.10, 49.90, 50.05, 50.15),
(3, 49.95, 50.08, 49.88, 50.02),
(4, 50.00, 50.10, 50.05, 49.90),
(5, 50.05, 49.85, 50.15, 50.00),
(6, 49.90, 50.00, 50.10, 50.05),
(7, 50.08, 50.02, 49.92, 50.05),
(8, 49.95, 50.00, 50.10, 50.05),
(9, 50.05, 49.95, 50.00, 50.15),
(10, 50.00, 49.90, 50.10, 50.00),
(11, 50.05, 49.85, 49.95, 50.10),
(12, 50.00, 50.10, 50.05, 49.95),
(13, 50.35, 50.40, 50.38, 50.42), # process shift UP
(14, 50.05, 50.00, 49.90, 50.10),
(15, 49.95, 50.08, 50.02, 49.88),
(16, 50.00, 49.95, 50.05, 50.10),
(17, 50.08, 50.02, 49.95, 50.00),
(18, 49.55, 49.60, 49.58, 49.62), # process shift DOWN
(19, 50.00, 50.05, 49.90, 50.05),
(20, 49.95, 50.10, 50.00, 50.05),
]
cursor.executemany('INSERT INTO shaft_measurements VALUES (?,?,?,?,?)', shaft_data)
conn.commit()
print(f'Inserted {len(shaft_data)} subgroups into shaft_measurements')
Inserted 20 subgroups into shaft_measurements
Step 4 - Compute subgroup means and ranges with SQL¶
For each subgroup of 4 readings, we need two numbers:
- Mean (X-bar): average of the 4 readings - tells us where the process is centred
- Range (R): largest reading minus smallest - tells us how spread out the readings are
MAX(obs1, obs2, obs3, obs4) and MIN(...) in SQLite can take multiple columns from the same row, so the whole calculation stays inside the SQL query.
# SQL computes the subgroup mean and range for us in one query
rows = cursor.execute('''
SELECT
sample_no,
ROUND((obs1 + obs2 + obs3 + obs4) / 4.0, 4) AS xbar,
ROUND(MAX(obs1,obs2,obs3,obs4) - MIN(obs1,obs2,obs3,obs4), 4) AS range_val
FROM shaft_measurements
ORDER BY sample_no
''').fetchall()
# Print results row by row using a simple for loop
print('Sample X-bar (mm) Range (mm)')
print('------ ---------- ----------')
for row in rows:
sample = row[0]
xbar = row[1]
rng = row[2]
print(f' {sample:2d} {xbar:.4f} {rng:.4f}')
Sample X-bar (mm) Range (mm) ------ ---------- ---------- 1 49.9875 0.2700 2 50.0500 0.2500 3 49.9825 0.2000 4 50.0125 0.2000 5 50.0125 0.3000 6 50.0125 0.2000 7 50.0175 0.1600 8 50.0250 0.1500 9 50.0375 0.2000 10 50.0000 0.2000 11 49.9875 0.2500 12 50.0250 0.1500 13 50.3875 0.0700 14 50.0125 0.2000 15 49.9825 0.2000 16 50.0250 0.1500 17 50.0125 0.1300 18 49.5875 0.0700 19 50.0000 0.1500 20 50.0250 0.1500
Step 5 - Compute X-bar and R control limits¶
Control limit formulas¶
The limits are placed at ±3 standard deviations from the centre line — covering 99.73 % of normal variation.
Rather than estimating σ directly, Shewhart derived simple multipliers that work from $\bar{R}$ alone:
$$UCL_{\bar{X}} = \bar{\bar{X}} + A_2\bar{R}, \qquad LCL_{\bar{X}} = \bar{\bar{X}} - A_2\bar{R}$$
$$UCL_R = D_4\bar{R}, \qquad LCL_R = D_3\bar{R}$$
Variables:
- $\bar{\bar{X}}$ — grand mean (average of all subgroup means)
- $\bar{R}$ — average range across all subgroups
- $A_2, D_3, D_4, d_2$ — Shewhart constants (depend only on subgroup size $n$)
Shewhart control chart constants¶
These values are published in ASTM E2281 and ISO 8258. They are derived mathematically from the distribution of ranges for samples drawn from a normal population — you look them up, you do not calculate them.
| $n$ | $A_2$ | $D_3$ | $D_4$ | $d_2$ |
|---|---|---|---|---|
| 2 | 1.880 | 0 | 3.267 | 1.128 |
| 3 | 1.023 | 0 | 2.574 | 1.693 |
| 4 | 0.729 | 0 | 2.282 | 2.059 |
| 5 | 0.577 | 0 | 2.114 | 2.326 |
| 6 | 0.483 | 0 | 2.004 | 2.534 |
| 7 | 0.419 | 0.076 | 1.924 | 2.704 |
| 8 | 0.373 | 0.136 | 1.864 | 2.847 |
This notebook uses $n = 4$ (highlighted row above).
What each factor does:
| Factor | Role | Formula it appears in |
|---|---|---|
| $A_2$ | Scales $\bar{R}$ into a ±3σ half-width for the X-bar chart | $UCL/LCL_{\bar{X}} = \bar{\bar{X}} \pm A_2\bar{R}$ |
| $D_4$ | Upper multiplier for the R chart | $UCL_R = D_4\bar{R}$ |
| $D_3$ | Lower multiplier for the R chart (0 for $n \le 6$ — range cannot be negative) | $LCL_R = D_3\bar{R}$ |
| $d_2$ | Unbiasing constant; converts $\bar{R}$ into an estimate of σ | $\hat{\sigma} = \bar{R}/d_2$ (used for Cpk in Step 12) |
SQL gives us $\bar{\bar{X}}$ and $\bar{R}$; Python just multiplies by the factors above.
# Shewhart factors for subgroup size n = 4 (standard published table)
A2 = 0.729 # multiplied by R-bar to get X-bar chart limits
D3 = 0.000 # lower factor for R chart (0 for n <= 6)
D4 = 2.282 # upper factor for R chart
d2 = 2.059 # divides R-bar to estimate process standard deviation
# Ask SQL for the grand mean and average range across all 20 subgroups
result = cursor.execute('''
SELECT
AVG((obs1 + obs2 + obs3 + obs4) / 4.0),
AVG(MAX(obs1,obs2,obs3,obs4) - MIN(obs1,obs2,obs3,obs4))
FROM shaft_measurements
''').fetchone()
grand_mean = round(result[0], 4) # X-double-bar
r_bar = round(result[1], 4) # R-bar
# X-bar chart limits: grand_mean ± A2 × R-bar
ucl_x = round(grand_mean + A2 * r_bar, 4)
lcl_x = round(grand_mean - A2 * r_bar, 4)
# R chart limits: D4 × R-bar and D3 × R-bar
ucl_r = round(D4 * r_bar, 4)
lcl_r = round(D3 * r_bar, 4)
print('Grand Mean :', grand_mean, 'mm')
print('Avg Range :', r_bar, 'mm')
print()
print('X-bar chart UCL:', ucl_x, ' Centre:', grand_mean, ' LCL:', lcl_x)
print('R chart UCL:', ucl_r, ' Centre:', r_bar, ' LCL:', lcl_r)
Grand Mean : 50.0091 mm Avg Range : 0.1825 mm X-bar chart UCL: 50.1421 Centre: 50.0091 LCL: 49.8761 R chart UCL: 0.4165 Centre: 0.1825 LCL: 0.0
Step 6 - X-bar Control Chart¶
The X-bar chart monitors the process mean over time.
$$UCL_{\bar{X}} = \bar{\bar{X}} + A_2\bar{R}, \quad CL = \bar{\bar{X}}, \quad LCL_{\bar{X}} = \bar{\bar{X}} - A_2\bar{R}$$
Any point outside UCL/LCL indicates an assignable cause has shifted the mean.
# Unpack the SQL results into plain lists for the chart
sample_nos = []
xbars = []
for row in rows:
sample_nos.append(row[0])
xbars.append(row[1])
# Find which samples fall outside the control limits
ooc_samples = []
ooc_values = []
for row in rows:
if row[1] > ucl_x or row[1] < lcl_x:
ooc_samples.append(row[0])
ooc_values.append(row[1])
# Plot the X-bar chart
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(sample_nos, xbars, marker='o', color='steelblue', linewidth=1.5, label='Sample X-bar')
ax.axhline(grand_mean, color='green', linewidth=1.5, linestyle='-', label='Centre Line')
ax.axhline(ucl_x, color='red', linewidth=1.5, linestyle='--', label='UCL / LCL')
ax.axhline(lcl_x, color='red', linewidth=1.5, linestyle='--')
# Highlight out-of-control points in red
ax.scatter(ooc_samples, ooc_values, color='red', s=120, zorder=5, label='Out of Control')
ax.set_title('X-bar Control Chart - Shaft Diameter (mm)', fontweight='bold')
ax.set_xlabel('Sample Number')
ax.set_ylabel('Mean Diameter (mm)')
ax.legend()
ax.set_xticks(sample_nos)
plt.tight_layout()
plt.show()
print('Out-of-control sample numbers:', ooc_samples)
Out-of-control sample numbers: [13, 18]
Step 7 - R (Range) Control Chart¶
The R chart monitors process precision (within-subgroup spread).
$$UCL_R = D_4\bar{R}, \quad CL = \bar{R}, \quad LCL_R = D_3\bar{R}$$
An out-of-control R value means variability itself has changed — even if the mean looks fine.
# Unpack range values into a plain list
ranges = []
for row in rows:
ranges.append(row[2])
# Find out-of-control range values (above UCL means spread got worse)
ooc_r_samples = []
ooc_r_values = []
for row in rows:
if row[2] > ucl_r:
ooc_r_samples.append(row[0])
ooc_r_values.append(row[2])
# Plot the R chart
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(sample_nos, ranges, marker='s', color='darkorange', linewidth=1.5, label='Sample Range')
ax.axhline(r_bar, color='green', linewidth=1.5, linestyle='-', label='Centre Line (R-bar)')
ax.axhline(ucl_r, color='red', linewidth=1.5, linestyle='--', label='UCL / LCL')
ax.axhline(lcl_r, color='red', linewidth=1.5, linestyle='--')
if ooc_r_samples:
ax.scatter(ooc_r_samples, ooc_r_values, color='red', s=120, zorder=5, label='Out of Control')
ax.set_title('R (Range) Control Chart — Shaft Diameter (mm)', fontweight='bold')
ax.set_xlabel('Sample Number')
ax.set_ylabel('Within-Sample Range (mm)')
ax.legend()
ax.set_xticks(sample_nos)
plt.tight_layout()
plt.show()
if ooc_r_samples:
print('Out-of-control samples (R chart):', ooc_r_samples)
else:
print('All ranges are in control — process precision is stable.')
All ranges are in control — process precision is stable.
Step 8 - Classify each sample as in-control or out-of-control¶
SQL fetches each sample's X-bar value.
Python's if/elif then assigns a status label - this is the standard SQL + Python pattern: SQL retrieves the data, Python applies the logic.
# SQL fetches each sample's X-bar; Python classifies it with a simple if/elif
sample_status = cursor.execute('''
SELECT
sample_no,
ROUND((obs1 + obs2 + obs3 + obs4) / 4.0, 4) AS xbar
FROM shaft_measurements
ORDER BY sample_no
''').fetchall()
print('Sample X-bar Status')
print('------ ------- ------')
for row in sample_status:
sample = row[0]
xbar = row[1]
if xbar > ucl_x:
status = 'ABOVE UCL *** Assignable Cause'
elif xbar < lcl_x:
status = 'BELOW LCL *** Assignable Cause'
else:
status = 'In Control'
print(f' {sample:2d} {xbar:.4f} {status}')
Sample X-bar Status ------ ------- ------ 1 49.9875 In Control 2 50.0500 In Control 3 49.9825 In Control 4 50.0125 In Control 5 50.0125 In Control 6 50.0125 In Control 7 50.0175 In Control 8 50.0250 In Control 9 50.0375 In Control 10 50.0000 In Control 11 49.9875 In Control 12 50.0250 In Control 13 50.3875 ABOVE UCL *** Assignable Cause 14 50.0125 In Control 15 49.9825 In Control 16 50.0250 In Control 17 50.0125 In Control 18 49.5875 BELOW LCL *** Assignable Cause 19 50.0000 In Control 20 50.0250 In Control
Step 9 - Attribute data: insert inspection results (p-chart)¶
A p-chart monitors the fraction defective when each unit is classified as pass/fail.
We inspect 100 units per sample. Samples 7 and 13 simulate a quality upset.
# 15 inspection samples, n=100 each
# Defective counts for samples 7 (18) and 13 (17) are intentionally elevated
defect_data = [
(1, 100, 4), (2, 100, 6), (3, 100, 3), (4, 100, 5),
(5, 100, 7), (6, 100, 4), (7, 100, 18), (8, 100, 6),
(9, 100, 3), (10, 100, 5), (11, 100, 4), (12, 100, 6),
(13, 100, 17), (14, 100, 5), (15, 100, 3),
]
cursor.executemany('INSERT INTO defect_inspection VALUES (?,?,?)', defect_data)
conn.commit()
print(f'Inserted {len(defect_data)} inspection records into defect_inspection')
Inserted 15 inspection records into defect_inspection
Step 10 - p-Chart: compute limits and visualize¶
SQL sums all defectives and all units inspected to give us $\bar{p}$ (the overall fraction defective).
Python then calculates the control limits using $\bar{p}$ and the sample size.
$$\bar{p} = \frac{\sum d_i}{\sum n_i}, \qquad \sigma_p = \sqrt{\frac{\bar{p}(1-\bar{p})}{n}}$$
$$UCL_p = \bar{p} + 3\sigma_p, \qquad LCL_p = \bar{p} - 3\sigma_p \; (\ge 0)$$
Variables: $d_i$ = defectives in sample $i$; $n_i$ = sample size; $n$ = constant inspection batch size.
# Get per-sample defective proportions from SQL
p_rows = cursor.execute('''
SELECT
sample_no,
defectives,
sample_size,
ROUND(CAST(defectives AS REAL) / sample_size, 4) AS p_val
FROM defect_inspection
ORDER BY sample_no
''').fetchall()
# Compute overall p-bar: total defectives divided by total units inspected
result = cursor.execute(
'SELECT SUM(defectives), SUM(sample_size) FROM defect_inspection'
).fetchone()
total_defectives = result[0]
total_inspected = result[1]
p_bar = round(total_defectives / total_inspected, 4)
# Control limits (sample size n = 100 throughout)
n_insp = 100
sig_p = math.sqrt(p_bar * (1 - p_bar) / n_insp)
ucl_p = round(p_bar + 3 * sig_p, 4)
lcl_p = max(0.0, round(p_bar - 3 * sig_p, 4)) # LCL cannot be negative
print('p-bar (overall fraction defective) :', p_bar)
print('p-Chart UCL:', ucl_p, ' Centre:', p_bar, ' LCL:', lcl_p)
p-bar (overall fraction defective) : 0.064 p-Chart UCL: 0.1374 Centre: 0.064 LCL: 0.0
# Unpack sample numbers and p-values into plain lists
sn_p = []
pvals = []
for row in p_rows:
sn_p.append(row[0])
pvals.append(row[3])
# Find out-of-control samples
ooc_p_samples = []
ooc_p_values = []
for row in p_rows:
if row[3] > ucl_p:
ooc_p_samples.append(row[0])
ooc_p_values.append(row[3])
# Plot the p-chart
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(sn_p, pvals, marker='o', color='purple', linewidth=1.5, label='Fraction Defective (p)')
ax.axhline(p_bar, color='green', linewidth=1.5, linestyle='-', label='p-bar (centre)')
ax.axhline(ucl_p, color='red', linewidth=1.5, linestyle='--', label='UCL / LCL')
ax.axhline(lcl_p, color='red', linewidth=1.5, linestyle='--')
ax.scatter(ooc_p_samples, ooc_p_values, color='red', s=120, zorder=5, label='Out of Control')
ax.set_title('p-Chart — Fraction Defective per Inspection Sample', fontweight='bold')
ax.set_xlabel('Sample Number')
ax.set_ylabel('Fraction Defective (p)')
ax.legend()
ax.set_xticks(sn_p)
plt.tight_layout()
plt.show()
print('Out-of-control samples:', ooc_p_samples)
Out-of-control samples: [7, 13]
Step 11 - c-Chart: defects per unit¶
A c-chart counts the number of defects on each unit (not whether the unit passes or fails).
Defect counts follow a Poisson distribution, so the standard deviation is simply $\sqrt{\bar{c}}$:
$$\bar{c} = \frac{\sum c_i}{k}, \qquad UCL_c = \bar{c} + 3\sqrt{\bar{c}}, \qquad LCL_c = \bar{c} - 3\sqrt{\bar{c}} \; (\ge 0)$$
Variables: $c_i$ = defect count on unit $i$; $k$ = total number of units inspected.
Units 8 and 15 simulate a burst of defects — for example from tool wear or contamination.
# 20 finished parts, surface defect count per unit
c_raw = [
(1, 3), (2, 4), (3, 2), (4, 5), (5, 3),
(6, 4), (7, 3), (8, 11), (9, 2), (10, 4),
(11, 3), (12, 5), (13, 2), (14, 4), (15,12),
(16, 3), (17, 4), (18, 2), (19, 3), (20, 5),
]
cursor.executemany('INSERT INTO surface_defects VALUES (?,?)', c_raw)
conn.commit()
# Average defects per unit from SQL
result = cursor.execute(
'SELECT AVG(CAST(defect_count AS REAL)) FROM surface_defects'
).fetchone()
c_bar = round(result[0], 4)
# Poisson-based control limits: c-bar ± 3 × sqrt(c-bar)
ucl_c = round(c_bar + 3 * math.sqrt(c_bar), 4)
lcl_c = max(0.0, round(c_bar - 3 * math.sqrt(c_bar), 4))
print('c-bar (average defects per unit) :', c_bar)
print('c-Chart UCL:', ucl_c, ' Centre:', c_bar, ' LCL:', lcl_c)
c-bar (average defects per unit) : 4.2 c-Chart UCL: 10.3482 Centre: 4.2 LCL: 0.0
# Unpack unit numbers and defect counts into plain lists
unit_nos = []
def_counts = []
for row in c_raw:
unit_nos.append(row[0])
def_counts.append(row[1])
# Find out-of-control units
ooc_c_units = []
ooc_c_values = []
for row in c_raw:
if row[1] > ucl_c:
ooc_c_units.append(row[0])
ooc_c_values.append(row[1])
# Plot the c-chart
fig, ax = plt.subplots(figsize=(12, 4))
ax.plot(unit_nos, def_counts, marker='^', color='teal', linewidth=1.5, label='Defects per Unit')
ax.axhline(c_bar, color='green', linewidth=1.5, linestyle='-', label='c-bar (centre)')
ax.axhline(ucl_c, color='red', linewidth=1.5, linestyle='--', label='UCL / LCL')
ax.axhline(lcl_c, color='red', linewidth=1.5, linestyle='--')
ax.scatter(ooc_c_units, ooc_c_values, color='red', s=120, zorder=5, label='Out of Control')
ax.set_title('c-Chart — Surface Defects per Manufactured Unit', fontweight='bold')
ax.set_xlabel('Unit Number')
ax.set_ylabel('Number of Defects')
ax.legend()
ax.set_xticks(unit_nos)
plt.tight_layout()
plt.show()
print('Out-of-control units:', ooc_c_units)
Out-of-control units: [8, 15]
Step 12 - Process Capability (Cpk)¶
SPC tells you when a process goes out of control.
Cpk tells you how capable the process is — whether it fits comfortably within the engineering tolerances, even when it is in control.
Formulas¶
First, estimate the process standard deviation from the average range:
$$\hat{\sigma} = \frac{\bar{R}}{d_2}$$
Then measure how many 3σ gaps fit between the process mean and each spec limit:
$$C_p = \frac{USL - LSL}{6\hat{\sigma}}, \qquad C_{pu} = \frac{USL - \bar{\bar{X}}}{3\hat{\sigma}}, \qquad C_{pl} = \frac{\bar{\bar{X}} - LSL}{3\hat{\sigma}}$$
$$C_{pk} = \min(C_{pu},\; C_{pl})$$
Variables: $USL$ = upper spec limit; $LSL$ = lower spec limit; $\bar{\bar{X}}$ = grand mean; $\hat{\sigma}$ = estimated process standard deviation.
$C_p$ assumes a perfectly centred process. $C_{pk}$ accounts for any off-centre mean — it is always $\le C_p$.
How to interpret Cpk:
| Cpk | What it means |
|---|---|
| ≥ 1.33 | Capable — comfortable margin inside the spec limits |
| 1.00 – 1.33 | Marginally capable — just fitting inside |
| < 1.00 | Not capable — defects are being produced |
# Engineering tolerance: shaft must be 50.00 mm ± 0.40 mm
USL = 50.40 # Upper Specification Limit
LSL = 49.60 # Lower Specification Limit
# Estimate the process standard deviation from the average range
sigma_hat = r_bar / d2
# Cp: potential capability — assumes the process is perfectly centred
Cp = round((USL - LSL) / (6 * sigma_hat), 4)
# Cpu and Cpl: how much room on each side of the mean
Cpu = round((USL - grand_mean) / (3 * sigma_hat), 4) # upper side
Cpl = round((grand_mean - LSL) / (3 * sigma_hat), 4) # lower side
# Cpk: actual capability — whichever side is tighter wins
Cpk = round(min(Cpu, Cpl), 4)
print('Spec limits : LSL =', LSL, 'mm USL =', USL, 'mm')
print('Sigma-hat :', round(sigma_hat, 4), 'mm')
print()
print('Cp =', Cp, ' — potential capability')
print('Cpu =', Cpu, ' — upper-half capability')
print('Cpl =', Cpl, ' — lower-half capability')
print('Cpk =', Cpk, ' — actual capability (the key number)')
print()
if Cpk >= 1.33:
print('Verdict: Process IS CAPABLE (Cpk >= 1.33)')
elif Cpk >= 1.00:
print('Verdict: Process is MARGINALLY capable (1.00 <= Cpk < 1.33)')
else:
print('Verdict: Process is NOT capable (Cpk < 1.00 — defects expected)')
Spec limits : LSL = 49.6 mm USL = 50.4 mm Sigma-hat : 0.0886 mm Cp = 1.5043 — potential capability Cpu = 1.4701 — upper-half capability Cpl = 1.5385 — lower-half capability Cpk = 1.4701 — actual capability (the key number) Verdict: Process IS CAPABLE (Cpk >= 1.33)
# Build a normal curve with numpy, then overlay the spec limits
x_vals = np.linspace(LSL - 0.2, USL + 0.2, 400)
y_vals = (1 / (sigma_hat * (2 * math.pi) ** 0.5)) * np.exp(
-0.5 * ((x_vals - grand_mean) / sigma_hat) ** 2
)
fig, ax = plt.subplots(figsize=(9, 4))
ax.plot(x_vals, y_vals, color='steelblue', linewidth=2)
ax.fill_between(x_vals, y_vals, alpha=0.25, color='steelblue', label='Process distribution')
ax.axvline(LSL, color='red', linestyle='--', linewidth=1.5, label='LSL = ' + str(LSL))
ax.axvline(USL, color='red', linestyle='--', linewidth=1.5, label='USL = ' + str(USL))
ax.axvline(grand_mean, color='green', linestyle='-', linewidth=1.5, label='Process mean')
ax.set_title('Process Capability - Cpk = ' + str(Cpk), fontweight='bold')
ax.set_xlabel('Shaft Diameter (mm)')
ax.set_ylabel('Probability Density')
ax.legend()
plt.tight_layout()
plt.show()
Step 13 - Close the connection¶
conn.close()
print('Connection to spc_demo.db closed.')
Connection to spc_demo.db closed.
Summary — What we built¶
| Step | SQL / Python | What was done |
|---|---|---|
| 1 | sqlite3.connect |
Fresh spc_demo.db, 3 tables |
| 2–3 | CREATE TABLE, executemany |
Schema and data load |
| 4 | SELECT MAX/MIN row-wise |
X-bar and Range in SQL |
| 5 | SELECT AVG + Python multiply |
Grand mean, R-bar, control limits |
| 6–7 | Matplotlib + ax.scatter |
X-bar and R charts, OOC highlighted |
| 8 | SQL SELECT + Python if/elif |
Sample-by-sample status report |
| 9–10 | SQL SUM + Python math.sqrt |
p-chart (fraction defective) |
| 11 | SQL AVG + Python math.sqrt |
c-chart (defects per unit) |
| 12 | Python arithmetic + numpy curve | Cpk and capability plot |
Key SPC rules to remember:
- A process is in control when all points are inside the limits and look random.
- 8 consecutive points on one side of the centre line signal a shift — even without breaching the limits.
- Cpk ≥ 1.33 is the common industry benchmark for a capable process.
Previous: day3_visualization.ipynb — bar, pie, line, scatter, histogram, and horizontal bar charts
Also see: day4_capstone.ipynb — full SQL + Python + CSV export capstone
About This Material¶
| Author | Prakash Ukhalkar |
| Role | Assistant Professor (MCA) · Researcher in Data Science and Machine Learning |
| GitHub | @prakash-ukhalkar |
| Repository | mfg-python-sql-training |
| Reference | ASTM E2281 / ISO 8258 — Control Chart Constants |
| License | MIT |
Provided for educational use in manufacturing analytics training programmes.
For questions, corrections, or contributions, open an issue or pull request on GitHub.