Weibull Probability Plot for Reliability Analysis — MUI X Charts

A Weibull probability plot displays failure or lifetime data on Weibull probability paper (logarithmic x-axis for time/cycles, linearized Weibull CDF on y-axis) with a fitted straight line. It is the standard tool in reliability engineering for estimating Weibull distribution parameters (shape and scale), assessing whether data follow a Weibull distribution, and extrapolating failure probabilities. The slope of the fitted line gives the shape parameter (beta), while the characteristic life (eta) is read at the 63.2% failure probability crossing.

Weibull Probability Plot for Reliability Analysis rendered with MUI X Charts

Renders

JavaScript source (MUI X Charts)

// anyplot.ai
// probability-weibull: Weibull Probability Plot for Reliability Analysis
// Library: muix 7.29.1 | JavaScript 22.23.2
// Quality: 92/100 | Created: 2026-08-24
import { ChartContainer } from "@mui/x-charts/ChartContainer";
import { ScatterPlot } from "@mui/x-charts/ScatterChart";
import { LinePlot } from "@mui/x-charts/LineChart";
import { ChartsXAxis } from "@mui/x-charts/ChartsXAxis";
import { ChartsYAxis } from "@mui/x-charts/ChartsYAxis";
import { ChartsGrid } from "@mui/x-charts/ChartsGrid";
import { ChartsReferenceLine } from "@mui/x-charts/ChartsReferenceLine";
import { useDrawingArea, useYScale } from "@mui/x-charts/hooks";

const t = window.ANYPLOT_TOKENS;
const THEME = window.ANYPLOT_THEME === "dark" ? "dark" : "light";
// ANYPLOT_TOKENS has no "muted" anchor — derive it from default-style-guide.md
// "Theme-adaptive Chrome" (tertiary text token).
const INK_MUTED = THEME === "dark" ? "#A8A79F" : "#6B6A63";

// --- Data: fixed-seed LCG (the browser has no seeded RNG) -------------------
function makeLcg(seed: number) {
  let state = seed >>> 0;
  return () => {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state / 4294967296;
  };
}
const rand = makeLcg(42);

// Turbine blade fatigue-life test: cycles to failure, in thousands (kcycles).
const BETA_TRUE = 2.4;
const ETA_TRUE = 420;
const N = 24;
const CENSORED_EVERY = 6; // every 6th-ranked blade pulled for inspection, unfailed

const sortedTimes = Array.from({ length: N }, () => {
  const u = rand();
  return (
    Math.round(ETA_TRUE * Math.pow(-Math.log(1 - u), 1 / BETA_TRUE) * 10) / 10
  );
}).sort((a, b) => a - b);

// Never censor the very first (smallest) time — a suspension needs at least
// one preceding failure to inherit a plotting position from.
const observations = sortedTimes.map((time, idx) => ({
  time,
  isCensored: idx > 0 && (idx + 1) % CENSORED_EVERY === 0,
}));

// Median-rank plotting positions via Johnson's rank-adjustment method: each
// failure's adjusted rank absorbs the "credit" left behind by suspensions
// that preceded it, then Benard's approximation turns rank into probability.
let adjustedRank = 0;
let lastProbability = 0;
const n = observations.length;
const points = observations.map((obs, idx) => {
  const reverseRank = n - idx;
  if (!obs.isCensored) {
    adjustedRank += (n + 1 - adjustedRank) / (1 + reverseRank);
    lastProbability = (adjustedRank - 0.3) / (n + 0.4);
  }
  return { ...obs, probability: lastProbability };
});

// y = ln(-ln(1-F)) linearizes the Weibull CDF so Weibull data plots straight.
const weibullY = (p: number) => Math.log(-Math.log(1 - p));

const failurePoints = points.filter((p) => !p.isCensored);
const censoredPoints = points.filter((p) => p.isCensored);

const failureData = failurePoints.map((p, i) => ({
  x: p.time,
  y: weibullY(p.probability),
  id: `failure-${i}`,
}));
const censoredData = censoredPoints.map((p, i) => ({
  x: p.time,
  y: weibullY(p.probability),
  id: `censored-${i}`,
}));

