Skip to content

System capacity

A diagram says whether a system works; a plant also asks how much it delivers. With three pumps of half the demand each, one failure costs nothing and two cost half the output, while the reliability of the pumps in parallel says only that the plant still runs. Give each node a capacity, its throughput while it works, and RePyability works out the exact distribution of the system's capacity: how likely each output is, the probability that it meets a demand, and the output to expect. Theory: Concepts.

Giving nodes a capacity

Every RBD class takes capacity, a dict of node capacities, alongside k:

import numpy as np
import surpyval as surv
from repyability import NonRepairableRBD

pump = surv.Weibull.from_params([2000, 1.5])
pipe = surv.Exponential.from_params([1e-5])

#   in -> (p1 | p2 | p3) -> pipe -> out      pumps of 50, a pipe of 120
edges = [("in", "p1"), ("in", "p2"), ("in", "p3"),
         ("p1", "pipe"), ("p2", "pipe"), ("p3", "pipe"), ("pipe", "out")]
plant = NonRepairableRBD(
    edges,
    {"p1": pump, "p2": pump, "p3": pump, "pipe": pipe},
    capacity={"p1": 50, "p2": 50, "p3": 50, "pipe": 120},
)

The rules:

  • A node carries at most its capacity while it works, and nothing once it has failed. Capacities are positive numbers in any unit (the same for every node): tonnes per hour, megawatts, percent of the design flow.
  • The system's capacity is the most that can flow from the input to the output: the diagram's maximum flow. The edges carry any amount, so a series chain carries the least of its nodes' capacities and a parallel group the sum. Here all three pumps give 150, but the pipe passes 120.
  • A node given no capacity limits nothing: it passes whatever reaches it while it works (a capacity of inf). Control systems and power supplies are usually of this kind: needed, but not carrying the flow.
  • A node that works at several levels takes a dict of them instead (see below).
  • A k-out-of-n node keeps its meaning (see below).
  • A repeated node has the capacity of the node it repeats, wherever it is drawn: give that node's.

The capacity is positive exactly when the system works, so nothing about reliability changes: capacity adds to the analysis, it does not replace it.

At a time (non-repairable systems)

capacity_distribution(x) gives the distribution at time x, a CapacityDistribution:

capacity = plant.capacity_distribution(1000)
capacity.levels                     # array([  0.,  50., 100., 120.])
capacity.probabilities              # array([0.0361, 0.185 , 0.4361, 0.3428])
capacity.meets(100)                 # -> 0.7789   at least two pumps, and the pipe
capacity.mean                       # -> 93.997   the expected capacity
capacity.delivered_fraction(100)    # -> 0.8714   E[min(capacity, 100)] / 100
capacity.meets(50)                  # -> 0.9639   the capacity is positive...
plant.sf(1000)                      # -> 0.9639   ...exactly when the plant works
  • levels are the capacities the system can have, in increasing order (0 when it is down), and probabilities their probabilities.
  • meets(demand) is the probability that the capacity is at least the demand: the system's reliability for that demand. A capacity within rounding of the demand meets it, so three units of 1/3 meet a demand of 1.
  • mean() is the expected capacity.
  • delivered_fraction(demand) is the fraction of the demand the system can be expected to deliver: all of it when the capacity is enough, and what it can when it is not.

An array of times gives one column per time, so each method returns an array. The method also takes working_nodes and broken_nodes, and honours common-cause groups:

plant.capacity_distribution([500, 1000, 2000]).meets(100)   # array([0.957 , 0.7789, 0.3004])
plant.capacity_distribution(1000, broken_nodes=["p1"]).meets(100)  # -> 0.4882

In the long run (repairable systems)

For a RepairableRBD, capacity_distribution() gives the long-run distribution: the fraction of time the system spends at each level. delivered_fraction(demand) is then the fraction of the demand met over time, the production availability that plant owners contract on:

from repyability import RepairableRBD

E = surv.Exponential.from_params
unit = {"reliability": E([1 / 500]), "repairability": E([1 / 20])}  # MTTF 500, MTTR 20

pumps = RepairableRBD(
    [("in", "p1"), ("in", "p2"), ("in", "p3"),
     ("p1", "out"), ("p2", "out"), ("p3", "out")],
    {"p1": unit, "p2": unit, "p3": unit},
    capacity={"p1": 50, "p2": 50, "p3": 50},
)
long_run = pumps.capacity_distribution()
long_run.probabilities              # array([6.0000e-05, 4.2700e-03, 1.0668e-01, 8.8900e-01])
long_run.meets(100)                 # -> 0.99568   time with two pumps or more up
long_run.delivered_fraction(100)    # -> 0.99781   the production availability
long_run.mean                       # -> 144.23    the average capacity
pumps.mean_availability()           # -> 0.99994   the time with any output at all

