Skip to content

Common-cause failures

Learning this for the first time?

This page is the reference. The ideas behind it are taught step by step, with worked examples and exercises, in Lesson 5 (when redundancy disappoints).

Redundancy only helps while the redundant units fail for independent reasons. In practice they often share a cause: one manufacturing batch, one power supply, one maintenance error applied to every unit. The exact engine assumes independence, so on its own it over-estimates a redundant group. A common-cause (CCF) group adds the shared failures back. Theory, including the model's assumptions: Concepts.

Declaring a group

Wrap the coupled nodes and a model in a CCFGroup and pass the groups with ccf_groups=:

import surpyval as surv
from surpyval import FixedEventProbability
from repyability import BetaFactor, CCFGroup, MGL, NonRepairableRBD, PerfectReliability

# Two redundant pumps, each failing with probability 0.01 on demand
edges = [("s", "p1"), ("s", "p2"), ("p1", "t"), ("p2", "t")]
pumps = {
    "p1": FixedEventProbability.from_params(0.01),
    "p2": FixedEventProbability.from_params(0.01),
}
independent = NonRepairableRBD(edges, pumps)
coupled = NonRepairableRBD(
    edges, pumps, ccf_groups=[CCFGroup(["p1", "p2"], BetaFactor(0.1))]
)
independent.ff()   # -> 0.0001    both must fail independently: 0.01²
coupled.ff()       # -> 0.001081  ten times worse: the shared cause dominates

The system unreliability is ten times the independent estimate, because one shared cause (probability β · Q = 0.001) now fails both pumps at once.

The two models

BetaFactor(beta): a fraction beta of each member's failure probability Q comes from a cause shared by the whole group; the rest, (1 − beta) Q, is independent. beta is in [0, 1]: 0 reproduces the independent result exactly and 1 makes the group no better than one unit. Over a lifetime, split the failure rate instead, BetaFactor(beta, basis="rate") (see Over a lifetime).

NonRepairableRBD(edges, pumps, ccf_groups=[CCFGroup(["p1", "p2"], BetaFactor(0.0))]).ff()
# -> 0.0001
NonRepairableRBD(edges, pumps, ccf_groups=[CCFGroup(["p1", "p2"], BetaFactor(1.0))]).ff()
# -> 0.01

MGL(beta, gamma, ...): the Multiple Greek Letter model also describes partial common causes, which fail some but not all of a larger group. beta is the probability that a failure is shared with at least one other member, gamma the probability that a shared failure takes at least three, and so on. A group of m members takes exactly m − 1 letters (MGL(0.1, 0.3).group_size == 3), and MGL(beta) on two members is exactly BetaFactor(beta).

Partial causes matter for k-out-of-n groups. For three units where two must work, a cause that fails a pair is enough to fail the system:

def two_of_three(model=None):
    return NonRepairableRBD(
        [("s", "a"), ("s", "b"), ("s", "c"),
         ("a", "v"), ("b", "v"), ("c", "v"), ("v", "t")],
        {
            "a": FixedEventProbability.from_params(0.01),
            "b": FixedEventProbability.from_params(0.01),
            "c": FixedEventProbability.from_params(0.01),
            "v": PerfectReliability,
        },
        k={"v": 2},
        ccf_groups=[CCFGroup(["a", "b", "c"], model)] if model else None,
    )

two_of_three().ff()                  # -> 0.000298   independent
two_of_three(BetaFactor(0.1)).ff()   # -> 0.001241   every shared cause takes all three
two_of_three(MGL(0.1, 0.3)).ff()     # -> 0.001591   70% of shared causes take a pair

With the same beta, the MGL group is worse here: a cause that fails two of the three is as fatal as one that fails all three, and MGL says most shared causes are of that kind.

What each model assigns

decompose(members, Q) shows how a model splits each member's failure probability Q into an independent part and mutually exclusive shocks (each a set of members failing together, with its probability):

import numpy as np

q_independent, shocks = MGL(0.1, 0.3).decompose(["a", "b", "c"], np.array([0.01]))
q_independent        # array([0.009])
[(sorted(members), float(p[0])) for members, p in shocks]
# [(['a', 'b'], 0.00035), (['a', 'c'], 0.00035), (['b', 'c'], 0.00035),
#  (['a', 'b', 'c'], 0.0003)]