// Least-squares fit of ln(t) vs y over failures only: y = beta*ln(t) + b, so
// slope = shape parameter (beta) and eta = exp(-intercept / beta).
const xs = failurePoints.map((p) => Math.log(p.time));
const ys = failureData.map((d) => d.y);
const nF = xs.length;
const meanX = xs.reduce((a, b) => a + b, 0) / nF;
const meanY = ys.reduce((a, b) => a + b, 0) / nF;
let sxy = 0;
let sxx = 0;
for (let i = 0; i < nF; i += 1) {
  sxy += (xs[i] - meanX) * (ys[i] - meanY);
  sxx += (xs[i] - meanX) ** 2;
}
const beta = sxy / sxx;
const intercept = meanY - beta * meanX;
const eta = Math.exp(-intercept / beta);

const X_MIN = 80;
const X_MAX = 1000;
const fitLineX = [X_MIN, X_MAX];
const fitLineY = fitLineX.map((x) => beta * Math.log(x) + intercept);

const yAt632 = weibullY(0.632);

// Probability-paper y-axis: fixed percentage gridlines, deliberately NOT
// evenly spaced in y — that nonlinearity is the whole point of the transform.
const PROB_TICKS = [1, 2, 5, 10, 20, 30, 50, 63.2, 80, 90, 95, 99];
const yTickValues = PROB_TICKS.map((p) => weibullY(p / 100));
const yTickLabels = new Map(
  PROB_TICKS.map((p, i) => [yTickValues[i], `${p}%`]),
);
const Y_MIN = weibullY(0.01);
const Y_MAX = weibullY(0.99);

// Custom scatter marker: filled circle for observed failures, hollow ring for
// right-censored (suspended) units — same hue, status told by fill alone.
function StatusMarker({ series, xScale, yScale, color, markerSize }: any) {
  const isCensored = series.id === "censored";
  return (
    <g>
      {series.data.map((point: any) => (
        <circle
          key={point.id}
          cx={xScale(point.x)}
          cy={yScale(point.y)}
          r={markerSize}
          fill={isCensored ? "none" : color}
          stroke={color}
          strokeWidth={isCensored ? 2.5 : 0}
        />
      ))}
    </g>
  );
}

const TITLE = "probability-weibull · javascript · muix · anyplot.ai";
const TITLE_FONT_SIZE =
  TITLE.length > 67 ? Math.max(15, Math.round((22 * 67) / TITLE.length)) : 22;

// Chart margins — the y-axis label below is a hand-placed <text>, not the
// built-in `label` prop, because ChartsYAxis offsets it by a fixed
// `tickFontSize + tickSize + 10` regardless of the actual (custom-formatted)
// tick label width, which collided with "63.2%"-style ticks.
// `top` is tall enough for the hand-rolled 3-row legend below the title.
const MARGIN = { top: 132, right: 64, bottom: 84, left: 140 };

// Hand-rolled legend: the built-in ChartsLegend only draws solid-color square
// swatches, which can't reflect the filled-vs-hollow marker encoding used on
// the chart. Swatches here mirror the on-chart marks exactly (dash for the
// fit line, filled circle for failures, hollow ring for suspensions).
const LEGEND_FONT_SIZE = 13;
const LEGEND_ROW_HEIGHT = 22;
const LEGEND_TOP = 60;

function Legend({ right }: { right: number }) {
  const legendItems = [
    {
      kind: "dash" as const,
      label: `Fit: β≈${beta.toFixed(2)}, η≈${Math.round(eta)} kcycles`,
      swatchColor: t.ink,
    },
    { kind: "filled" as const, label: "Failure", swatchColor: t.palette[0] },
    {
      kind: "hollow" as const,
      label: "Suspended (censored)",
      swatchColor: t.palette[0],
    },
  ];
  const legendWidth = Math.max(
    ...legendItems.map(
      (item) => 22 + item.label.length * LEGEND_FONT_SIZE * 0.58,
    ),
  );
  const left = right - legendWidth;
  return (
    <g>
      {legendItems.map((item, i) => {
        const y = LEGEND_TOP + i * LEGEND_ROW_HEIGHT;
        return (
          <g key={item.kind}>
            {item.kind === "dash" && (
              <line
                x1={left}
                x2={left + 14}
                y1={y}
                y2={y}
                stroke={item.swatchColor}
                strokeWidth={2.5}
                strokeDasharray="6 3"
              />
            )}
            {item.kind === "filled" && (
              <circle cx={left + 7} cy={y} r={6} fill={item.swatchColor} />
            )}
            {item.kind === "hollow" && (
              <circle
                cx={left + 7}
                cy={y}
                r={6}
                fill="none"
                stroke={item.swatchColor}
                strokeWidth={2.5}
              />
            )}
            <text
              x={left + 22}
              y={y}
              dominantBaseline="central"
              fontSize={LEGEND_FONT_SIZE}
              fill={t.inkSoft}
            >
              {item.label}
            </text>
          </g>
        );
      })}
    </g>
  );
}

