Centre line, control limits and the rule violations of a process (an SPC or Shewhart chart). Reach for it to see a process drift before it leaves its specification.
import { ControlChart, controlLimits, violations } from "@umriss-ui/charts";
Control limits are computed from a named reference window, never from what happens to be visible; specification limits are chosen and drawn with LimitLine (ADR-0008).
The rules - outlier, run, trend, two of three - are exported as plain functions, so the violations can stand beside the chart as text.
In the plant: origin names the reference window the limits come from; the burner's overshoot breaks them while every tile stays in specification.
import { ControlChart, LimitLine, Tooltip, useChart } from "@umriss-ui/charts";
/* Data from the plant world, written out here so the example runs on its own. */
/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
Between the two warnings lies the tolerance - a tile fired outside it is
scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
/** The kiln fires one tile every quarter of a minute when nothing holds it up -
the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;
/** One minute of the line. */
interface Reading {
/** Minutes since the start of the shift. */
minute: number;
/** Zone 3 of the kiln, in °C. */
kiln: number;
/** The exit pyrometer, in °C - `null` while it sends nothing. */
exit: number | null;
/** Whether the belt runs. */
running: boolean;
/** Tiles out of the kiln in this minute. */
fired: number;
/** Of them, the ones fired inside the tolerance. */
good: number;
}
/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
minute: number;
/** The tile's length after firing, in mm (nominal 600). */
length: number;
}
/** A batch of tiles through the three stations. */
interface Batch {
id: string;
name: string;
/** How many tiles the batch is planned for. */
tiles: number;
/** Minutes since the start of the shift, per station. */
press: readonly [number, number];
dryer: readonly [number, number];
kiln: readonly [number, number];
}
interface Plant {
readings: readonly Reading[];
samples: readonly Sample[];
batches: readonly Batch[];
}
/* When the exit pyrometer goes silent, and for how long. Fixed rather than
seeded, so that a running control room shows its reading turn stale, then
lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;
/** The shift, from a seed. */
function plant(seed: number): Plant {
const r = random(seed);
/* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
into the shift, for half an hour, peaking near 1242 °C. The operator stops
the feed twenty-two minutes in, for eighteen minutes. The noise around it
is too small to reach a warning on its own, so this is the only crossing -
which is what the tests hold. */
const surgeFrom = 150 + Math.floor(r() * 60);
const surgeFor = 30;
const stopFrom = surgeFrom + 22;
const stopFor = 18;
/* The plan: batches through the kiln one after another, each pressed an
hour and dried half an hour before - the first ones during the night
shift. Tiles come out of the kiln only while a batch is in it, so the
OEE's count and the batches' counts are the same tiles. The plan is a
plan: a stop of the belt does not move it. */
const batches: Batch[] = [];
for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
const length = 70 + Math.floor(r() * 25);
batches.push({
id: `b-${4121 + i}`,
name: `B-${4121 + i}`,
tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
press: [at - 60, at - 60 + length],
dryer: [at - 30, at - 30 + length],
kiln: [at, at + length],
});
at += length + 5;
}
const readings: Reading[] = [];
const samples: Sample[] = [];
let drift = 0;
for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
drift = 0.9 * drift + (r() - 0.5) * 3;
const into = minute - surgeFrom;
const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
const jam = r() < 0.12;
const fired = running && loaded ? (jam ? 3 : 4) : 0;
const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
if (running && minute % 10 === 0) {
/* Hotter shrinks more: the length follows the kiln, so the control
chart sees the excursion the trend sees. */
const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
samples.push({ minute, length: Math.round(length * 100) / 100 });
}
}
return { readings, samples, batches };
}
/* A tile measured off the belt every ten minutes of the early shift. */
const SAMPLES = plant(7).samples;
/* The first fifteen samples, 06:00 to 08:20: the kiln demonstrably ran in
control. Never the samples that happen to be visible (ADR-0008). */
const REFERENCE_WINDOW = { kind: "referenceWindow", from: 0, to: 15 } as const;
export default function ControlChartExample() {
const { Chart, XAxis, YAxis } = useChart(SAMPLES);
return (
<Chart height={300} ariaLabel="Control chart of the tile length after firing">
<XAxis value="minute" label="Minute of the shift" />
<YAxis label="mm" />
{/* Specification limits are chosen, control limits are computed. */}
<LimitLine value={602.5} severity="alarm" label="USL" />
<LimitLine value={597.5} severity="alarm" label="LSL" />
<ControlChart
value="length"
data={SAMPLES}
origin={REFERENCE_WINDOW}
name="Tile length"
labelUpper="UCL"
labelLower="LCL"
violationName="Tile length - rule violation"
/>
<Tooltip mode="x" />
</Chart>
);
}
In the plant: onViolations hands over the verdict the chart marks, so the same finds can stand beside it as text.
import { useState } from "react";
import { ControlChart, Tooltip, useChart } from "@umriss-ui/charts";
import type { RuleName, Violation } from "@umriss-ui/charts";
/* Data from the plant world, written out here so the example runs on its own. */
/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
Between the two warnings lies the tolerance - a tile fired outside it is
scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
/** The kiln fires one tile every quarter of a minute when nothing holds it up -
the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;
/** One minute of the line. */
interface Reading {
/** Minutes since the start of the shift. */
minute: number;
/** Zone 3 of the kiln, in °C. */
kiln: number;
/** The exit pyrometer, in °C - `null` while it sends nothing. */
exit: number | null;
/** Whether the belt runs. */
running: boolean;
/** Tiles out of the kiln in this minute. */
fired: number;
/** Of them, the ones fired inside the tolerance. */
good: number;
}
/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
minute: number;
/** The tile's length after firing, in mm (nominal 600). */
length: number;
}
/** A batch of tiles through the three stations. */
interface Batch {
id: string;
name: string;
/** How many tiles the batch is planned for. */
tiles: number;
/** Minutes since the start of the shift, per station. */
press: readonly [number, number];
dryer: readonly [number, number];
kiln: readonly [number, number];
}
interface Plant {
readings: readonly Reading[];
samples: readonly Sample[];
batches: readonly Batch[];
}
/* When the exit pyrometer goes silent, and for how long. Fixed rather than
seeded, so that a running control room shows its reading turn stale, then
lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;
/** The shift, from a seed. */
function plant(seed: number): Plant {
const r = random(seed);
/* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
into the shift, for half an hour, peaking near 1242 °C. The operator stops
the feed twenty-two minutes in, for eighteen minutes. The noise around it
is too small to reach a warning on its own, so this is the only crossing -
which is what the tests hold. */
const surgeFrom = 150 + Math.floor(r() * 60);
const surgeFor = 30;
const stopFrom = surgeFrom + 22;
const stopFor = 18;
/* The plan: batches through the kiln one after another, each pressed an
hour and dried half an hour before - the first ones during the night
shift. Tiles come out of the kiln only while a batch is in it, so the
OEE's count and the batches' counts are the same tiles. The plan is a
plan: a stop of the belt does not move it. */
const batches: Batch[] = [];
for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
const length = 70 + Math.floor(r() * 25);
batches.push({
id: `b-${4121 + i}`,
name: `B-${4121 + i}`,
tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
press: [at - 60, at - 60 + length],
dryer: [at - 30, at - 30 + length],
kiln: [at, at + length],
});
at += length + 5;
}
const readings: Reading[] = [];
const samples: Sample[] = [];
let drift = 0;
for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
drift = 0.9 * drift + (r() - 0.5) * 3;
const into = minute - surgeFrom;
const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
const jam = r() < 0.12;
const fired = running && loaded ? (jam ? 3 : 4) : 0;
const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
if (running && minute % 10 === 0) {
/* Hotter shrinks more: the length follows the kiln, so the control
chart sees the excursion the trend sees. */
const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
samples.push({ minute, length: Math.round(length * 100) / 100 });
}
}
return { readings, samples, batches };
}
const SAMPLES = plant(7).samples;
const REFERENCE_WINDOW = { kind: "referenceWindow", from: 0, to: 15 } as const;
const RULES: Record<RuleName, string> = {
outlier: "Beyond a control limit",
run: "A run on one side of the centre",
trend: "A steady climb or fall",
twoOfThree: "Two of three near a limit",
};
const clock = (minute: number) => {
const total = 6 * 60 + minute;
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
};
export default function ListTheViolations() {
const [found, setFound] = useState<readonly Violation[]>([]);
const { Chart, XAxis, YAxis } = useChart(SAMPLES);
return (
<div className="side-by-side">
<div style={{ flex: "1 1 320px" }}>
<Chart height={240} ariaLabel="Control chart of the tile length, its violations listed beside it">
<XAxis value="minute" label="Minute of the shift" />
<YAxis label="mm" />
<ControlChart value="length" data={SAMPLES} origin={REFERENCE_WINDOW} name="Tile length" onViolations={setFound} />
<Tooltip mode="x" />
</Chart>
</div>
<ul className="side-note">
{found.length === 0 ? (
<li>No rule violated.</li>
) : (
found.map((v) => (
<li key={v.rule}>
<strong>{RULES[v.rule]}</strong>: {v.indices.map((i) => clock(SAMPLES[i]!.minute)).join(", ")}
</li>
))
)}
</ul>
</div>
);
}
In the plant: rules switches rules off and sets their run lengths - here rule 4 is off and a run counts from eight - and without zoneLines the sigma zones stay unmarked.
import { useState } from "react";
import { ControlChart, Tooltip, useChart } from "@umriss-ui/charts";
import type { RuleName, Violation } from "@umriss-ui/charts";
/* Data from the plant world, written out here so the example runs on its own. */
/** A small LCG - reproducible across runs and platforms. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
/** The early shift, 06:00 to 14:00. */
const SHIFT_MINUTES = 480;
/** The kiln's zone 3, in °C: the numbers every part reads it against, once.
Between the two warnings lies the tolerance - a tile fired outside it is
scrap; `returnTo` is the alarm's dead band. */
const KILN = { target: 1200, tolerance: [1185, 1215], alarm: 1230, returnTo: 1222 } as const;
/** The kiln fires one tile every quarter of a minute when nothing holds it up -
the ideal cycle time of the OEE's performance. */
const IDEAL_CYCLE_MINUTES = 0.25;
/** One minute of the line. */
interface Reading {
/** Minutes since the start of the shift. */
minute: number;
/** Zone 3 of the kiln, in °C. */
kiln: number;
/** The exit pyrometer, in °C - `null` while it sends nothing. */
exit: number | null;
/** Whether the belt runs. */
running: boolean;
/** Tiles out of the kiln in this minute. */
fired: number;
/** Of them, the ones fired inside the tolerance. */
good: number;
}
/** A tile taken off the belt and measured - one every ten minutes while it runs. */
interface Sample {
minute: number;
/** The tile's length after firing, in mm (nominal 600). */
length: number;
}
/** A batch of tiles through the three stations. */
interface Batch {
id: string;
name: string;
/** How many tiles the batch is planned for. */
tiles: number;
/** Minutes since the start of the shift, per station. */
press: readonly [number, number];
dryer: readonly [number, number];
kiln: readonly [number, number];
}
interface Plant {
readings: readonly Reading[];
samples: readonly Sample[];
batches: readonly Batch[];
}
/* When the exit pyrometer goes silent, and for how long. Fixed rather than
seeded, so that a running control room shows its reading turn stale, then
lost, and come back. */
const SILENT_FROM = 288;
const SILENT_FOR = 50;
/** The shift, from a seed. */
function plant(seed: number): Plant {
const r = random(seed);
/* The one excursion: a burner overshoots somewhere between 02:30 and 03:30
into the shift, for half an hour, peaking near 1242 °C. The operator stops
the feed twenty-two minutes in, for eighteen minutes. The noise around it
is too small to reach a warning on its own, so this is the only crossing -
which is what the tests hold. */
const surgeFrom = 150 + Math.floor(r() * 60);
const surgeFor = 30;
const stopFrom = surgeFrom + 22;
const stopFor = 18;
/* The plan: batches through the kiln one after another, each pressed an
hour and dried half an hour before - the first ones during the night
shift. Tiles come out of the kiln only while a batch is in it, so the
OEE's count and the batches' counts are the same tiles. The plan is a
plan: a stop of the belt does not move it. */
const batches: Batch[] = [];
for (let at = 0, i = 0; at < SHIFT_MINUTES; i++) {
const length = 70 + Math.floor(r() * 25);
batches.push({
id: `b-${4121 + i}`,
name: `B-${4121 + i}`,
tiles: Math.round(length / IDEAL_CYCLE_MINUTES / 10) * 10,
press: [at - 60, at - 60 + length],
dryer: [at - 30, at - 30 + length],
kiln: [at, at + length],
});
at += length + 5;
}
const readings: Reading[] = [];
const samples: Sample[] = [];
let drift = 0;
for (let minute = 0; minute < SHIFT_MINUTES; minute++) {
drift = 0.9 * drift + (r() - 0.5) * 3;
const into = minute - surgeFrom;
const surge = into >= 0 && into <= surgeFor ? 42 * Math.sin((Math.PI * into) / surgeFor) : 0;
const kiln = Math.round((KILN.target + drift + surge) * 10) / 10;
const running = !(minute >= stopFrom && minute < stopFrom + stopFor);
const loaded = batches.some((batch) => batch.kiln[0] <= minute && minute < batch.kiln[1]);
const jam = r() < 0.12;
const fired = running && loaded ? (jam ? 3 : 4) : 0;
const inTolerance = kiln >= KILN.tolerance[0] && kiln <= KILN.tolerance[1];
const silent = minute >= SILENT_FROM && minute < SILENT_FROM + SILENT_FOR;
const exit = silent ? null : Math.round((kiln - 380 + (r() - 0.5) * 4) * 10) / 10;
readings.push({ minute, kiln, exit, running, fired, good: inTolerance ? fired : 0 });
if (running && minute % 10 === 0) {
/* Hotter shrinks more: the length follows the kiln, so the control
chart sees the excursion the trend sees. */
const length = 600 + (KILN.target - kiln) * 0.05 + (r() - 0.5) * 0.6;
samples.push({ minute, length: Math.round(length * 100) / 100 });
}
}
return { readings, samples, batches };
}
const SAMPLES = plant(7).samples;
const REFERENCE_WINDOW = { kind: "referenceWindow", from: 0, to: 15 } as const;
/* Western Electric's run of eight in place of Nelson's nine; "two of three
near a limit" is not watched on this line, so its zones need no lines. */
const CHOSEN = { twoOfThree: false, runLength: 8 } as const;
const RULES: Record<RuleName, string> = {
outlier: "Beyond a control limit",
run: "Eight on one side of the centre",
trend: "A steady climb or fall",
twoOfThree: "Two of three near a limit",
};
const clock = (minute: number) => {
const total = 6 * 60 + minute;
return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String(total % 60).padStart(2, "0")}`;
};
export default function ChooseTheRules() {
const [found, setFound] = useState<readonly Violation[]>([]);
const { Chart, XAxis, YAxis } = useChart(SAMPLES);
return (
<div className="side-by-side">
<div style={{ flex: "1 1 320px" }}>
<Chart height={240} ariaLabel="Control chart of the tile length under three of the four rules">
<XAxis value="minute" label="Minute of the shift" />
<YAxis label="mm" />
<ControlChart
value="length"
data={SAMPLES}
origin={REFERENCE_WINDOW}
name="Tile length"
rules={CHOSEN}
zoneLines={false}
onViolations={setFound}
/>
<Tooltip mode="x" />
</Chart>
</div>
<ul className="side-note">
{found.length === 0 ? (
<li>No rule violated.</li>
) : (
found.map((v) => (
<li key={v.rule}>
<strong>{RULES[v.rule]}</strong>: {v.indices.map((i) => clock(SAMPLES[i]!.minute)).join(", ")}
</li>
))
)}
</ul>
</div>
);
}
The keys of Chart apply here.
ControlChartProps<T>| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | Value<T> | — | The values of the chart - a number field of its data's rows or a
function of them.Shown in: Chart a process against its control limits, List the rule violations, Choose the rules and 1 more |
datarequired | readonly T[] | — | Required: the limits arise out of these values, not out of what happens to
be visible. Shown in: Chart a process against its control limits, List the rule violations, Choose the rules and 1 more |
originrequired | ControlLimitOrigin | — | Given, or from a named reference window - never tacitly from everything on
the screen (ADR-0008). Shown in: Chart a process against its control limits, List the rule violations, Choose the rules and 1 more |
xAxisId | string | "x" | Binding to an x axis. |
yAxisId | string | "y" | Binding to a y axis. |
name | string | "Series n", after its place in the chart | The name in legend and tooltip. Without one a warning stands in DEV - an
unnamed series is a colour nobody can look up. Shown in: Chart a process against its control limits, List the rule violations, Choose the rules and 1 more |
format | (value: number) => string | the y axis' tickFormat, else the built-in number format | The value as the tooltip writes it - on the line and on the violations. |
color | string | the palette's --uc-series-N | Any CSS colour value. |
tone | | "ok" | — | A role instead of a colour value for the line; the theme resolves it. The violations are always "alarm". |
rules | Partial<RuleOptions> | — | Switchable one by one; run lengths are parameters. Shown in: Choose the rules |
zoneLines | boolean | true | The faint zone lines at one and two sigma. Rule 4 is about the two-sigma
zone; without them a reader cannot check it. Shown in: Choose the rules |
labelUpper | string | no label | Label of the upper control limit in the axis band. The package brings no
text along, not even "UCL" and "LCL". Shown in: Chart a process against its control limits, Hold the tile length in control (Scenarios) |
labelLower | string | no label | The same for the lower control limit. Named separately because a chart may
well want one of the two labelled and not the other. Shown in: Chart a process against its control limits, Hold the tile length in control (Scenarios) |
violationName | string | "Series n", after its place in the chart | Name of the scatter of violations in the legend and the tooltip. Without
one a legend warns once in DEV. Before this, a German suffix stood on the
name of the chart. Shown in: Chart a process against its control limits, Hold the tile length in control (Scenarios) |
| Name | Type | Default | Description |
|---|---|---|---|
onViolations | (found: readonly Violation[]) => void | — | The violations as data - they can be listed beside the chart as well as
marked on it. Shown in: List the rule violations, Choose the rules |
Value<T>A value read from the row (ADR-0048): the name of a number field, compared
by its name, or a function, compared by its source text (ValueFunction).
type Value<T> = NumberField<T> | ValueFunction<T>;ControlLimitOriginWhere the limits come from. There are exactly these two possibilities.
type ControlLimitOrigin =
/** From a baseline the caller has fixed. Taken as it is - even where the
series would yield something else out of itself. */
| { readonly kind: "given"; readonly center: number; readonly sigma: number }
/** From exactly this index range of the series, half-open [from, to). */
| { readonly kind: "referenceWindow"; readonly from: number; readonly to: number };RuleOptionsWhich rules run, and with which run lengths.
| Name | Type | Default | Description |
|---|---|---|---|
outlierrequired | boolean | — | |
runrequired | boolean | — | |
trendrequired | boolean | — | |
twoOfThreerequired | boolean | — | |
runLengthrequired | number | — | Points on one side of the centre line from which rule 2 fires. |
trendLengthrequired | number | — | Points in succession from which rule 3 fires. |
ViolationA rule and the indices it violates.
| Name | Type | Default | Description |
|---|---|---|---|
rulerequired | RuleName| "outlier" | — | Shown in: List the rule violations, Choose the rules, Hold the tile length in control (Scenarios) |
indicesrequired | readonly number[] | — | Shown in: List the rule violations, Choose the rules, Hold the tile length in control (Scenarios) |
NumberField<T>The fields of a row whose value is a number, or absent.
type NumberField<T> = NoInfer<
{
[K in keyof T & string]-?: T[K] extends number | null | undefined ? K : never;
}[keyof T & string]
>;ValueFunction<T>A value computed from the row - the function form of Value.
Compared by its source text, not its identity - an inline function is new
on every render. Known limit: one that reads a
changed closure variable under the same text is not run again; pass a new
data reference then. A native or bound function is compared by identity.
type ValueFunction<T> = (d: T, index: number) => number | null | undefined;RuleNameName of a rule, the way it is switched on and off in violations.
type RuleName = "outlier" | "run" | "trend" | "twoOfThree";