required_group_size() is the group size a model needs (None for BetaFactor, which fits any group of two or more).

Exclusive or independent shocks

By default the shocks are mutually exclusive, as decompose gives them: one shared cause strikes the group at most, and a member's own failure is a separate event, independent of it, so each member fails with probability Q to first order, as in PRA's basic events (βQ + (1 − βQ)(1 − β)Q = Q − β(1 − β)Q² for a BetaFactor: 0.0991 at Q = 0.1, β = 0.1). PRA codes (SAPHIRE, CAFTA, RiskSpectrum) take each shock's Q_k as a basic event of its own instead, independent of the others, so several can strike at once. The two differ at second order in Q, so to check a result against such a tool, give MGL(..., shocks="independent"):

W = surv.Weibull.from_params([1000, 1.5])          # Q = 0.0311 at t = 100
parallel = [("s", x) for x in "abc"] + [(x, "t") for x in "abc"]

def vote(model):
    return NonRepairableRBD(parallel, {x: W for x in "abc"}, k={"t": 2},
                            ccf_groups=[CCFGroup(list("abc"), model)])

vote(MGL(0.2, 0.3)).ff(100.0)                         # -> 0.010219   exclusive
vote(MGL(0.2, 0.3, shocks="independent")).ff(100.0)   # -> 0.010192   as PRA codes

A BetaFactor, or an MGL group with one shared cause, has a single shock, and both conventions agree. By rate (basis="rate", below) the causes always strike independently.

Rules for groups

  • Symmetric. Every member must carry an identical model (the standard CCF assumption); otherwise the constructor raises ValueError.
  • Disjoint. A node can be in at most one group.
  • Members must be component nodes of the RBD: not the input or output node, and not a repeated component.
  • An MGL model must match its group's size.

Time-dependent members

With lifetime models, Q is each member's failure probability at the time asked for:

unit = surv.Weibull.from_params([1000, 2])
pair = NonRepairableRBD(edges, {"p1": unit, "p2": unit})
pair_ccf = NonRepairableRBD(
    edges, {"p1": unit, "p2": unit},
    ccf_groups=[CCFGroup(["p1", "p2"], BetaFactor(0.1))],
)
pair.ff(100)       # -> 9.9e-05
pair_ccf.ff(100)   # -> 0.001075

Keep each member's Q small. By default the models split a failure probability: they are the probabilistic-risk assessment basic-event models, rare-event models for a mission or a proof-test interval, where each member's Q stays small, not for a whole life. For a β = 0.3 pair, the result is within 3.5% of a rate-based treatment at Q = 0.1, but from about Q = 0.5 it comes out more reliable than an independent pair (Concepts has the numbers). So the diagram warns, once for each group, when a member's Q passes 0.1:

pair_ccf.sf(1000)   # -> 0.6336   more reliable than the independent pair's 0.6004
# UserWarning: Common-cause group ['p1', 'p2'] (BetaFactor(beta=0.1)): its members'
# probability of failing reaches Q = 0.632 at the times evaluated, beyond the 0.1
# the probability split is meant for. [...] Over a lifetime, split the failure
# rate: BetaFactor(beta=0.1, basis='rate').

Over a lifetime

basis="rate" splits each member's failure rate instead. The shared cause is a shock that has not struck by t with probability R(t)^β, and each member survives its own causes with R(t)^(1 − β), R the members' reliability:

  • every member keeps its own life distribution, whatever it is: its reliability is R(t)^β · R(t)^(1 − β) = R(t);
  • the model holds over the whole life, so the system's reliability falls to 0 as its members' do, and the exact MTTF and the simulations include the group;
  • while Q is small it agrees with the probability split to first order, so over a mission or a proof-test interval the two give about the same.