It is exact, with no simulation, from each component's long-run availability, as mean_availability is. Components inspected or replaced on a calendar are down together more often than independent ones would be, and the distribution is averaged over their schedules, as mean_availability is.

Components with several levels

Some components degrade rather than fail outright: a pump at full, half or no output. There are three ways to give a node several levels, and binary nodes are the special case of each.

Levels while it works. A dict {level: probability} in place of a number gives the levels a node works at and the probability of each while it works. Its reliability model still says whether it works, so at a time t it is at each level with the level's probability times R(t), and down with F(t):

derated = NonRepairableRBD(
    [("in", "p1"), ("in", "p2"), ("p1", "out"), ("p2", "out")],
    {"p1": pump, "p2": pump},
    capacity={"p1": {50: 0.9, 25: 0.1}, "p2": {50: 0.9, 25: 0.1}},   # 10% of the time at half output
)
derated.capacity_distribution(1000).levels       # array([  0.,  25.,  50.,  75., 100.])
derated.capacity_distribution(1000).meets(100)   # -> 0.3994

A component that degrades over time. A DegradingNode runs through stages, each at its own capacity for a time from its own lifetime model, and has failed once the last ends: a model of the component's states over time. Its stages give its levels; its lifetime, the sum of its stages' times, gives its reliability, so it is a node model like any other:

from repyability import DegradingNode

E = surv.Exponential.from_params
worn = DegradingNode([
    (50, surv.Weibull.from_params([1500, 2])),   # full output until it wears
    (25, E([1 / 800])),                          # then half output until it fails
])
worn.stage_probabilities(1000)       # array([0.6412, 0.2379])   in each stage at 1000
worn.mean()                          # -> 2129.3   the stages' mean times, added

degrading = NonRepairableRBD(
    [("in", "p1"), ("in", "p2"), ("p1", "out"), ("p2", "out")],
    {"p1": worn, "p2": worn},
)
degrading.capacity_distribution(1000).meets(100)   # -> 0.4111   both still at full output
degrading.capacity_distribution(1000).meets(50)    # -> 0.9278
degrading.sf(1000)                                 # -> 0.9854   any output at all

The stage probabilities come from the same convolution of the stages' times as the reliability: exact for identical exponential stages, and otherwise accurate to about 1e-7.

A nested RBD with capacities. A nested RBD that has capacities of its own brings its whole distribution: a pump skid drawn as its own RBD, and used as one node, gives the same distribution as its pumps drawn in place.

A capacity given for a node in the outer RBD takes the place of what its model would give: the node is then a single component with that capacity.

In the long run, a RepairableRBD component with levels while it is up spends each level's share of its up time at that level. A degrading component (a DegradingNode as its reliability) spends its up time in its stages in proportion to their mean times, by the renewal-reward theorem; one on a maintenance or inspection schedule has no exact long-run values here. A nested RepairableRBD with capacities brings its own long-run distribution.

worn_unit = {"reliability": worn, "repairability": E([1 / 48])}   # MTTR 48
repaired = RepairableRBD(
    [("in", "p1"), ("in", "p2"), ("p1", "out"), ("p2", "out")],
    {"p1": worn_unit, "p2": worn_unit},
)
repaired.capacity_distribution().delivered_fraction(100)   # -> 0.7942
repaired.mean_availability()                               # -> 0.99951

Over time from new (exact)

A contract period starts with every unit new, and the long run is where a plant settles, not where it starts. point_capacity(x) gives the distribution of the capacity at each time x from new, and mission_capacity(t) the expected fraction of a window [0, t] spent at each level, so its delivered_fraction(demand) is the production availability of the window:

early = pumps.point_capacity([0.0, 10.0, 100.0, 1000.0])
early.meets(100)                     # array([1.    , 0.9993, 0.9957, 0.9957])
year = pumps.mission_capacity(8760)
year.delivered_fraction(100)         # -> 0.99782   the long run: 0.99781
pumps.mission_capacity([24.0, 168.0, 720.0]).delivered_fraction(100)   # array([0.9995, 0.9982, 0.9979])

Both are exact, with no simulation, and take state= to start from the components' current states rather than new: each component is up at t with its point availability (see Availability over time), and the system's distribution is the same computation at those as in the long run, so the probability of a capacity above 0 is point_availability(t), and the distribution settles at capacity_distribution(). The window's mean is integrated as mission_availability integrates the availability, and a window of decades costs no more than a few years.

