Batch PLS fault diagnosis: the simulated SBR reactor#
Case study for issue #156.
Styrene-butadiene rubber (SBR) is made by emulsion polymerization in a batch reactor. Six trajectories are recorded during each batch (reactor, cooling water and jacket temperatures, latex density, conversion and the energy released), and five quality attributes of the latex are measured at the end (composition, particle size, branching, cross-linking and polydispersity). The 53 batches were simulated from a first-principles model, which makes this a rare kind of case study: the fault is known (Nomikos and MacGregor, 1994). Batch 37 received 30% more organic impurity in the butadiene feed than the normal batches from its very start, and batch 34 50% more from midway through.
The complete script is sbr_batch_pls.py in this directory:
uv run python docs/user_guide/case_studies/batch/sbr_batch_pls.py --output-dir case-study-output/sbr
It prints the numbers quoted below and writes every figure as an HTML file to the output directory.
Data#
SBR batch reactor: 53 batches
of 200 samples with nine trajectories, and five quality attributes per batch.
process_improve.batch.load_sbr() downloads the workbook and returns
the batch dictionary, the quality table, and the list of the six trajectories
the original study modelled. The two feed flows are constant in the simulation
and the feed temperature barely moves, so they are left out.
CONF_LEVEL = 0.95
FAULT_FROM_START = 37
FAULT_MID_BATCH = 34
FAULT_BATCHES = [FAULT_MID_BATCH, FAULT_FROM_START]
INSPECT_SAMPLE = 120 # a sample well after the fault in batch 34 has developed
HIGHLIGHT = '{"color": "red", "width": 4}' # Plotly line style, JSON-encoded, for the highlighted batches
LABELS = {"show_labels": True}
def load_data(url: str | None = None) -> tuple[dict, pd.DataFrame]:
"""Download the batches and keep the six trajectories the original study modelled.
The two feed flows are constant in the simulation and the feed
temperature barely moves, so they carry no batch-to-batch information.
"""
sbr = load_sbr(url=url)
trajectories = {batch_id: batch[sbr.trajectory_tags] for batch_id, batch in sbr.X.items()}
first = next(iter(trajectories.values()))
print(
f"{len(trajectories)} batches x {first.shape[0]} samples x {first.shape[1]} tags; quality block {sbr.Y.shape}"
)
return trajectories, sbr.Y
def plot_raw(trajectories: dict, tag: str, highlight: list[int]) -> go.Figure:
"""Overlay one tag for every batch, with the batches of interest drawn in red."""
return plot_all_batches_per_tag(
trajectories, tag, batches_to_highlight={HIGHLIGHT: highlight}, extra_info=f"highlighting {highlight}"
)
The batch PLS model#
process_improve.batch.BatchPLS unfolds each batch into one row of
6 tags times 200 samples, scales every column to unit variance, and fits a
PLS model from that row to the five quality attributes.
def fit_model(trajectories: dict, quality: pd.DataFrame) -> BatchPLS:
"""Two-component batch PLS from the unfolded trajectories to the five quality attributes.
Each batch is one row of 6 tags x 200 samples = 1200 columns. Every
column is scaled to unit variance, so the mean of the per-column R2 is the
R2 of the whole trajectory block.
"""
model = BatchPLS(n_components=2).fit(trajectories, quality)
r2y = per_component(model.r2_cumulative_.to_numpy())
r2x = per_component(model.r2_per_variable_.mean(axis=0).to_numpy())
print(f"R2X per component = {r2x[0]:.3f}, {r2x[1]:.3f}; R2Y per component = {r2y[0]:.3f}, {r2y[1]:.3f}")
t1, t2 = model.scores_.iloc[:, 0], model.scores_.iloc[:, 1]
print(f"lowest t1 = {t1.nsmallest(3).index.tolist()}; highest t2 = {t2.nlargest(2).index.tolist()}")
t2_limit = model.hotellings_t2_limit(conf_level=CONF_LEVEL)
spe_limit = model.spe_limit(conf_level=CONF_LEVEL)
for batch_id in FAULT_BATCHES:
hotelling = model.hotellings_t2_.loc[batch_id].iloc[-1]
spe = model.spe_.loc[batch_id].iloc[-1]
print(f"batch {batch_id}: T2 = {hotelling:.1f} (limit {t2_limit:.1f}), SPE = {spe:.1f} (limit {spe_limit:.1f})")
return model
def per_component(cumulative: np.ndarray) -> np.ndarray:
"""Turn a cumulative R2 vector into the increment each component adds."""
return np.diff(np.concatenate([[0.0], cumulative]))
R2X per component = 0.245, 0.127; R2Y per component = 0.653, 0.069
lowest t1 = [37, 34, 38]; highest t2 = [34, 9]
batch 34: T2 = 28.2 (limit 6.6), SPE = 23.1 (limit 34.6)
batch 37: T2 = 19.2 (limit 6.6), SPE = 18.7 (limit 34.6)
The first component explains 24.5% of the trajectories and 65.3% of the quality block; the second adds 12.7% and 6.9%. The score plot flags both faulty batches, which is encouraging: batch 37 has the lowest \(t_1\) of all batches, and batch 34 the highest \(t_2\). Both are far outside the Hotelling’s \(T^2\) limit. The SPE, on the other hand, flags neither. The SPE of a whole batch averages the residuals over 200 samples, so a deviation that the model can describe (a shift along the components) does not show up there. SPE and scores answer different questions.
Where the model explains the trajectories#
Every unfolded column has its own \(R^2\), so the fit can be read per tag and per time sample.
def r2_breakdown(model: BatchPLS) -> pd.DataFrame:
"""R2 of every (tag, time) cell after two components, as a tags x time grid."""
grid = model.r2_per_variable_.iloc[:, -1].unstack(level="sequence") # noqa: PD010 - inverse of the unfold
print(
"R2 per tag, averaged over time: " + ", ".join(f"{tag} {value:.2f}" for tag, value in grid.mean(axis=1).items())
)
return grid
def plot_r2_over_time(grid: pd.DataFrame) -> go.Figure:
"""One line per tag: how much of each trajectory the model explains at every sample."""
fig = go.Figure()
for tag, row in grid.iterrows():
fig.add_trace(go.Scatter(x=list(grid.columns), y=row.to_numpy(), mode="lines", name=str(tag)))
fig.update_layout(
title="R2 of each trajectory over the batch", xaxis_title="Time [sequence order]", yaxis_title="R2"
)
return fig
R2 per tag, averaged over time: Conversion 0.75, CoolingTemp 0.23, EnergyReleased 0.26,
JacketTemp 0.24, LatexDensity 0.67, ReactorTemp 0.08
Latex density and conversion are the trajectories the model uses most, and
\(R^2\) is low at the start of every trajectory because all batches begin
alike. The time-varying weights \(w_1\) and \(w_2\), drawn with
process_improve.batch.time_varying_loading_plot(), show the same
picture per component.
Batch 37: the fault from the start#
def diagnose_batch_37(model: BatchPLS, trajectories: dict) -> pd.DataFrame:
"""Score contributions to t1: why batch 37 sits at the low end of t1."""
contributions = model.score_contributions(model.unfold_and_scale(trajectories), component=1)
row = contributions.loc[FAULT_FROM_START]
by_tag = row.groupby(level="tag", sort=False).sum()
print(f"batch {FAULT_FROM_START}: t1 contributions per tag = " + describe(by_tag))
print(f"batch {FAULT_FROM_START}: share of the t1 contribution per fifth of the batch = {share_per_fifth(row)}")
print(
f"batch {FAULT_FROM_START}: first sustained departure from the other batches: {sustained_departure(trajectories, FAULT_FROM_START)}"
)
return contributions
def describe(by_tag: pd.Series) -> str:
"""Format per-tag contributions as one line."""
return ", ".join(f"{tag} {value:+.1f}" for tag, value in by_tag.items())
def share_per_fifth(row: pd.Series) -> str:
"""Split a batch's contribution vector into five equal time blocks and give each block's share."""
by_time = row.groupby(level="sequence").sum()
fifths = by_time.groupby(np.arange(len(by_time)) * 5 // len(by_time)).sum()
return ", ".join(f"{share:.0%}" for share in fifths / fifths.sum())
def sustained_departure(trajectories: dict, batch_id: int, n_sd: float = 2.0, run: int = 20) -> dict[str, int | None]:
"""Return the first sample from which each tag of one batch stays outside the band of the normal batches.
The band is the mean of the batches without the fault plus or minus ``n_sd``
standard deviations, sample by sample. A tag has departed once it stays
outside the band for ``run`` consecutive samples, a tenth of the batch by
default, so that a single noisy excursion does not count. ``None`` means
the tag never departs.
"""
others = np.stack([batch.to_numpy() for key, batch in trajectories.items() if key not in FAULT_BATCHES])
z = (trajectories[batch_id].to_numpy() - others.mean(axis=0)) / others.std(axis=0, ddof=1)
outside = (np.abs(z) > n_sd).astype(int)
window = np.ones(run, dtype=int)
onset: dict[str, int | None] = {}
for j, tag in enumerate(trajectories[batch_id].columns):
runs = np.convolve(outside[:, j], window, mode="valid") == run
onset[tag] = int(np.argmax(runs)) if runs.any() else None
return onset
batch 37: t1 contributions per tag = Conversion -33.4, CoolingTemp -4.2, EnergyReleased -5.2,
JacketTemp -4.3, LatexDensity -25.5, ReactorTemp -1.3
batch 37: share of the t1 contribution per fifth of the batch = 15%, 18%, 19%, 26%, 22%
batch 37: first sustained departure from the other batches: {'ReactorTemp': None,
'CoolingTemp': None, 'JacketTemp': None, 'LatexDensity': 13, 'Conversion': 9, 'EnergyReleased': None}
Batch 37 sits at the low end of \(t_1\) because its conversion and latex
density were below average, and the contribution is spread over the whole
batch: every fifth of it carries between 15% and 26% of the total. The raw
data confirm it. sustained_departure expresses each trajectory of a faulty
batch as a distance from the mean of the normal batches, in units of their
standard deviation at that sample, and reports the first sample from which a
tag stays more than two standard deviations away for twenty samples in a row
(a single crossing is not informative, because a noisy tag crosses that line
now and then in every batch). Conversion and latex density of batch 37 depart
at samples 9 and 13 and run under the other batches to the end; none of its
other four trajectories stays outside the band for twenty samples. The
impurity slowed the reaction from the start, which is the injected fault.
Batch 34: the same fault, from the middle of the batch#
def diagnose_batch_34(model: BatchPLS, trajectories: dict) -> pd.DataFrame:
"""Score contributions to t2: the same fault, but starting midway through batch 34."""
contributions = model.score_contributions(model.unfold_and_scale(trajectories), component=2)
row = contributions.loc[FAULT_MID_BATCH]
by_tag = row.groupby(level="tag", sort=False).sum()
print(f"batch {FAULT_MID_BATCH}: t2 contributions per tag = " + describe(by_tag))
print(f"batch {FAULT_MID_BATCH}: share of the t2 contribution per fifth of the batch = {share_per_fifth(row)}")
print(
f"batch {FAULT_MID_BATCH}: first sustained departure from the other batches: {sustained_departure(trajectories, FAULT_MID_BATCH)}"
)
return contributions
batch 34: t2 contributions per tag = Conversion +8.1, CoolingTemp +12.2, EnergyReleased +14.3,
JacketTemp +12.3, LatexDensity +7.2, ReactorTemp +4.0
batch 34: share of the t2 contribution per fifth of the batch = 6%, 9%, 17%, 39%, 29%
batch 34: first sustained departure from the other batches: {'ReactorTemp': None,
'CoolingTemp': 103, 'JacketTemp': 104, 'LatexDensity': 129, 'Conversion': 123, 'EnergyReleased': 105}
Batch 34 is high on \(t_2\), and the contributions come from the energy
released, the jacket temperature and the cooling-water temperature. The
timing is different from batch 37 in both views. The first two fifths of the
batch carry only 15% of the \(t_2\) contribution and the last two fifths
carry 68%, and in the raw data the cooling-water temperature, the jacket
temperature and the energy released leave the band of the other batches at
samples 103 to 105, the middle of the batch, while conversion and latex
density only do so at samples 123 and 129. The same impurity, injected midway,
shows up first in the heat balance of the reactor and only afterwards in the
extent of reaction.
process_improve.batch.contribution_at_time_plot() at sample 120 shows
the same three tags carrying the deviation.
The same fault appears in two different places of the score plot because it started at two different times. A batch model describes deviations in (tag, time) cells, so the time of an event is part of its signature. This is what makes batch models useful for diagnosis, and it is also why a library of “known faults” in score space needs the onset time as a coordinate.
Predicted quality#
def compare_predictions(model: BatchPLS, quality: pd.DataFrame) -> pd.DataFrame:
"""Observed and fitted quality of the two faulty batches, with the rank of each observed value."""
table = pd.concat(
{
"observed": quality.loc[FAULT_BATCHES],
"predicted": model.predictions_.loc[FAULT_BATCHES],
"rank of observed": quality.rank().loc[FAULT_BATCHES].astype(int),
},
names=["value", "batch_id"],
)
print(f"quality of the faulty batches (rank 1 = lowest of {len(quality)} batches)")
print(table.to_string(float_format=lambda value: f"{value:.4g}"))
return table
quality of the faulty batches (rank 1 = lowest of 53 batches)
Composition ParticleSize Branching CrossLinking Polydispersity
value batch_id
observed 34 0.4525 1244 1.234e-05 4.784e-05 3.599
37 0.4525 1247 1.173e-05 4.549e-05 3.462
predicted 34 0.454 1245 1.228e-05 4.761e-05 3.577
37 0.45 1250 1.183e-05 4.585e-05 3.491
rank of observed 34 5 1 4 4 17
37 4 2 1 1 1
Both batches produced poor latex: batch 37 has the lowest branching, cross-linking and polydispersity of all 53 batches and batch 34 the smallest particle size. The fitted values from the PLS model place them at the same end of every attribute, so a quality prediction from the trajectories would have flagged both batches before the laboratory did. Batch 37 is predicted low on every attribute; batch 34 is predicted only mildly low on polydispersity, because the \(t_2\) direction that carries its fault explains 6.9% of the quality block.
Predicting quality before the batch ends#
The model above was fitted on complete batches, and a plant would like to
know the quality while the batch is still running. The unfolded row of a
running batch is complete up to the newest sample and missing after it, so
this is a missing-data problem: after \(k\) samples, estimate the scores
from the cells observed so far, and read the quality prediction off the
scores through the model’s Y loadings. The scores are estimated with trimmed
score regression (Arteaga and Ferrer, 2002), a regression of the scores on
the observed cells built from the training batches, the estimator that
Garcia-Munoz, Kourti and MacGregor (2004) found gives stable score estimates
from the first samples of a batch.
process_improve.batch.BatchPLS.predict_online() does this for one
point in time, and
process_improve.batch.BatchPLS.predict_online_trace() for every sample
of a complete batch, as the prediction would have evolved in real time.
AVERAGE_BATCH = 4 # the batch whose trajectories lie closest to the average
PREDICTION_SAMPLES = [10, 25, 50, 100, 150, 200]
ERROR_SAMPLES = [10, 50, 100, 150, 200]
def evolving_prediction(
model: BatchPLS, trajectories: dict, quality: pd.DataFrame, batch_id: int = AVERAGE_BATCH
) -> pd.DataFrame:
"""Predict the final quality after every sample of one batch, as it would have been seen in real time.
The unfolded row of a running batch is complete up to the newest sample
and missing after it. ``predict_online_trace`` estimates the scores from
the observed cells alone (trimmed score regression by default) and maps
them to the quality attributes, once per sample.
"""
y_hat = model.predict_online_trace(trajectories[batch_id]).y_hat
actual = quality.loc[batch_id, "ParticleSize"]
fitted = model.predictions_.loc[batch_id, "ParticleSize"]
print(f"batch {batch_id}: ParticleSize measured {actual:.1f}, fitted from the complete batch {fitted:.1f}")
print(
f"batch {batch_id}: ParticleSize predicted after "
+ ", ".join(f"{k} samples {y_hat.loc[k, 'ParticleSize']:.1f}" for k in PREDICTION_SAMPLES)
)
return y_hat
def evolving_error(model: BatchPLS, trajectories: dict, quality: pd.DataFrame) -> pd.DataFrame:
"""RMSEE of the evolving prediction at every sample, relative to the standard deviation of each attribute.
``online_rmse`` traces every training batch and pools the squared errors
per sample, so this is the estimation error (RMSEE) as a function of how
much of the batch has been observed. A ratio of about 1 is the error of
predicting the average batch.
"""
ratio = model.online_rmse(trajectories, quality) / quality.std(ddof=1)
for target in ("ParticleSize", "Branching"):
print(
f"RMSEE / sd of {target} after "
+ ", ".join(f"{k} samples {ratio.loc[k, target]:.2f}" for k in ERROR_SAMPLES)
)
return ratio
def plot_online_rmse(ratio: pd.DataFrame) -> go.Figure:
"""One line per quality attribute: the RMSEE relative to that attribute's standard deviation."""
fig = go.Figure()
for target in ratio.columns:
fig.add_trace(go.Scatter(x=ratio.index, y=ratio[target].to_numpy(), mode="lines", name=str(target)))
fig.add_hline(y=1.0, line_dash="dash", annotation_text="predicting the average batch")
fig.update_layout(
title="RMSEE of the evolving quality prediction (training batches)",
xaxis_title="Samples observed",
yaxis_title="RMSEE / standard deviation",
)
return fig
def plot_online_prediction(y_hat: pd.DataFrame, quality: pd.DataFrame, batch_id: int) -> go.Figure:
"""Draw the evolving prediction of one batch's particle size against its measured value and the average batch."""
fig = go.Figure()
fig.add_trace(go.Scatter(x=y_hat.index, y=y_hat["ParticleSize"].to_numpy(), mode="lines", name="predicted"))
fig.add_hline(y=quality.loc[batch_id, "ParticleSize"], line_dash="dash", annotation_text="measured")
fig.add_hline(y=quality["ParticleSize"].mean(), line_dash="dot", annotation_text="average of all batches")
fig.update_layout(
title=f"ParticleSize of batch {batch_id}, predicted as the batch runs",
xaxis_title="Samples observed",
yaxis_title="ParticleSize",
)
return fig
batch 4: ParticleSize measured 1256.9, fitted from the complete batch 1257.1
batch 4: ParticleSize predicted after 10 samples 1251.0, 25 samples 1248.1, 50 samples 1255.3,
100 samples 1254.9, 150 samples 1257.4, 200 samples 1257.1
RMSEE / sd of ParticleSize after 10 samples 2.84, 50 samples 1.40, 100 samples 1.29,
150 samples 0.62, 200 samples 0.60
RMSEE / sd of Branching after 10 samples 2.10, 50 samples 0.88, 100 samples 0.81,
150 samples 0.32, 200 samples 0.24
Batch 4 is the batch whose trajectories lie closest to the average. Its
particle size is predicted 6 to 9 units away from the measured value in the
first 25 samples and within about 2 units from sample 50 onwards, and after
200 samples the prediction equals the fitted value from the complete batch,
as it must, because the row is then complete. One batch says little about
the error, though. process_improve.batch.BatchPLS.online_rmse() traces
every training batch and pools the squared errors sample by sample; on the
training batches this is the root-mean-square error of estimation, RMSEE, as
a function of how much of the batch has been observed, and its last value is
the RMSEE of the model fitted on complete batches. Dividing by the standard
deviation of each attribute puts the five attributes on one axis, where a
ratio of about 1 is the error of predicting the average batch every time.
For particle size the ratio is 2.84 after 10 samples, still 1.29 at the halfway point and 0.62 after 150 samples: in the first half of the batch the prediction is no better than the average batch, and it improves in the second half. Branching, whose final RMSEE is a quarter of its standard deviation, is below 1 by sample 50. The early predictions are worse than the average because few cells have been observed, and they are the cells where every batch begins alike (the \(R^2\) breakdown showed this), so the score estimate carries little information about the batch. The RMSEE is measured on the batches the model was fitted to. Refitting the model with one batch left out and tracing that batch, which the script leaves out because it refits 53 models, gives a prediction error, RMSEP, of 3.10, 1.66, 1.65, 0.84 and 0.78 standard deviations at the same five samples: the same shape, at a higher level.
Would the model have caught it on-line?#
The score plot flagged both faulty batches once they were complete. The
question here is whether a chart could have flagged them while they were
running, and after how many samples. Two things change from the model
above. A reference model must describe normal operation, so batches 34 and
37 are left out and a two-component model is refitted on the other 51
batches. And a running batch needs a limit at every sample rather than one
limit for the whole batch: the score estimates early in a batch are shrunk
and noisy compared with those near its end, so the scatter of the reference
batches differs from sample to sample.
process_improve.batch.BatchMonitor passes every reference batch
through predict_online_trace and summarises the spread at each sample,
following Nomikos and MacGregor (1995), who set the limits of their score
charts from that spread and note that the \(T^2\) chart needs the score
covariance at each sample as well. The \(T^2\) at sample \(k\)
is standardised by the covariance of the reference batches’ score estimates
at that sample, as Garcia-Munoz, Kourti and MacGregor (2004) compute it,
which gives one limit for the whole batch, and the SPE
limit is a chi-squared limit fitted to the reference batches’ SPE at that
sample (spe_window pools the values of neighbouring samples into that
fit, which steadies the limits when few reference batches are available;
on these 51 batches a window of two samples either side leaves every alarm
sample unchanged). The SPE charted is the instantaneous one, the residual of the newest
sample only, which reacts in the sample a fault begins; the cumulative SPE
over every cell observed so far is diluted by the earlier, normal samples,
and here it reacts to batch 34 after 112 samples instead of 105.
process_improve.batch.online_monitoring_plot() draws either chart.
MONITOR_CONF_LEVEL = 0.99
ALARM_RUN = 3 # consecutive samples above the limit before an alarm counts
FORECAST_FROM = {FAULT_FROM_START: ("Conversion", [30, 60]), FAULT_MID_BATCH: ("CoolingTemp", [60, 115])}
def normal_batches(trajectories: dict) -> dict:
"""Return the batches without the injected fault."""
return {batch_id: batch for batch_id, batch in trajectories.items() if batch_id not in FAULT_BATCHES}
def online_monitoring(trajectories: dict, quality: pd.DataFrame) -> tuple[BatchMonitor, dict]:
"""Per-sample T2 and SPE charts of the two faulty batches against a model of the normal batches.
A reference model must describe normal operation, so batches 34 and 37
are left out of it. ``BatchMonitor`` projects every reference batch after
1, 2, ..., 200 samples with the same missing-data estimator and builds a
limit at each sample from the spread of those projections. The SPE
charted is the instantaneous one, the residual of the newest sample only,
which reacts in the sample a fault begins. An alarm counts once the
statistic stays above its limit for three consecutive samples.
"""
normal = normal_batches(trajectories)
reference = BatchPLS(n_components=2).fit(normal, quality.loc[list(normal)])
monitor = BatchMonitor(reference, conf_level=MONITOR_CONF_LEVEL, spe_statistic="instantaneous").fit(normal)
print(f"reference model on {len(normal)} batches; T2 limit {monitor.t2_limit_over_time_[0]:.2f} at every sample")
alarms: dict[int, dict[str, int | None]] = {}
for batch_id in FAULT_BATCHES:
result = monitor.monitor(trajectories[batch_id])
alarms[batch_id] = {
"T2": first_sustained_alarm(result.t2_alarm),
"SPE": first_sustained_alarm(result.spe_alarm),
}
print(
f"batch {batch_id}: first {ALARM_RUN} consecutive samples above the limit: "
f"T2 after {alarms[batch_id]['T2']} samples, SPE after {alarms[batch_id]['SPE']} samples"
)
t2_rate, spe_rate = alarm_rates(monitor, normal)
print(f"normal batches: {t2_rate:.2%} of the T2 values and {spe_rate:.2%} of the SPE values above their limits")
spe_alarm = alarms[FAULT_MID_BATCH]["SPE"]
if spe_alarm is None:
raise RuntimeError(
f"batch {FAULT_MID_BATCH} raised no sustained SPE alarm; the residual shares below need one."
)
for k in (spe_alarm, spe_alarm + 4):
shares = residual_shares(reference, trajectories[FAULT_MID_BATCH], k)
print(f"batch {FAULT_MID_BATCH}: share of the SPE after {k} samples per tag = " + describe_shares(shares))
for batch_id, (tag, sample_points) in FORECAST_FROM.items():
for k in sample_points:
forecast = reference.predict_online(trajectories[batch_id], k).forecast
print(
f"batch {batch_id} {tag}, mean of the samples after {k}: "
f"forecast {forecast[tag].iloc[k:].mean():.4f}, actual {trajectories[batch_id][tag].iloc[k:].mean():.4f}, "
f"average of the normal batches {np.mean([batch[tag].iloc[k:].mean() for batch in normal.values()]):.4f}"
)
return monitor, alarms
def first_sustained_alarm(alarm: np.ndarray, run: int = ALARM_RUN) -> int | None:
"""Return the 1-based sample after which a statistic first stays above its limit for ``run`` samples in a row."""
runs = np.convolve(np.asarray(alarm, dtype=int), np.ones(run, dtype=int), mode="valid") == run
return int(np.argmax(runs)) + 1 if runs.any() else None
def alarm_rates(monitor: BatchMonitor, batches: dict) -> tuple[float, float]:
"""Fraction of the (batch, sample) points of these batches above the T2 limit and above the SPE limit."""
results = [monitor.monitor(batch) for batch in batches.values()]
t2_rate = float(np.mean([result.t2_alarm for result in results]))
spe_rate = float(np.mean([result.spe_alarm for result in results]))
return t2_rate, spe_rate
def residual_shares(model: BatchPLS, batch: pd.DataFrame, k: int) -> pd.Series:
"""Share of the squared instantaneous SPE after ``k`` samples carried by each tag."""
squared = model.predict_online(batch, k).residuals.xs(k - 1, level="sequence") ** 2
return squared / squared.sum()
def describe_shares(shares: pd.Series) -> str:
"""Format per-tag shares as one line, largest first."""
return ", ".join(f"{tag} {share:.0%}" for tag, share in shares.sort_values(ascending=False).items())
def plot_forecasts(reference: BatchPLS, trajectories: dict) -> go.Figure:
"""Draw the rest of each faulty batch as forecast from its scores at two points, next to what happened."""
normal = normal_batches(trajectories)
titles = [f"batch {batch_id}: {tag}" for batch_id, (tag, _) in FORECAST_FROM.items()]
fig = make_subplots(rows=1, cols=2, subplot_titles=titles)
for col, (batch_id, (tag, sample_points)) in enumerate(FORECAST_FROM.items(), start=1):
n = len(trajectories[batch_id])
time = np.arange(1, n + 1)
average = np.mean([batch[tag].to_numpy() for batch in normal.values()], axis=0)
fig.add_trace(go.Scatter(x=time, y=average, mode="lines", name="average of the normal batches"), row=1, col=col)
fig.add_trace(
go.Scatter(x=time, y=trajectories[batch_id][tag].to_numpy(), mode="lines", name=f"batch {batch_id}"),
row=1,
col=col,
)
for k in sample_points:
forecast = reference.predict_online(trajectories[batch_id], k).forecast[tag]
fig.add_trace(
go.Scatter(
x=time[k:],
y=forecast.iloc[k:].to_numpy(),
mode="lines",
line={"dash": "dash"},
name=f"forecast from {k} samples",
),
row=1,
col=col,
)
fig.update_xaxes(title_text="Time [sequence order]", row=1, col=col)
fig.update_yaxes(title_text=tag, row=1, col=col)
fig.update_layout(title="The rest of the batch, forecast from the reference model")
return fig
reference model on 51 batches; T2 limit 10.54 at every sample
batch 34: first 3 consecutive samples above the limit: T2 after 190 samples,
SPE after 105 samples
batch 37: first 3 consecutive samples above the limit: T2 after 23 samples,
SPE after 145 samples
normal batches: 0.17% of the T2 values and 1.24% of the SPE values above their limits
batch 34: share of the SPE after 105 samples per tag = ReactorTemp 40%, CoolingTemp 30%,
JacketTemp 16%, EnergyReleased 11%, Conversion 2%, LatexDensity 1%
batch 34: share of the SPE after 109 samples per tag = CoolingTemp 36%, JacketTemp 29%,
EnergyReleased 19%, ReactorTemp 12%, Conversion 2%, LatexDensity 1%
batch 37 Conversion, mean of the samples after 30: forecast 0.6472, actual 0.6420,
average of the normal batches 0.6609
batch 37 Conversion, mean of the samples after 60: forecast 0.6480, actual 0.6477,
average of the normal batches 0.6656
batch 34 CoolingTemp, mean of the samples after 60: forecast 46.6357, actual 46.7744,
average of the normal batches 46.6152
batch 34 CoolingTemp, mean of the samples after 115: forecast 46.6366, actual 46.8173,
average of the normal batches 46.6015
An alarm counts once the statistic stays above its 99% limit for three consecutive samples. A single crossing is not informative: the normal batches put 0.17% of their \(T^2\) values and 1.24% of their SPE values above the limits, so a batch of 200 samples crosses the SPE limit now and then (batch 34 has one isolated crossing after 37 samples, and batch 4, the near-average batch, one after 26).
Batch 37 shows in the \(T^2\) chart after 23 samples: its \(T^2\) is 9.6 after 21 samples, 10.2 after 22, 10.7 after 23 and 11.9 after 25, against a limit of 10.54, and it stays above the limit for the rest of the batch. The impurity slowed the reaction from the first sample, and low conversion and latex density is the direction the reference model’s first component describes, so the score estimate moves along \(t_1\) as soon as enough samples have been observed to estimate it. The instantaneous SPE of batch 37 does not react until sample 145; the deviation lies in the model plane and leaves little residual.
Batch 34 shows in the SPE chart after 105 samples, within a couple of samples of the point where the cooling-water temperature, the jacket temperature and the energy released leave the band of the other batches in the raw data (samples 103 to 105). At the alarm sample the reactor temperature carries 40% of the squared residual and the cooling-water temperature 30%; four samples later the cooling-water temperature, the jacket temperature and the energy released carry 84% between them, the same three tags the whole-batch contribution plot named. The \(T^2\) of batch 34 does not alarm until sample 190. None of the reference batches has a heat-balance deviation that starts midway, so the reference model has no component for it; the deviation is off the model plane and shows in the residual, not in the scores. The two charts answer different questions: the \(T^2\) reacts to a deviation along the components the reference batches exhibited, the SPE to a direction the reference model never saw, and which chart catches a fault first depends on the fault.
The same distinction decides whether the model can forecast the rest of a
running batch. predict_online also returns a forecast: the batch’s
own values up to the newest sample, and beyond it the trajectories implied
by the score estimate, Eq. 4 of Wold, Kettaneh-Wold, MacGregor and Dunn
(2009). For batch 37 the forecast from 30 samples puts the mean conversion
of the remaining samples at 0.6472, against 0.6420 observed and 0.6609 for
the average normal batch; from 60 samples the forecast is 0.6480 against
0.6477 observed. The scores had picked up the slow reaction, and the
forecast follows it. For batch 34 the forecast of the cooling-water
temperature stays at the average of the normal batches, 46.64 from 60
samples and again from 115 samples, while the batch ran at 46.77 and 46.82.
The fault of batch 34 does not move the scores of the reference model, and a
forecast made from the scores cannot see it.
Running the script#
"""Run the whole case study and write its figures."""
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--output-dir", type=pathlib.Path, default=pathlib.Path("case-study-output/sbr"))
parser.add_argument("--data-url", default=None, help="override the openmv.net URL (for example a file:// copy)")
args = parser.parse_args(argv)
args.output_dir.mkdir(parents=True, exist_ok=True)
def save(fig: go.Figure, name: str) -> None:
fig.write_html(args.output_dir / f"{name}.html", include_plotlyjs="cdn")
trajectories, quality = load_data(args.data_url)
for tag in ("Conversion", "LatexDensity", "CoolingTemp"):
save(plot_raw(trajectories, tag, FAULT_BATCHES), f"raw-{tag}")
model = fit_model(trajectories, quality)
save(model.score_plot(settings=LABELS), "scores")
save(model.spe_plot(settings=LABELS), "spe")
save(plot_r2_over_time(r2_breakdown(model)), "r2-over-time")
save(time_varying_loading_plot(model, component=1), "weights-w1")
save(time_varying_loading_plot(model, component=2), "weights-w2")
t1_contributions = diagnose_batch_37(model, trajectories)
save(unfolded_contribution_plot(t1_contributions, FAULT_FROM_START), "contributions-37-t1")
save(unfolded_contribution_plot(t1_contributions, FAULT_FROM_START, by_tag=True), "contributions-37-t1-by-tag")
for tag in ("LatexDensity", "Conversion"):
save(plot_raw(trajectories, tag, [FAULT_FROM_START]), f"raw-{tag}-batch-37")
t2_contributions = diagnose_batch_34(model, trajectories)
save(unfolded_contribution_plot(t2_contributions, FAULT_MID_BATCH), "contributions-34-t2")
save(
contribution_at_time_plot(t2_contributions, k=INSPECT_SAMPLE, batch_id=FAULT_MID_BATCH),
"contributions-34-t2-at-sample",
)
for tag in ("CoolingTemp", "JacketTemp", "EnergyReleased"):
save(plot_raw(trajectories, tag, [FAULT_MID_BATCH]), f"raw-{tag}-batch-34")
compare_predictions(model, quality)
for variable in ("Composition", "ParticleSize"):
save(model.predictions_vs_observed_plot(quality, variable=variable), f"observed-vs-predicted-{variable}")
y_hat = evolving_prediction(model, trajectories, quality)
save(plot_online_prediction(y_hat, quality, AVERAGE_BATCH), f"online-prediction-batch-{AVERAGE_BATCH}")
save(plot_online_rmse(evolving_error(model, trajectories, quality)), "online-rmse")
monitor, _alarms = online_monitoring(trajectories, quality)
save(online_monitoring_plot(monitor, trajectories[FAULT_FROM_START], "t2"), f"online-t2-batch-{FAULT_FROM_START}")
save(online_monitoring_plot(monitor, trajectories[FAULT_MID_BATCH], "spe"), f"online-spe-batch-{FAULT_MID_BATCH}")
save(plot_forecasts(monitor.model, trajectories), f"forecast-batch-{FAULT_FROM_START}-and-{FAULT_MID_BATCH}")
print(f"figures written to {args.output_dir}")
return 0
References#
Paul Nomikos, Statistical process control of batch processes, PhD thesis, McMaster University, 1995.
Paul Nomikos and John F. MacGregor, “Monitoring batch processes using multiway principal component analysis”, AIChE Journal, 40, 1361-1375, 1994, https://literature.learnche.org/item/30/monitoring-batch-processes-using-multiway-principal-component-analysis The simulation and the two faulty batches.
Paul Nomikos and John F. MacGregor, “Multi-way partial least squares in monitoring batch processes”, Chemometrics and Intelligent Laboratory Systems, 30, 97-108, 1995, https://literature.learnche.org/item/32/multi-way-partial-least-squares-in-monitoring-batch-processes
Paul Nomikos and John F. MacGregor, “Multivariate SPC charts for monitoring batch processes”, Technometrics, 37, 41-59, 1995, https://literature.learnche.org/item/34/multivariate-spc-charts-for-monitoring-batch-processes
Francisco Arteaga and Alberto Ferrer, “Dealing with missing data in MSPC: several methods, different interpretations, some examples”, Journal of Chemometrics, 16, 408-418, 2002, https://literature.learnche.org/item/20/dealing-with-missing-data-in-mspc-several-methods-different-interpretations-some-examples
Salvador Garcia-Munoz, Theodora Kourti and John F. MacGregor, “Model predictive monitoring for batch processes”, Industrial & Engineering Chemistry Research, 43, 5929-5941, 2004, https://literature.learnche.org/item/157/model-predictive-monitoring-for-batch-processes
Svante Wold, Nouna Kettaneh-Wold, John F. MacGregor and Kevin G. Dunn, “Batch process modeling and MSPC”, Comprehensive Chemometrics, chapter 2.10, 163-197, 2009, https://literature.learnche.org/item/155/batch-process-modeling-and-mspc
Kevin Dunn, Latent Variable Methods course notes (ConnectMV, 2011-2012), the SBR batch PLS example, CC BY-SA 3.0.