pair_rate = NonRepairableRBD(
    edges, {"p1": unit, "p2": unit},
    ccf_groups=[CCFGroup(["p1", "p2"], BetaFactor(0.1, basis="rate"))],
)
pair_rate.ff(100)    # -> 0.00108   as the probability split's 0.001075
pair_rate.sf(1000)   # -> 0.5862    below the independent pair's 0.6004
pair_rate.sf(3000)   # -> 0.000247  where the probability split still gives 0.1712
pair_rate.mean()     # -> 1129.5    exact; the independent pair's is 1145.8
pair_rate.mean(method="simulate", seed=0)   # -> 1128.6   the shocks are drawn
pair_rate.bx_life(10)   # -> 581.9

MGL(..., basis="rate") gives each specific set of members a cause of its own, which strikes independently of the others with the share of the members' hazard that the probability split gives its probability (Q_k / Q above). Several causes may have struck by a time, and a member has failed if any of its causes has, so decompose lists the distinct sets the struck causes fail between them, mutually exclusive as before:

q_independent, shocks = MGL(0.1, 0.3, basis="rate").decompose(["a", "b", "c"], np.array([0.01]))
q_independent[0]     # -> 0.0090045   1 − 0.99^0.9, against 0.009 by probability
[(sorted(members), round(float(p[0]), 7)) for members, p in shocks]
# [(['a', 'b'], 0.0003513), (['a', 'c'], 0.0003513), (['b', 'c'], 0.0003513),
#  (['a', 'b', 'c'], 0.0003018)]

The simulations draw a rate-split group through its members' quantile function: in the members' cumulative hazard H = −log R, each cause strikes at an exponential time of rate its share, and a member fails at the first of its causes to strike. Members whose model has no quantile function (one that draws its own random numbers) cannot be drawn so, and the simulations refuse the group; its sf, ff and mean stay exact.

Importance, sensitivity, uncertainty and allocation

A group's members fail together, so a member's state says something about the others'. The importance measures condition on it: R(1_i) and R(0_i), the system's reliability given member i works and given it has failed, are summed over the groups' shock outcomes, each weighted by the member's chance of that state under it. A node outside the groups is held working and failed, as without them. Birnbaum, improvement potential, RAW, RRW and criticality follow from them as usual, and Fussell–Vesely sums, outcome by outcome, the probability that a minimal cut set containing the node has failed. Each is a sum of products, so a small probability keeps its precision, and beta = 0 gives the measures without the group. With a valve in series with the pumps, the shared cause moves the importance from the valve to the pumps:

valve_edges = [("s", "p1"), ("s", "p2"), ("p1", "v"), ("p2", "v"), ("v", "t")]
valve_nodes = {
    "p1": FixedEventProbability.from_params(0.01),
    "p2": FixedEventProbability.from_params(0.01),
    "v": FixedEventProbability.from_params(0.001),
}
group = CCFGroup(["p1", "p2"], BetaFactor(0.1))
with_valve = NonRepairableRBD(valve_edges, valve_nodes, ccf_groups=[group])
with_valve.fussell_vesely()["p1"]          # -> 0.5197   independent: 0.0909
with_valve.fussell_vesely()["v"]           # -> 0.4808   independent: 0.9092
with_valve.risk_achievement_worth()["p1"]  # -> 52.45    independent: 9.99

A member held working or broken (working_nodes, broken_nodes) is refused, as it is for sf.

parameter_sensitivity reports a group's parameters once, under the tuple of its members. They carry one model, so each value is the derivative of the system reliability as the parameter moves for all of them at once. The parameters of the group's own model come with them, named ccf_beta (and, for an MGL model, ccf_gamma, ccf_delta, ...):

sensitivity = with_valve.parameter_sensitivity()
sensitivity[("p1", "p2")]["p"]          # -> -0.11606   the pumps' probability of failing
sensitivity[("p1", "p2")]["ccf_beta"]   # -> -0.00981
sensitivity["v"]["p"]                   # -> -0.99892

The parameter-uncertainty methods (sf_uncertainty, mean_uncertainty, time_to_reliability_uncertainty, bx_life_uncertainty) work out each draw with the groups. The members' uncertainty is given together, in one tuple, and the group's own model can be uncertain too: give the group as a key, with distributions over its parameters (or a list of models, drawn with replacement):

import scipy.stats as st