// Small elevated callout for the eta/63.2% crossing — a filled rounded box
// behind the label sharpens the focal point beyond a thin dashed line + text.
function ReferenceCallout({ y, label }: { y: number; label: string }) {
  const { left, width: drawWidth } = useDrawingArea();
  const yScale = useYScale();
  const yPixel = yScale(y);
  const paddingX = 8;
  const boxWidth = label.length * 6.8 + paddingX * 2;
  const boxHeight = 22;
  const boxX = left + drawWidth - boxWidth;
  const boxY = yPixel - boxHeight - 8;
  return (
    <g>
      <rect
        x={boxX}
        y={boxY}
        width={boxWidth}
        height={boxHeight}
        rx={4}
        fill={t.elevatedBg}
        stroke={INK_MUTED}
        strokeWidth={1}
      />
      <text
        x={boxX + boxWidth / 2}
        y={boxY + boxHeight / 2}
        textAnchor="middle"
        dominantBaseline="central"
        fontSize={LEGEND_FONT_SIZE}
        fill={INK_MUTED}
      >
        {label}
      </text>
    </g>
  );
}

// --- Chart (default-exported component — the harness mounts it) -----------
export default function Chart() {
  const { width, height } = window.ANYPLOT_SIZE;
  const plotMidY = MARGIN.top + (height - MARGIN.top - MARGIN.bottom) / 2;

  return (
    <ChartContainer
      width={width}
      height={height}
      margin={MARGIN}
      series={[
        {
          type: "line",
          id: "fit",
          data: fitLineY,
          curve: "linear",
          color: t.ink,
          showMark: false,
          disableHighlight: true,
        },
        {
          type: "scatter",
          id: "failure",
          data: failureData,
          markerSize: 12,
          color: t.palette[0],
        },
        {
          type: "scatter",
          id: "censored",
          data: censoredData,
          markerSize: 12,
          color: t.palette[0],
        },
      ]}
      xAxis={[
        {
          data: fitLineX,
          scaleType: "log",
          min: X_MIN,
          max: X_MAX,
          tickInterval: [100, 200, 300, 500, 700, 1000],
          valueFormatter: (v: number) => `${v}`,
          label: "Cycles to Failure (thousands)",
          tickLabelStyle: { fontSize: 14 },
          labelStyle: { fontSize: 16 },
        },
      ]}
      yAxis={[
        {
          scaleType: "linear",
          min: Y_MIN,
          max: Y_MAX,
          tickInterval: yTickValues,
          valueFormatter: (v: number) => yTickLabels.get(v) ?? "",
          tickLabelStyle: { fontSize: 14 },
        },
      ]}
      skipAnimation
    >
      <ChartsGrid horizontal vertical />
      <LinePlot
        skipAnimation
        slotProps={{ line: { strokeDasharray: "12 7", strokeWidth: 2.5 } }}
      />
      <ScatterPlot slots={{ scatter: StatusMarker }} />
      <ChartsXAxis />
      <ChartsYAxis />
      <ChartsReferenceLine
        y={yAt632}
        lineStyle={{
          stroke: INK_MUTED,
          strokeDasharray: "4 4",
          strokeWidth: 1.5,
        }}
      />
      <ReferenceCallout
        y={yAt632}
        label={`63.2% · η ≈ ${Math.round(eta)} kcycles`}
      />
      <Legend right={width - MARGIN.right} />
      <text
        x={width / 2}
        y={44}
        textAnchor="middle"
        fontSize={TITLE_FONT_SIZE}
        fontWeight={600}
        fill={t.ink}
      >
        {TITLE}
      </text>
      <text
        x={0}
        y={0}
        textAnchor="middle"
        fontSize={16}
        fill={t.ink}
        transform={`translate(36, ${plotMidY}) rotate(-90)`}
      >
        Cumulative Failure Probability
      </text>
    </ChartContainer>
  );
}

Part of Weibull Probability Plot for Reliability Analysis on anyplot.ai.

Other implementations