A component with levels while it is up is at each with its share of its availability. A degrading component is in each stage with the probability its first unit is, by its age, or a unit put into service after a repair is, by its renewals (solved on the grid of its availability, to about 1e-7); a degrading pump that starts new spends its first years at full output more than in the long run:

early = repaired.point_capacity([1000.0, 5000.0, 50000.0])
early.meets(100)                     # array([0.5543, 0.3723, 0.3728])   both at full output
repaired.mission_capacity(8760).delivered_fraction(100)   # -> 0.8127   the long run: 0.7942

A nested RBD with capacities brings its own distribution over time. They take working_nodes and broken_nodes (a degrading component or nested RBD held working is at the levels it is up at, in proportion), and cover what point_availability covers; a degrading component on a maintenance or inspection schedule is refused, as in the long run.

Over a window (simulated)

The long run is where a plant settles; a contract year starts with every unit new. availability() simulates the window, and when nodes have capacities the same simulations follow what the system can deliver, against a demand:

year = pumps.availability(8760, mc_samples=500, seed=1, demand=100)   # a year, in hours
year.capacity[:3]                   # array([150. , 149.9, 149.8])   the mean capacity curve
year.mean_capacity                  # -> 144.2     the long run: 144.23
year.delivered_fraction             # -> 0.99766   the long run: 0.99781
year.delivered_fraction_interval().upper   # -> 0.99782
year.capacity_time[100.0] / (500 * 8760)   # -> 0.10732   the time at 100
  • capacity_timeline and capacity are the mean capacity over time, as timeline and availability are the mean availability. The capacity is worked out after every component failure and repair, not only those that change whether the system is up: here most failures cost 50 and leave the plant running.
  • capacity_time is the time spent at each capacity, summed over the simulations.
  • delivered holds each simulation's delivered fraction: what it delivered against the demand, min(capacity, demand) over the window, over the demand over the window. delivered_fraction is their mean, the production availability over the window, and delivered_fraction_interval(confidence) its simulation error.
  • demand defaults to the design capacity, everything up (150 here). Contracts usually name a smaller one.

The simulation draws only when components fail and are repaired, exactly as without capacities, so the availability results are unchanged. A node working at several levels counts at each in proportion to its probability. The capacity of each set of components down that the simulations meet is worked out once, and those a simulation meets first (a batch of simulations, compiled) together, when every node works at one level. A large system can meet many: 62 components in redundant pairs met 90 000 in 500 simulations of 1 000 hours, which took 16 seconds in Python and under 3 compiled. Nodes that take their capacity from their models (a DegradingNode's stages, or a nested RBD's capacities) are not followed: give them a capacity, or use the exact point_capacity and mission_capacity, or the long-run capacity_distribution().

From node probabilities

RBD.system_capacity takes the probability that each node works directly, as system_probability does, so a structure with no models can be analysed too:

from repyability import RBD

structure = RBD(
    [("in", "p1"), ("in", "p2"), ("p1", "pipe"), ("p2", "pipe"),
     ("pipe", "out")],
    capacity={"p1": 60, "p2": 40, "pipe": 80},
)
structure.system_capacity({"p1": 0.9, "p2": 0.8, "pipe": 1.0}).mean()  # -> 71.6

k-out-of-n nodes and demand

A k-out-of-n node passes flow only while at least k of its inputs are reached, as in the reliability analysis, and then carries the sum of what they bring. So three pumps voting 2-out-of-3 at the output deliver nothing with one pump running: the diagram says that the plant has failed.

voting = RBD(
    [("in", "p1"), ("in", "p2"), ("in", "p3"),
     ("p1", "out"), ("p2", "out"), ("p3", "out")],
    k={"out": 2},
    capacity={"p1": 50, "p2": 50, "p3": 50},
)
voting.system_capacity({"p1": 0.9, "p2": 0.9, "p3": 0.9}).levels  # array([  0., 100., 150.])

To say instead that one pump gives half the output, draw the pumps in plain parallel and ask what meets the demand: meets(100) of the plain parallel pumps is the 2-out-of-3 reliability, and delivered_fraction(100) counts the half output too.

How it is computed

The distribution is exact, over every combination of the components' states, assuming they are independent (apart from any common-cause groups). The system's capacity is its maximum flow, which by the max-flow min-cut theorem is the least total capacity of a cut: a set of nodes whose failure would disconnect the output. It is worked out as the system reliability is (see Saving, reproducibility and performance): each series, parallel and k-out-of-n module's distribution in closed form from its members' (the least of their capacities, their sum, their sum while at least k work: the universal generating function of multi-state systems), and what is left, such as a bridge, by conditioning on its parts' capacities one at a time, keeping only each cut's running total. So it costs about what sf does, and a series-parallel system of any size is quick.