result = with_valve.sf_uncertainty(
    uncertainty={
        ("p1", "p2"): {"p": st.beta(2, 198)},   # about 0.01
        group: {"beta": st.beta(2, 18)},        # about 0.1
    },
    n_draws=10_000,
    seed=1,
)
result.interval(0.9)   # (0.99564, 0.9989); (0.99616, 0.99882) with beta known

mean_uncertainty refuses a group that splits a probability, as mean does.

In allocate_redundancy and redundancy_front a copy of a member joins its group, as it would in the plant: the shared cause fails it too. A BetaFactor group's beta holds at any size, so its members can be copied: active copies of their own model, with any number of them required. An MGL model's letters are for its group's size, so copying its member is refused, as are options and cold spares for a member. The shared cause can change what is worth buying. With two pumps that fail with probability 0.05 and a valve that fails with 0.002, a third pump is the better buy if the pumps are independent, and a second valve if a fifth of their failures are shared:

weak = {
    "p1": FixedEventProbability.from_params(0.05),
    "p2": FixedEventProbability.from_params(0.05),
    "v": FixedEventProbability.from_params(0.002),
}
costs = {"p1": 1.0, "p2": 1.0, "v": 1.0}
NonRepairableRBD(valve_edges, weak).allocate_redundancy(costs, budget=4).units
# {'p1': 1, 'p2': 2, 'v': 1}
NonRepairableRBD(
    valve_edges, weak, ccf_groups=[CCFGroup(["p1", "p2"], BetaFactor(0.2))]
).allocate_redundancy(costs, budget=4).units
# {'p1': 1, 'p2': 1, 'v': 2}

allocate_reliability_redundancy takes the groups in for the nodes outside them, and refuses to choose a member's component reliability, which is the group's.

What honours a CCF group

Method With CCF groups
sf, ff, reliability, unreliability, and what is derived from them (df, hf, Hf, cs, time_to_reliability, bx_life) Include the common cause, exactly. A probability split warns once its members' Q passes 0.1.
structural_importance Unaffected (it does not use probabilities).
mean, mean_time_to_failure (exact by default) Include a group splitting the rate. Raise NotImplementedError for a group splitting a probability: the exact MTTF integrates the reliability over whole lifetimes, where Q runs to 1.
random, mean(method="simulate"), mean_time_to_failure_interval, compare, unreliability_interval Draw a group splitting the rate with its shared shocks. Sample the members of a group splitting a probability independently, leaving the common cause out (unreliability_interval refuses such a group).
The importance measures (Birnbaum, improvement potential, RAW, RRW, criticality, Fussell–Vesely) Include the groups, exactly: a member conditioned on its state through the shock outcomes, a node outside them held.
parameter_sensitivity A group's parameters, and its model's (ccf_beta, ...), reported once, under the tuple of its members.
sf_uncertainty, time_to_reliability_uncertainty, bx_life_uncertainty, mean_uncertainty Each draw worked out with the groups; the members drawn together, and the group's model too if it is given. mean_uncertainty refuses a group splitting a probability, as mean does.
allocate_redundancy, redundancy_front A member's copies join its group: a BetaFactor group's, active copies of the member's own model. Copies of an MGL group's member, and options or cold spares for a member, raise NotImplementedError.
allocate_reliability_redundancy Includes the groups; a member in uses raises NotImplementedError (its reliability is the group's).
The condition-based methods (sf_given_state, remaining_life, importances_given_state) Raise NotImplementedError: they would need members of different ages.
working_nodes / broken_nodes naming a group member Raises NotImplementedError.
pair_ccf.mean(method="simulate", seed=0)   # -> 1147.4   the same as without the group
pair.mean(method="simulate", seed=0)       # -> 1147.4
pair.mean()                                # -> 1145.8   exact, without the group

Groups are saved with the RBD, their basis with them. An alpha-factor model, a data-estimable reparameterisation of MGL, is a planned extension.

Many groups

Given the outcome of a group's shocks, its members are independent of everything else, so the exact values condition on a group only within the smallest module of the diagram that holds its members: groups in separate modules (a pair of pumps in each of many stages) cost a sum each. Groups that share a module, as a group of each kind of component across redundant trains does, would multiply their outcomes: past 64 combinations, their shared causes are written out as events of their own, each repeated under every member it strikes, which the diagram's decision diagram works out with the rest (#219). A beta factor's cause, and an MGL model's by rate or with shocks="independent", are such events as they stand; an MGL model's exclusive shocks are taken as independent causes that fail the same sets of members as often, which exist unless the model leaves out a set that two pair shocks would fail together (a gamma of 0, say), when the groups are conditioned on together, as before. The values are the same either way:

import time

def trains(kinds, model):
    """Three trains of `kinds` components in series, a group of each kind."""
    units = {f"{i}{j}": surv.Exponential.from_params([1e-3])
             for i in range(kinds) for j in "abc"}
    edges = [(("s" if i == 0 else f"{i - 1}{j}"), f"{i}{j}")
             for i in range(kinds) for j in "abc"]
    edges += [(f"{kinds - 1}{j}", "t") for j in "abc"]
    groups = [CCFGroup([f"{i}{j}" for j in "abc"], model) for i in range(kinds)]
    return NonRepairableRBD(edges, units, ccf_groups=groups)

big = trains(30, MGL(0.1, 0.3))
start = time.perf_counter()
big.ff(1.0)                             # -> 0.0010048   30 groups across 3 trains
time.perf_counter() - start < 1.0       # True: where 5 ** 30 outcomes would never end

The Fussell–Vesely importance conditions on the groups within their modules, and the capacity distribution and the redundancy allocations on every combination of the groups' outcomes, as yet.

A FaultTree takes the same groups over its basic events, with the same exact top event probability and importance measures, and its conversions to and from a diagram keep them.

Repairable systems

A RepairableRBD takes ccf_groups too. A repairable component's failures are a rate, so a group's model always splits the rate (whatever its basis): each cause, a member's own or a shared one, strikes at its share of the failure rate and fails the members it names that are up, at once. Each member on its own still fails at its rate, so node_availability is as without the group; what changes is which members are down together. With two pumps, each down a tenth of the time, a fifth of their failures shared:

from repyability import RepairableRBD

pump = {
    "reliability": surv.Exponential.from_params([0.01]),
    "repairability": surv.Exponential.from_params([0.1]),
}
independent = RepairableRBD(edges, {"p1": pump, "p2": pump})
shared = RepairableRBD(
    edges,
    {"p1": pump, "p2": pump},
    ccf_groups=[CCFGroup(["p1", "p2"], BetaFactor(0.2))],
)
independent.mean_unavailability()   # -> 0.008264   (1/11)^2
shared.mean_unavailability()        # -> 0.01585

The long-run values are exact: a group's members form a Markov chain of which of them are down, its long-run distribution found without subtraction, so a small probability keeps its precision. The members need exponential lives, and either revealed failures with exponential repairs, as here, or hidden failures found by tests (with one coverage for the group), at offsets of their own if they are staggered, whose tests and repairs take no time, a fixed time or an exponential one (#220); see the PFDavg of a safety function. mean_availability, mean_unavailability, system_failure_frequency, MTBF, MUT and MDT, the cost rate, capacity_distribution, and the interval choices built on them take the groups in. So do the importance measures: a member's are conditioned on its state at each time, as its state says something of its group's, then averaged over the times as every node's are. With beta = 0 they are the measures without the group:

shared.birnbaum_importance()["p1"]       # -> 0.1743   P(p2 down | p1 down)
independent.birnbaum_importance()["p1"]  # -> 0.0909

Given which of its members are down, a group is independent of everything else, so, as for a non-repairable diagram, each group is conditioned on only within the smallest module of the diagram holding its members (#218): ten, or fifty, pairs of tested units in series, each pair a group, cost a sum, not a product, and take a fraction of a second. Groups that meet in one module, a group of each kind of component across redundant trains, multiply their members' combinations there; past about 33 million (combinations times the long-run grid's times) the exact values refuse, saying so, before working anything out: simulate such a system with availability() or cost(). The capacity distribution and the allocations still take every combination of every group's states at once, and refuse, before building them, where those would take too much memory. Limited repair crews are refused too, by every exact value, node_availability among them: the groups' chains do not take the crews' queue in, and the crews' chain does not take the common causes in, as yet. The simulations take both.

Over time from new, each group's chain is followed from every member up at 0 (by uniformization, or through the members' tests, after whose first period it repeats its long run), and each group is conditioned on within its module at each time, as in the long run. point_availability, mission_availability, expected_failures, expected_events, expected_cost, point_capacity and mission_capacity take the groups in, and settle into the long-run values. A member's own curve, and so its own events, are as without its group; the system's failures count each cause that takes it down, a shared one several members at once:

shared.point_availability(10.0)       # -> 0.98891   independent: 0.99632
shared.mission_availability(1000.0)   # -> 0.98429   settling to 0.98415
shared.expected_failures(1000.0)      # -> 3.158     independent: 1.639

The simulations (availability, cost, compare, simulate_timelines, event stepping and spares_demand(method="simulate")) draw each group's causes: each strikes as a Poisson process at its share of the members' failure rate, and fails the members it names that are up, at once. A test that can miss a failure tosses one coin for all the failures a cause makes. They run in Python (the compiled engine does not draw the causes, as yet), and they take in what the chains cannot: tests and repairs of any length, and revealed failures' repairs of any distribution:

run = shared.availability(1000.0, mc_samples=2000, seed=0)
run.system_uptime / (2000 * 1000.0)          # -> 0.9845   simulated
run.mean_availability_interval().estimate    # -> 0.98429  exact, as the chains give it

The parameter-uncertainty methods (mean_availability_uncertainty and the rest; see Repairable systems) rebuild the diagram with each draw's models, the groups included: a group's members are drawn together, in one tuple, and the group's own model is an input of its own, keyed by the group. A beta known only roughly, here to a Beta(4, 16) distribution about 0.2, widens the availability's interval:

import scipy.stats as st

group = shared.ccf_groups[0]
drawn = shared.mean_availability_uncertainty(
    {group: {"beta": st.beta(4, 16)}}, n_draws=1000, seed=0
)
drawn.interval(0.9)   # (0.9783, 0.9886)

In allocate_redundancy a member's copies join its group, as for a non-repairable diagram: a BetaFactor group's, each copy alike in every way, struck by the shared cause too, and tested with its member when its failures are hidden. Copies of an MGL group's member, or of a train holding one, are refused. Each design is scored exactly by its groups' chains, which count how many of a member's copies are down rather than telling them apart, so a design with many copies is quick to score. The copies are repaired at once (the allocations assume as many repair crews as jobs), so a shared failure ends with the first copy repaired, and a shared cause can make more pumps worth buying, not fewer:

costed = {
    "p1": {**pump, "acquisition_cost": 10000.0},
    "p2": {**pump, "acquisition_cost": 10000.0},
    "v": {
        "reliability": surv.Exponential.from_params([0.001]),
        "repairability": surv.Exponential.from_params([0.5]),
        "acquisition_cost": 3000.0,
    },
}
plant = RepairableRBD(valve_edges, costed, downtime_cost_rate=100.0)
plant.allocate_redundancy(87600.0, nodes=["p1", "v"]).units
# {'p1': 2, 'v': 2}
plant_shared = RepairableRBD(
    valve_edges,
    costed,
    ccf_groups=[CCFGroup(["p1", "p2"], BetaFactor(0.2))],
    downtime_cost_rate=100.0,
)
plant_shared.allocate_redundancy(87600.0, nodes=["p1", "v"]).units
# {'p1': 3, 'v': 2}

availability_allocation and mttf_mttr_allocation keep the members' availability, as their MTTF and MTTR are their group's, and allocate the other components', the system scored over the groups' joint states. The members then bound what the others can bring: with the valve never down, the shared failures hold the plant to 0.98415, and a higher target is refused:

plant_shared.availability_allocation(0.983).mttf["v"]   # -> 1704.4   the valve's MTTF, at its 2 h repairs

What the groups' chains need (exponential lives, and revealed failures with exponential repairs or tests and repairs in no time) holds for the exact and numerical values; the simulations need exponential lives alone. A member held working or broken, a member started from a current state (state=), a member maintained on a schedule, repaired imperfectly or in a maintenance group, and a group with limited repair crews are refused, with the reason; analysis_routes() says which.