Costs¶
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 7 (what it costs) and Lesson 8 (preventive maintenance).
Availability says how often a system is up; the next question is usually
what running it costs. Price the components, and the production lost while
the system is down, and a RepairableRBD gives
the long-run cost rate exactly and the distribution of the cost over a window
by simulation. Theory: Concepts.
Pricing a system¶
Costs are optional keys of a component's dict, plus one system-level rate:
| Where | Key | Charged |
|---|---|---|
| Component | repair_cost |
Per failure (labour, a callout). A number or a distribution. |
| Component | replace_cost |
Per failure (the spare part); for a component repaired imperfectly, only per replacement (see Imperfect repair). A number or a distribution. |
| Component | downtime_cost |
Per unit time this component is down, even if the system is up (a degraded-mode or per-leg penalty). A number. |
| Component | acquisition_cost |
Once, to buy the unit. A number. Not a running cost: see the total cost of ownership. |
| System | downtime_cost_rate= |
Per unit time the system is down (lost production). A number. |
import numpy as np
import surpyval as surv
from repyability import BetaFactor, CCFGroup, RepairableRBD
def unit(failure_rate, repair_rate, **costs):
return {
"reliability": surv.Exponential.from_params([failure_rate]),
"repairability": surv.Exponential.from_params([repair_rate]),
**costs,
}
edges = [("s", "A"), ("s", "B"), ("A", "C"), ("B", "C"), ("C", "t")]
plant = RepairableRBD(
edges,
{
"A": unit(0.1, 1.0, repair_cost=200.0),
"B": unit(0.1, 1.0, repair_cost=200.0),
"C": unit(0.02, 0.5, repair_cost=500.0, replace_cost=1500.0),
},
downtime_cost_rate=1000.0,
)
plant.has_costs # True
Every cost defaults to 0, so any subset can be priced. Costs are
undiscounted (but for the total cost's optional discount
rate), and every failure is repaired at the same price (preventive
replacement is below). A cost must be finite and
non-negative; an unknown key (such as repair_costs) raises ValueError
rather than being priced at zero.
The long-run cost rate (exact)¶
plant.expected_cost_rate() # -> 121.23 cost per unit time, in the long run
It is the sum of three exact terms:
cost rate = downtime_cost_rate · (1 − A_sys) lost production
+ Σ ω_i · (repair_cost_i + replace_cost_i) corrective actions
+ Σ (1 − A_i) · downtime_cost_i per-component downtime
where A is availability and ω_i = 1 / (MTTF_i + MTTR_i) is component i's
long-run failure frequency. Here that is 46.41 of lost production, 18.18
for each pump's repairs, and 38.46 for the valve's. A cost given as a
distribution enters through its mean. expected_cost_rate accepts
working_nodes/broken_nodes; a forced component never fails, so it incurs
no corrective cost:
plant.expected_cost_rate(working_nodes=["A"]) # -> 95.1
With nothing priced, has_costs is False and the rate is 0.0 without any
work being done.
The simulated cost distribution¶
The exact rate is a mean. Budgeting usually needs the spread: what could a
bad year cost? cost(t_simulation, ...) runs the availability simulation with
cost accumulation and returns a CostResult: one
total cost per simulated window.
costs = plant.cost(t_simulation=1000.0, mc_samples=500, seed=0)
costs.mean # the expected cost of a window: exact here
costs.cost_rate # mean / t_simulation: tends to expected_cost_rate()
costs.percentile(90) # a planning budget: 9 windows in 10 cost less
costs.std # how much a window's cost varies
costs.by_category # mean split: repair, replace, preventive, inspection, component_downtime, system_downtime, setup
costs.by_component # the part of mean each costed component accounts for
cost() takes the same arguments as availability() (working_nodes,
broken_nodes, method, mc_samples, verbose, seed, and tolerance,
antithetic, n_jobs and engine: see
Simulation precision and speed). The same result comes with
availability(...) as result.cost, so one simulation gives both answers.
With nothing priced, cost() returns None and result.cost is None.
One expected value¶
mean is the run's estimate of a window's expected cost, the one
mean_interval() gives an interval for, and cost_rate and the breakdowns
follow it. By default it is exact where the exact methods work it out, as
here (expected_cost, below), and otherwise taken given the histories of
the system's dependent modules, or the simulations' own (see
exact or simulated?). The
simulations' own mean, the average of samples, is sample_mean; the repr
shows both:
costs.mean_interval().method # 'exact'
costs.mean == plant.expected_cost(1000.0).mean # True
round(costs.mean, 1) # -> 121155.1
round(costs.sample_mean, 1) # -> 120634.4 the 500 windows' own
Two different uncertainties¶
stdandpercentiledescribe how much a window's cost varies. That is a property of the system; more simulations will not shrink it.mean_seandmean_interval(confidence)describe how precisely the expected cost has been estimated: exactly, here, with no error; for a mean that is simulated they shrink like1/√N. Check them before quoting the mean, or passtolerancetocost()to simulate until the interval is narrow enough. Withcontrol_variate=False(andconditional=False), the mean and its interval are the simulations' own:
own = plant.cost(t_simulation=1000.0, mc_samples=500, seed=0, control_variate=False)
interval = own.mean_interval(confidence=0.95)
interval.method # 'simulated'
round(interval.standard_error, 1) # -> 862.0
interval.lower < plant.expected_cost(1000.0).mean < interval.upper # True
by_category sums to mean; by_component covers each component's repair,
replace, preventive, inspection and own downtime cost (lost production is a
system cost, and a maintenance group's set-up cost the group's: neither is
attributed to a component). Under control_variate=True, whose twin
controls only the total, the breakdowns are the simulations' own, and sum
to sample_mean.
The expected cost of a window (exact)¶
The long-run rate times a window is the window's expected cost only once the
components have settled: from new, the first stretch costs less (or more).
expected_cost(t) gives the expected cost of [0, t) from new exactly (or,
with state=, from the components' current
states: a repair or maintenance
going on at 0 was charged when it began, before the window), by the same
categories as the simulation:
window = plant.expected_cost(1000.0)
window.mean # -> 121155 cost() above estimates 121703 ± 1666
window.by_category # repair 45983.07, replace 28848.37, system_downtime 46323.67, the rest 0
window.by_component # {'A': 18183.47, 'B': 18183.47, 'C': 38464.5}
window.cost_rate # -> 121.16 per unit time, against 121.23 in the long run
plant.expected_cost([10.0, 100.0]).cost_rate # array([113.45, 120.45])
Each category is its events' expected number over the window (see
Repairable systems)
times its mean cost: the repair and replace costs at each corrective action,
the preventive cost at each preventive replacement, the inspection cost at
each test, the downtime rates over the expected downtimes, and a maintenance
group's set-up once per stop (replacements due at one instant are one
stop). It returns an ExpectedCost, with
mean, by_category, by_component, acquisition_cost, total (the two
added) and cost_rate. It covers what the availability over time covers,
and refuses the rest with the reason; with nothing priced, every category is
0. For the spread of a window's cost, simulate it with cost().
Costs drawn from distributions¶
repair_cost and replace_cost can be a distribution of the cost instead of
a number, such as a surpyval model fitted to past invoices. The exact rate
uses its mean; the simulation draws a fresh cost at every failure:
invoices = surv.LogNormal.from_params([5.0, 0.5]) # mean ≈ 168
variable = RepairableRBD(
edges,
{
"A": unit(0.1, 1.0, repair_cost=invoices),
"B": unit(0.1, 1.0, repair_cost=invoices),
"C": unit(0.02, 0.5),
},
)
variable.expected_cost_rate() # -> 30.58 = 2 × 168.17 / 11
- The distribution must have a finite mean and no appreciable probability of a negative cost.
- The cost draws come from random streams of their own (seeded from the
run's seed), so pricing never changes the failure and repair histories: a
seeded
availability()gives the same availability with or without costs. - The downtime costs must be numbers: they are rates, and the outage durations already make them random.
Instantly repaired components¶
A component with "repairability": "instant" has no downtime but still
fails, so its per-failure costs are charged at its failure rate:
fuse = RepairableRBD(
[("s", "f"), ("f", "t")],
{"f": {"reliability": surv.Exponential.from_params([0.1]),
"repairability": "instant",
"replace_cost": 40.0}},
)
fuse.expected_cost_rate() # -> 4.0 = 40 × 0.1 failures per unit time
fuse.mean_availability() # -> 1.0
Preventive maintenance¶
A component's dict can also schedule preventive replacement, under the key
"preventive", so that the cost and availability outputs price the trade
between preventive and corrective maintenance:
| Key | Meaning |
|---|---|
interval |
Required: the replacement interval T (inf: never). |
policy |
"age" (the default): T after the unit was last put into service as new, so a failure restarts the clock. "block": at T, 2T, 3T, … whatever the unit's age, skipped while it is down. "condition": inspected at T, 2T, 3T, …, and replaced if likely to fail before the next inspection (see below). |
duration |
"instant" (the default): renewed in place, never down. Or a time-to-maintain model: the unit is down meanwhile, a planned outage. |
cost |
Charged at each preventive replacement: a number or a distribution. |
A replacement renews the unit, so the failure it was heading for never happens. A failure due at the same time as a replacement comes first.
NonRepairable.find_optimal_replacement() finds a component's best
replacement age on its own (Maintenance policies). At
system level the answer changes, because a planned stop of a unit with a
standby costs almost no production, while the same stop of a single point of
failure halts it. Take a pump that wears out, is repaired in about 23 h and
replaced preventively in about 7 h, alone or with a standby, with lost
production at 500 per hour:
def pump(interval, policy="age"):
return {
"reliability": surv.Weibull.from_params([1000, 2.5]), # MTTF 887 h
"repairability": surv.LogNormal.from_params([3.0, 0.5]), # 23 h
"replace_cost": 5000.0,
"preventive": {
"interval": interval,
"policy": policy,
"duration": surv.Weibull.from_params([8, 3]), # 7 h
"cost": 1000.0,
},
}
def alone(interval, policy="age"):
return RepairableRBD(
[("s", "p"), ("p", "t")],
{"p": pump(interval, policy)},
downtime_cost_rate=500.0,
)
def with_standby(interval, policy="age"):
return RepairableRBD(
[("s", "a"), ("s", "b"), ("a", "t"), ("b", "t")],
{"a": pump(interval, policy), "b": pump(interval, policy)},
downtime_cost_rate=500.0,
)
alone(float("inf")).expected_cost_rate() # -> 18.0 run to failure
alone(580).expected_cost_rate() # -> 13.14 the best interval
with_standby(float("inf")).expected_cost_rate() # -> 11.3
with_standby(500).expected_cost_rate() # -> 6.985 the best interval
| Interval (h) | 300 | 400 | 500 | 600 | 800 | never |
|---|---|---|---|---|---|---|
| Alone | 16.92 | 14.36 | 13.35 | 13.14 | 13.84 | 18.00 |
| With a standby | 8.19 | 7.21 | 6.99 | 7.15 | 8.01 | 11.30 |
Replacement pays in both, but not at the same interval: the lone pump's
planned stops cost production, so it is best replaced less often, at about
580 h; replacing a pump with a standby costs almost no production, so the
pair is best replaced at about 500 h. Judged on its own, as NonRepairable
judges it (cp=1000, cu=5000), the pump is best replaced at 493 h.
Choosing the intervals¶
optimal_replacement_intervals finds the best interval of every component
under age replacement at once, from the exact long-run values, rather than
sweeping them one at a time. The components are chosen together: the one
alone in the line is replaced later than the ones with a standby, because
its own replacements stop the plant:
alone(1000).optimal_replacement_intervals().intervals["p"] # -> 589.6
plan = with_standby(1000).optimal_replacement_intervals()
plan.intervals["a"] # -> 497.1 and the same for "b"
plan.cost_rate # -> 6.985
It can also keep the system's long-run availability to a target at the least
cost (min_availability), or give the most availability within a cost rate
(max_cost_rate):
plan = alone(1000).optimal_replacement_intervals(min_availability=0.9807)
plan.intervals["p"] # -> 604.8 a little later than the cheapest
plan.cost_rate # -> 13.139 against 13.134
It returns a MaintenancePlan: the intervals
(inf for a component better never replaced), and the system's cost rate
and availability with them. The search starts from the intervals given, and
from others around each component's mean life, and keeps the best; a target
no intervals can meet raises ValueError, with the best they can do. The
cost rate is usually flat near its minimum, so an interval some way from the
one found costs almost the same.
Replacements are often made on a calendar. allowed gives the intervals to
choose from (#230), one list for every component or a dict of a list each
(inf among them for never), and every combination is tried, as
optimal_inspection_intervals chooses tests':
weeks = [336.0, 672.0, 1008.0, 1344.0] # every 2, 4, 6 or 8 weeks
plan = with_standby(1000).optimal_replacement_intervals(allowed=weeks)
plan.intervals # {'a': 672.0, 'b': 672.0}
plan.cost_rate # -> 7.405 against 6.985 at 497 h
The plan is the best for the long run. A new plant's units all start new,
so the first replacements of redundant units fall due together, and where
they take the system down together its first years cost more than the
plan's rate: expected_cost gives the cost from new, and from units of
other ages (state) the cost of a staggered start.
With limited repair_crews a component can wait for a crew, and the exact
long-run values the search uses no longer hold, so the choice is refused.
assume_unlimited_crews=True (#184) chooses the intervals as if every repair
started at once, and with_intervals(plan) gives the diagram with them, to
simulate with its crews and see what the waiting costs:
crewed = RepairableRBD(
[("s", "a"), ("a", "b1"), ("a", "b2"), ("b1", "t"), ("b2", "t")],
{n: pump(1000) for n in ("a", "b1", "b2")},
downtime_cost_rate=500.0, repair_crews=1,
)
plan = crewed.optimal_replacement_intervals(assume_unlimited_crews=True)
planned = crewed.with_intervals(plan) # the same crew, the plan's intervals
planned.cost(100_000.0, mc_samples=100, seed=1).mean / 100_000.0 # ~> 20.6 simulated
plan.cost_rate # -> 20.12 with a crew for every job
with_intervals takes a plan or a dict of intervals (and of test offsets),
and builds the copy as the diagram was built; compare() then sets the plan
against the schedules the diagram has.
expected_cost_rate prices an age-replaced component through its renewal
cycle: it ends at a failure or a preventive replacement, whichever comes
first, with mean length C = ∫₀ᵀ R + F(T)·MTTR + R(T)·MTTP (MTTP the mean
maintenance time), so it fails F(T) / C times and is replaced R(T) / C
times per unit time, and is up ∫₀ᵀ R / C of the time:
cost rate = … + Σ R_i(T_i) / C_i · preventive cost_i preventive actions
With instant repair and maintenance a component's own cost rate is
NonRepairable.cost_rate(T). mean_availability, node_availability, the
frequencies, MUT, MDT and the importance measures account for it the same
way.
Under block replacement the renewals are the block times at which the unit is
up: it is replaced there, and a replacement due while it is down is skipped.
Between two of them it fails and is repaired as usual, and a repair can run
over a block time. expected_cost_rate and the other long-run methods compute
its mean up time, failures and length between renewals numerically, from the
renewal equations of its lives and repairs within an interval, to about one
part in a million. With instant repair and replacement the cost rate is
(c_p + c_u · M(T)) / T, M the renewal function of the lives. Components
replaced at the same block times go down together, so the system's long-run
values average over the block interval (over the time the schedules take to
repeat together, for different intervals), rather than combining each
component's own average:
alone(580, "block").expected_cost_rate() # -> 14.06 against 13.14 for age
with_standby(580, "block").mean_availability() # -> 0.9901 both replaced at once
with_standby(580).mean_availability() # -> 0.9996
The exact block-replacement values need a surpyval parametric lifetime with a
density (no units dead on arrival), and repairs that always end; otherwise
they raise NotImplementedError, and the simulation still applies.
The simulation prices both policies. A replacement's cost is in
by_category["preventive"] (the expected cost, exact here, as the cost's
mean is), and a planned outage counts as downtime, but not as a failure:
system_planned_outages counts the times one took the system down in the
simulations.
year = alone(580).availability(t_simulation=8760.0, mc_samples=500, seed=0)
year.system_failures / year.n_simulations # -> 3.466
year.system_planned_outages / year.n_simulations # -> 11.95
year.cost.by_category["preventive"] # -> 11886.8 1000 each, expected
Replacement on condition¶
"policy": "condition" inspects the unit at every multiple of interval,
while it is up, and replaces it only if it is then more likely than
"threshold" to fail before the next inspection, given its age a:
1 − R(a + T) / R(a), the conditional survival that NodeState and
sf_given_state use. A unit renewed a while ago is left alone, and a worn
one is replaced at the last inspection before it is likely to fail.
| Key | Meaning |
|---|---|
threshold |
Required with "condition": the probability, in [0, 1], above which an inspection replaces the unit. |
inspection_cost |
Charged at each inspection: a number or a distribution. |
duration and cost are the replacement's, as under the other policies.
An inspection takes no time, and one due while the unit is down is skipped.
Inspected weekly and replaced when more than 20% likely to fail before the
next inspection, the lone pump above costs about what the best age
replacement does:
weekly = {"interval": 168.0, "policy": "condition", "threshold": 0.2,
"duration": surv.Weibull.from_params([8, 3]), "cost": 1000.0,
"inspection_cost": 20.0}
inspected = RepairableRBD([("s", "p"), ("p", "t")],
{"p": dict(pump(580), preventive=weekly)},
downtime_cost_rate=500.0)
inspected.expected_cost_rate() # -> 13.37 per hour
run = inspected.cost(200_000.0, mc_samples=20, seed=1)
run.cost_rate # -> 13.36 exact over the window
run.sample_mean / 200_000.0 # -> 13.27 the 20 simulations', ± 0.2
| Threshold | 0.05 | 0.1 | 0.15 | 0.2 | 0.25 | 0.3 |
|---|---|---|---|---|---|---|
| Cost per hour | 22.10 | 15.98 | 13.47 | 13.37 | 14.02 | 14.34 |
Judged on its age alone, a unit replaced on condition is replaced much as under age replacement (13.14 at the best age), but on the inspection calendar. A low threshold replaces it too young; a high one lets it fail.
- A threshold of 0 replaces the unit at every inspection: block replacement at the interval. A threshold of 1 never does: run to failure, with inspections.
- A constant failure rate gives the same probability at every age,
1 − exp(−λT): the unit is replaced at every inspection or at none, and it fails as often either way. - Numerical in the long run (#145). The inspections that replace the
unit are regeneration points, and a cycle between two is followed one
inspection interval at a time: the units put into service in an interval
as an alternating renewal process, as under block replacement, and the
units an inspection keeps on into the next interval by their ages. So the
long-run values, the cost rate (the inspections charged at the unit's
availability just before them) and the importance measures are
numerical, to about
1e-7. A cycle in which the unit fails many times before an inspection replaces it is summed to its end as a geometric series once it falls at a steady rate. - Numerical over time (#161). The same recursion, followed from new over all the intervals rather than over one cycle, gives the availability at any time and the expected events and cost of any window. Each interval starts with what the inspection at its start replaced, the repairs carried into it and the units it kept, by age, and after some intervals it repeats from one to the next: its long-run cycle. From a state, the unit in service at the start is decided on at its own age at each inspection, however that falls on the grid.
A year of the weekly-inspected pump from new, and the next 30 days of one already 600 hours old, 100 hours after its last inspection:
from repyability import NodeState
inspected.expected_cost(8760.0).total # -> 114491 new: less than a year at the rate, 117139
worn = {"p": NodeState(age=600.0, phase=100.0)}
inspected.expected_cost(720.0, state=worn).total # -> 10265 against 8517 new
Opportunistic maintenance¶
Plants group their work: once a unit is down, for a failure or its planned
replacement, the crew services its neighbours too, as the line is stopped
anyway. Components that share such stops form a maintenance group, named
by their "group" key. Each failure of a member, and each scheduled
replacement, opens a stop of its group, at which every other member that
is working and at least its "opportunity" age is replaced as well, as
its own scheduled replacement would replace it:
| Key | Meaning |
|---|---|
"group" (component) |
The component's maintenance group: any hashable name. |
opportunity ("preventive", age policy) |
The age, from 0 to interval, from which the unit is replaced early at a stop of its group. Left out, it never is. |
setup_cost (maintenance_groups) |
Charged once per stop, however many members it renews. |
system_down (maintenance_groups) |
True: every outage of the system is a stop of the group as well. |
A compressor and its motor in series wear out, and each stop of the train costs 3,000 to set up (isolation, permits, scaffolding) besides the units' own costs. Replaced separately at 600 h, each failure or replacement is a stop of its own; from age 300 h, a unit is replaced at the other's stop:
def unit(scale, opportunity=None):
preventive = {"interval": 600.0,
"duration": surv.Weibull.from_params([8, 3]), # 7 h
"cost": 1000.0}
if opportunity is not None:
preventive["opportunity"] = opportunity
return {
"reliability": surv.Weibull.from_params([scale, 2.5]),
"repairability": surv.LogNormal.from_params([3.0, 0.5]), # 23 h
"replace_cost": 5000.0,
"preventive": preventive,
"group": "train",
}
def train(opportunity=None):
return RepairableRBD(
[("s", "compressor"), ("compressor", "motor"), ("motor", "t")],
{"compressor": unit(1000, opportunity),
"motor": unit(1500, opportunity)},
downtime_cost_rate=500.0,
maintenance_groups={"train": {"setup_cost": 3000.0}},
)
train().expected_cost_rate() # -> 33.00 separate stops, exact
run = train(300).availability(200_000.0, mc_samples=20, seed=1)
run.cost.cost_rate # -> 23.48 grouped, simulated
run.opportunistic_renewals # -> {'compressor': 3337, 'motor': 3502}
| Opportunity age (h) | none | 500 | 400 | 300 | 200 | 100 |
|---|---|---|---|---|---|---|
| Cost per hour (± 0.15) | 33.00 | 29.99 | 26.82 | 23.48 | 23.20 | 23.18 |
Grouping pays twice: the stops, and their set-ups, are fewer (5.64 an hour of set-ups against 10.33), and the units' outages overlap, so the train is down less (12.0 an hour of lost production against 16.7).
- A stop is an instant. Work started at one instant shares one set-up, and a member whose own failure or replacement falls due then keeps it, as part of the stop: two units on the same schedule are replaced together, on schedule, at one set-up.
- Durations overlap. Each member renewed at a stop takes its own maintenance time, from the stop, so the set-up time each one's maintenance includes is spent once in a series train.
- Results.
opportunistic_renewalscounts each component's early renewals, summed over the simulations; the set-ups are underby_category["setup"], and each early renewal is charged the member's preventive cost and uses a spare. - Exact or simulated. A component that can be renewed early depends on
the others, so the exact long-run values, the availability over time and
the spares counts refuse it;
availability,costandcomparesimulate it, in Python. With noopportunitybelow an interval, the set-up cost is exact:expected_cost_ratecharges a set-up at each failure and each preventive replacement of a member (soopportunityequal to the interval is plain age replacement, plus set-ups). It refuses two members replaced on a clock (block replacement, or never failing before an instant age replacement), whose replacements share stops. - Members cannot have hidden failures or be standby groups.
Hidden failures and inspection¶
Some failures announce themselves: a running pump stops, and the operators
know. Others do not: a relief valve that has seized, a standby pump that will
not start or a trip that no longer trips looks just like a working one until
something tests it. Such a failure is hidden (or unrevealed): the
component is down, nobody knows, and it stays down until a periodic
inspection (a proof test) finds it. The key "inspection" in a
component's dict makes its failures hidden:
| Key | Meaning |
|---|---|
interval |
Required: the time τ between inspections, positive and finite. The component is inspected at τ, 2τ, 3τ, …, or from its offset. |
duration |
"instant" (the default): the test takes no time. Or a time-to-test model: the component is off-line while it is tested (a planned outage), and does not age meanwhile. |
cost |
Charged at each inspection: a number or a distribution. |
offset |
The time of the first test, from 0 to less than τ: tests at offset, offset + τ, …, so that redundant components can be tested apart (staggered). By default 0, no offset: the first test at τ, none at the start. A positive offset puts one there, so an offset just above 0 adds a test (and its outage and cost) at the start; one within a billionth of τ of 0 is taken as 0. |
coverage |
The chance that a test finds a failure, from 0 to 1 (by default 1): its proof-test coverage. A failure a test misses stays hidden until a full test. |
full_test |
With a coverage below 1, required: the time between full tests, which find every failure, a whole multiple of τ (the tests at offset and every full_test after it). Often the mission time, after which the component is renewed. |
A failure found by an inspection is repaired once the test is done (taking a
time drawn from the component's "repairability"), and its repair and
replace costs are charged when it is found. An inspection due while the
component is being repaired is skipped. The time a failure lies hidden counts
as downtime in every output, and each inspection's cost is in
by_category["inspection"]. A component can have an inspection or a
preventive schedule, but not both.
The average probability of failure on demand¶
A protective function whose failure is hidden does not act when it is
needed, so its long-run unavailability is its average probability of
failure on demand, the PFDavg that safety-integrity (SIL) verification asks
for (IEC 61508 and 61511). With a constant rate λ of such failures,
inspected every τ, with instant tests and repairs, a single channel is
down, after a failure, until the next test: half an interval on average, so
PFDavg = 1 − (1 − e^(−λτ)) / (λτ) ≈ λτ/2. Take a shutdown valve with
dangerous undetected failures at 2 × 10⁻⁶ per hour, proof-tested yearly:
def valve(interval):
return {
"reliability": surv.Exponential.from_params([2e-6]), # dangerous, undetected
"repairability": "instant",
"inspection": {"interval": interval},
}
one = RepairableRBD([("s", "v"), ("v", "t")], {"v": valve(8760.0)})
one.mean_unavailability() # -> 0.008709 about λτ/2 = 0.00876
pair = RepairableRBD(
[("s", "v1"), ("s", "v2"), ("v1", "t"), ("v2", "t")],
{"v1": valve(8760.0), "v2": valve(8760.0)},
)
pair.mean_unavailability() # -> 1.0098e-4 about (λτ)²/3
Two valves in parallel (1oo2), tested together, are down only when both
have failed since the last test: PFDavg = (1/τ) ∫₀^τ (1 − e^(−λt))² dt ≈
(λτ)²/3. That is a third more than the (λτ/2)² of two channels that fail
independently in time, because both have gone untested for the same time.
The diagram supplies the voting, so 2oo3 and larger architectures follow in
the same way (see k-out-of-n nodes). Halving
the interval halves a single channel's PFDavg but quarters the pair's:
| Test interval | 6 months | 1 year | 2 years |
|---|---|---|---|
| One valve | 0.00437 | 0.00871 | 0.0173 |
| Two valves (1oo2) | 2.54 × 10⁻⁵ | 1.01 × 10⁻⁴ | 3.99 × 10⁻⁴ |
These long-run values are exact. Components inspected at the same times go
down together, so mean_availability and the other long-run methods average
the system's availability over one period of the inspection schedules (the
least common multiple of the intervals: components on different intervals
can be mixed), and the importance measures are ratios of those averages.
With instant tests and repairs they are in closed form for a constant
failure rate, and numerical for any other life (see a life that wears
out); tests and repairs that take time are
numerical too (see tests and repairs that take
time). Testing both valves at once, for
example, takes the whole function off-line during the test, which here
costs far more than the hidden failures:
def tested(interval, offset=0.0):
return {
"reliability": surv.Exponential.from_params([2e-6]),
"repairability": surv.LogNormal.from_params([np.log(24), 0.5]), # about a day
"inspection": {
"interval": interval,
"duration": surv.Weibull.from_params([4, 3]), # about 3.6 h
"offset": offset,
},
}
pair = [("s", "v1"), ("s", "v2"), ("v1", "t"), ("v2", "t")]
both = RepairableRBD(pair, {"v1": tested(8760.0), "v2": tested(8760.0)})
both.mean_unavailability() # -> 4.28e-4
1 - both.mission_availability(10 * 8760.0) # -> 3.95e-4 its first ten years
both.expected_events(10 * 8760.0).system_planned_outages # -> 9.0 one per test
apart = RepairableRBD(pair, {"v1": tested(8760.0), "v2": tested(8760.0, 4380.0)})
apart.mean_unavailability() # -> 7.12e-5
Tested half a year apart, a test takes one valve off-line while the other holds the function, and the PFDavg falls six-fold.
A life that wears out¶
The closed forms above need a constant failure rate. With any other life,
and instant tests and repairs, the values are numerical, exact to rounding.
A failed unit is found and renewed at the next test, so every renewal falls
on a test, and a unit renewed m intervals before a test is still up with
probability R(mτ). In the long run a cycle lasts S = 1 + Σ R(mτ)
intervals (over m ≥ 1), and the unit is up a time u after a test with
probability (R(u) + Σ R(mτ + u)) / S. From new, the chance of a renewal
at each test follows a renewal equation over the tests, and settles to
1/S. A valve that wears out, with a mean life of about 15 years:
worn = surv.Weibull.from_params([150_000.0, 2.5]) # wears out; MTTF 133,090 h
wearing = RepairableRBD(
[("s", "v"), ("v", "t")],
{"v": {
"reliability": worn,
"repairability": "instant",
"inspection": {"interval": 8760.0},
}},
)
wearing.mean_unavailability() # -> 0.03186 about τ / (2 MTTF) = 0.0329
1 - wearing.mission_availability(87600.0) # -> 0.01126 its first ten years
years = np.array([1, 5, 10, 20]) * 8760.0 - 1.0 # just before a test
1 - wearing.point_availability(years)
# -> [0.00082 0.01908 0.04983 0.06844]
For a short interval the long-run PFDavg is about τ / (2 MTTF), whatever
the life's shape. What the wear changes is the PFD over time. A new valve
rarely fails in its first years, so its first ten years' PFD is a third of
the long run's, and the PFD just before each test climbs with its age
before it settles. The values over a window, from new or from the valve's
current state (its age, and the time since its last test), are exact too:
expected_events, expected_cost and the other analyses over
time.
The sums run over the intervals a unit can last. Within an interval, every unit but the one renewed at the last test ages smoothly, so their sum is worked out once, at Chebyshev points across the interval, and interpolated between them, to rounding. A term that bends there (a life with a threshold, say) is summed directly instead. A life that lasts more than 200,000 intervals (the most summed) refuses.
Tests and repairs that take time¶
A test of a working unit takes it off-line (a planned outage) for the test's time, during which it does not age; a failure found by a test is repaired once the test is over, and the tests that fall in the repair are not done. Then renewals no longer fall on the tests, and a test that can miss a failure (see test coverage) leaves the failure for a later one. The values are still numerical, for any life (#159). The tests that find failures are the unit's regeneration points: from one, a new unit is put into service the test's time plus the repair's later, and its age at its later tests is a random walk (each test holds it back by the test's time), followed test by test on a grid of 2,000 steps or more an interval. The long run follows by renewal-reward over that cycle (over the cycles from each place in the full tests' period, with tests that miss), and from new, or from a state, the tests that find failures are a renewal process on the tests. When a test is over, and when a unit is back in service, are kept out of the grid, exact at any time; the rest is exact to the square of the grid's step, about 1e-8. An exponential life needs no ages: its chain of states from test to test (working, failed, missed, in a repair) is followed instead.
def valve(coverage=1.0):
inspection = {
"interval": 8760.0,
"duration": surv.ExactEventTime.from_params([8.0]), # 8 h off-line
}
if coverage < 1.0:
inspection.update(coverage=coverage, full_test=5 * 8760.0)
return RepairableRBD(
[("s", "v"), ("v", "t")],
{"v": {
"reliability": surv.Weibull.from_params([150_000.0, 2.5]),
"repairability": surv.ExactEventTime.from_params([24.0]), # a day
"inspection": inspection,
}},
)
valve().mean_unavailability() # -> 0.03289 0.03186 with tests and repairs in no time
valve(0.7).mean_unavailability() # -> 0.06845 70% coverage, a full test every 5 years
valve(0.7).expected_events(10 * 8760.0).system_planned_outages # -> 8.750
spares_demand and spares_stock count such a valve's spares on the
tests that find its failures (see how spares are
counted). Only a test that can last as long
as its interval stays simulated. A common-cause group's members' tests and
repairs may take time too, of a fixed length or an exponential one (see
below).
Common cause, staggered tests and test coverage¶
Three more terms decide a real safety function's PFDavg, and each has its place in the diagram:
- Common cause. Redundant valves of one design, in one service, fail
together more often than chance allows. Give the diagram
ccf_groups, as for a non-repairable one: aBetaFactor(β)makes a shareβof each valve's failures a shared cause that fails both at once. For a redundant function the shared term usually dominates: aboutβλτ/2, five times the pair's independent(λτ)²/3here. - Staggered tests. An
"offset"tests one valve half an interval after the other: a shared failure is then found by whichever test comes first, and the independent term falls from(λτ)²/3to about5(λτ)²/24. Each valve is restored by its own test alone: the first test restores its valve, and with it the pair, and the other stays down until its own test (where practice has the first test find and restore both, the value is a little less, the pair then not left on one valve until the second test). - Test coverage. A proof test that finds only a share
cof the failures (a"coverage") leaves the rest hidden until a full test ("full_test"), say the ten-year overhaul: about(1 − c)λT/2more,Tthe full test's interval.
def proof_tested(**inspection):
return {
"reliability": surv.Exponential.from_params([2e-6]),
"repairability": "instant",
"inspection": {"interval": 8760.0, **inspection},
}
redundant_edges = [("s", "v1"), ("s", "v2"), ("v1", "t"), ("v2", "t")]
common = [CCFGroup(["v1", "v2"], BetaFactor(0.05))]
shared = RepairableRBD(
redundant_edges,
{"v1": proof_tested(), "v2": proof_tested()},
ccf_groups=common,
)
shared.mean_unavailability() # -> 5.290e-4 (λτ)²/3 + βλτ/2, about
staggered = RepairableRBD(
redundant_edges,
{"v1": proof_tested(), "v2": proof_tested(offset=4380.0)},
ccf_groups=common,
)
staggered.mean_unavailability() # -> 2.779e-4 the shared term halved
partial = RepairableRBD(
[("s", "v"), ("v", "t")],
{"v": proof_tested(coverage=0.9, full_test=87600.0)},
)
partial.mean_unavailability() # -> 0.01642 0.9λτ/2 + 0.1λT/2, about
All three are exact with constant failure rates and instant tests and repairs, and staggered tests and test coverage are numerical with any life too, and with tests and repairs that take time (see tests and repairs that take time). With common causes, a group's members are a Markov chain of which of them are down, with each member found by its own tests, and a shared failure found alike by every test (the coverage is the group's), each member it struck restored by its own test, as in the simulations (#237). Their tests and repairs may take no time, a fixed time or an exponential one (#220): a working member is off line while it is tested, and neither ages nor fails meanwhile, a failure a test finds is repaired once the test is over, and a test that falls in a member's own test or repair is not done, as in the simulations. IEC 61508-6's 1oo2 with an eight-hour mean repair time:
repaired = {**proof_tested(), "repairability": surv.ExactEventTime.from_params([8.0])}
mrt = RepairableRBD(
redundant_edges, {"v1": repaired, "v2": repaired}, ccf_groups=common
)
mrt.mean_unavailability() # -> 5.300e-4 IEC 61508-6: 5.316e-4
A test of an exponential length followed by a repair of a fixed one, or
fixed lengths that run into the member's next test, are refused, saying
so: estimate those by simulation.
The importance measures take the groups in, a member's conditioned on its
state at each time, and so do the allocations, the values over time from
new (the chain followed through the tests from every member up) and the
simulations, which draw the shared causes; see repairable
systems and analysis_routes().
Choosing the interval¶
Each inspection costs c_i, and each unit of time the component lies failed
costs c_d (its "downtime_cost", or the system's downtime_cost_rate).
Frequent tests cost more, and rare ones leave failures hidden for longer, so
the cost rate c_i/τ + c_d·U(τ) is least in between, near
τ* = √(2c_i / (λc_d)). expected_cost_rate prices it exactly:
def pump(interval):
return RepairableRBD(
[("s", "p"), ("p", "t")],
{"p": {
"reliability": surv.Exponential.from_params([1e-4]),
"repairability": "instant",
"downtime_cost": 500.0, # per hour failed
"inspection": {"interval": interval, "cost": 2000.0},
}},
)
pump(100.0).expected_cost_rate() # -> 22.49 testing too often
pump(283.0).expected_cost_rate() # -> 14.08 near √(2 × 2000 / (1e-4 × 500)) = 283
pump(1000.0).expected_cost_rate() # -> 26.19 failures hidden too long
optimal_inspection_intervals finds it, and with a target, the cheapest
intervals that meet it. Tests are made on a calendar, and components tested
at the same times are down together, so with several components the
intervals are chosen from those allowed (allowed): every combination when
there are at most 2000, a local search otherwise. With one inspected
component, any interval can be chosen:
pump(100.0).optimal_inspection_intervals().intervals["p"] # -> 285.5 the formula gives 283
For a safety function, min_availability=1 - PFDavg target gives the
cheapest tests that meet the target. With the valves above, each test costing
500, and tests monthly, quarterly, half-yearly, yearly or every two years:
def priced_valve():
return {
"reliability": surv.Exponential.from_params([2e-6]),
"repairability": "instant",
"inspection": {"interval": 8760.0, "cost": 500.0},
}
calendar = [730.0, 2190.0, 4380.0, 8760.0, 17520.0] # hours
single = RepairableRBD([("s", "v"), ("v", "t")], {"v": priced_valve()})
single.optimal_inspection_intervals(
allowed=calendar, min_availability=1 - 1e-3
).intervals["v"] # -> 730.0 monthly
redundant = RepairableRBD(
[("s", "v1"), ("s", "v2"), ("v1", "t"), ("v2", "t")],
{"v1": priced_valve(), "v2": priced_valve()},
)
redundant.optimal_inspection_intervals(
allowed=calendar, min_availability=1 - 1e-3
).intervals["v1"] # -> 17520.0 every two years
Redundancy relaxes the tests: one valve needs them monthly to keep the
PFDavg at most 10⁻³, two in parallel every two years. The result is a
MaintenancePlan, as for
age-replacement intervals.
When the valves are tested matters as well. Tested together, both are down
for as long as a common-cause failure stays hidden; tested half an interval
apart, it is found twice as soon (and the valve it found restored, the
other waiting for its own test). offsets chooses the times of the first
tests with the intervals (#184), as shares of each interval in [0, 1): a
list for every component, a dict of one per component, or "stagger", the
shares 0, 1/n, …, (n − 1)/n of n components, among which are tests of
one interval spread evenly over it. With a 10% common cause and a PFDavg of
at most 5 × 10⁻⁴:
paired = RepairableRBD(
[("s", "v1"), ("s", "v2"), ("v1", "t"), ("v2", "t")],
{"v1": priced_valve(), "v2": priced_valve()},
ccf_groups=[CCFGroup(["v1", "v2"], BetaFactor(0.1))],
)
together = paired.optimal_inspection_intervals(
allowed=calendar, min_availability=1 - 5e-4)
together.intervals # {'v1': 4380.0, 'v2': 8760.0}
together.cost_rate # -> 0.1712
apart = paired.optimal_inspection_intervals(
allowed=calendar, min_availability=1 - 5e-4, offset_shares="stagger")
apart.intervals # {'v1': 8760.0, 'v2': 8760.0}
apart.offsets # {'v1': 0.0, 'v2': 4380.0} six months apart
apart.cost_rate # -> 0.1142 a third less
Shifting every test by one time changes nothing in the long run, so the
first component's tests stay from 0, unless other tested components keep
their schedules (then its offset is chosen too). Offsets change no cost, so
of plans that cost the same the most available is chosen. The
offset_shares searched are shares of the interval; the result's
offsets are the times of the first tests, as with_intervals(offsets=)
takes them, and a plan has them whether they were searched or not.
The total cost of ownership¶
The costs so far are running costs. Buying the system is a one-off cost:
give each component an "acquisition_cost", and total_cost(horizon) adds
the purchase to the running cost over the time the system is owned,
total_cost(H) = acquisition_cost + expected_cost_rate() · H
(undiscounted by default: see Discounting). The
acquisition cost is not a running cost, so it is left out of has_costs,
expected_cost_rate and the simulated samples; a CostResult reports it
beside them, as acquisition_cost.
pump = {
"reliability": surv.Exponential.from_params([1e-3]), # MTTF 1000 h
"repairability": surv.Exponential.from_params([0.1]), # MTTR 10 h
"repair_cost": 500.0,
"acquisition_cost": 20000.0,
}
line = RepairableRBD([("s", "pump"), ("pump", "t")], {"pump": pump},
downtime_cost_rate=100.0)
line.acquisition_cost # -> 20000.0
line.expected_cost_rate() # -> 1.4851 (500 + 100 × 10) / 1010 per hour
line.total_cost(87600.0) # -> 150099.0 ten years
total_cost runs the system at its long-run rate from the start. The
exact expected cost of owning it from new is expected_cost(H).total: the
pump is new at the start, so it is down a little less early on than in the
long run.
line.expected_cost(87600.0).total # -> 150089
Discounting¶
Money spent in ten years is worth less than money spent now. discount_rate
gives total_cost (and allocate_redundancy) the present value (#184): the
components are bought at the start, and the running costs, spent at a steady
rate, are discounted continuously at r per unit time, so the horizon counts
as (1 − e^(−r·H)) / r. A rate is per unit time of the models: 7% a year,
with models in hours, is math.log(1.07) / 8760.
import math
seven = math.log(1.07) / 8760
line.total_cost(87600.0, discount_rate=seven) # -> 114538 ten years count as 63,656 hours
line.total_cost(math.inf, discount_rate=seven) # -> 212287 owned for ever: 1 / r hours
line.total_cost([8760.0, 87600.0], discount_rate=seven) # one total for each horizon
A rate per year given with the models in hours (0.07) discounts every cost
after the first few hours away: a warning says so, and how to convert it
(#231).
total_cost spends the long-run cost rate from the start. The exact present
value from new discounts each cost when it falls, which matters most in the
early years, where the two differ: expected_cost takes a discount_rate
too (#231), and is then a present value, acquisition_cost (paid at the
start) beside it:
line.expected_cost(87600.0, discount_rate=seven).total # -> 114528 from new
Discounting favours what is cheaper to buy and dearer to run, as the running costs it saves come later: it can change which design wins (see the fourth pump train below, which no longer pays at 15% a year). The cost rates and the simulated costs stay undiscounted.
Buying redundancy¶
A redundant copy is bought once and then runs: it fails, is repaired and
costs money for as long as it is owned, while it saves the lost production
of the outages it covers. allocate_redundancy(horizon) chooses how many
identical, independently repaired, active copies of each component give the
lowest total cost of ownership:
best = line.allocate_redundancy(87600.0)
best.units # {'pump': 2}
best.total_cost # -> 127591.4 a second pump saves 22,508 over ten years
best.acquisition_cost # -> 40000.0
best.availability # -> 0.999902
line.allocate_redundancy(8760.0).units # {'pump': 1} over one year it does not pay
The second pump costs 20000 + 0.495·H (its price and its repairs) and saves
100 · (U − U²) · H = 0.980·H of lost production, with U = 10/1010 the
fraction of the time one pump is down; it pays once H exceeds about 41,200
hours. A third would save at most 100 · U² · (1 − U) = 0.0097 per hour,
less than the 0.495 per hour its repairs cost, so it never pays, whatever it
costs to buy.
The result is a TotalCostAllocation:
units, total_cost, acquisition_cost, cost_rate (the running cost
per unit time) and availability of the design, the horizon and the
method. Each design is scored exactly: n copies of a component of
long-run availability A are all down (1 − A)ⁿ of the time, each copy has
the running cost of the original, and the system's availability comes from
the exact engine, so the result is what total_cost, expected_cost_rate
and mean_availability give for the system with the copies drawn out as
separate nodes. The arguments:
| Argument | Meaning |
|---|---|
nodes |
The components that may be given copies; by default every one with an "acquisition_cost" (none, given trains). The others are counted once. |
trains |
Chains of components that may be given copies as a whole, {name: [nodes]}: see Whole trains. |
min_availability |
Only designs at least this available, in (0, 1): e.g. a contractual availability, met at the lowest total cost. |
max_units |
The most copies of every node (an int) or of some (a dict). A node whose copies cost nothing needs one. |
method |
"exact" (the default): a proven optimum. "greedy": adds or removes one copy at a time while that lowers the total; fast, not guaranteed optimal. |
line.allocate_redundancy(87600.0, min_availability=0.99999).units # {'pump': 3}
Unlike the reliability of a non-repairable design, the total cost is not
monotone in the copies: each copy costs as much as the one before and saves
less. The exact search is bounded by two facts. The k+1-th copy of a
component down a fraction U of the time can save at most
H · downtime_cost_rate · Uᵏ · (1 − U) (all it could ever save, were
everything else perfect), so no copy beyond the point where that falls below
a copy's cost can pay (without a min_availability, which may need copies
that do not pay). And a design no worse than the best found spends no more
on copies than that design's total. When every node considered lies on every
path (in series with the rest of the system) and has no hidden failures, the
system's availability is theirs times the rest's, and a dynamic program over
the nodes finds the optimum for any number of them; on other structures a
branch and bound over the designs does, which suits a handful of nodes.
The model and its limits: copies are active and repaired independently of
each other (as many repair crews as failed copies), and fail independently
unless the component is in a common-cause group: then its copies join the
group, a BetaFactor group's shared cause failing them all (see repairable
systems; an MGL group's member is
refused). Copies of a component with hidden failures are inspected together. A nested
RepairableRBD cannot be given copies. Copies of a component under block
replacement are replaced together, at the same block times. Costs are not
discounted unless discount_rate is given (see Discounting).
For non-repairable systems, redundancy allocation within a budget or to a
reliability target is in
Design and allocation.
Whole trains¶
Redundancy often comes a train at a time: a station that needs two of its
three pump trains, each a pump and then its motor, asks whether a fourth
train pays. trains={name: [nodes]} names chains of components that may be
given copies as a whole (#184). A copy of a train is another path alongside
it, fed as its first node is and feeding the node its last one feeds, which
keeps its k: copies of a train of a 2-out-of-3 vote make it 2-out-of-4.
motor = {
"reliability": surv.Exponential.from_params([2e-4]), # MTTF 5000 h
"repairability": surv.Exponential.from_params([0.05]), # MTTR 20 h
"acquisition_cost": 10000.0,
}
trains = (1, 2, 3)
station = RepairableRBD(
[("s", f"pump {i}") for i in trains]
+ [(f"pump {i}", f"motor {i}") for i in trains]
+ [(f"motor {i}", "t") for i in trains],
{**{f"pump {i}": pump for i in trains}, **{f"motor {i}": motor for i in trains}},
k={"t": 2}, downtime_cost_rate=2000.0,
)
station.total_cost(87600.0) # -> 319927 three trains
fourth = station.allocate_redundancy(87600.0, trains={"train 1": ["pump 1", "motor 1"]})
fourth.units # {'train 1': 2} train 1 and a copy: four trains
fourth.total_cost # -> 295306
station.allocate_redundancy(87600.0, nodes=["pump 1", "motor 1"]).total_cost # -> 311130
fifteen = math.log(1.15) / 8760 # 15% a year
station.allocate_redundancy(87600.0, trains={"train 1": ["pump 1", "motor 1"]},
discount_rate=fifteen).units # {'train 1': 1}
Copies of train 1's own nodes, each in parallel with its own, stand in for
train 1 alone, where a fourth train stands in for whichever train is down:
the fourth train is the cheaper design. Discounted at 15% a year it no
longer pays: bought now, it saves running costs and downtime that come
later. Name one of identical trains.
units counts it and its copies under its name, which is not a node's, and
the result's trains lists each train's nodes.
A train is a chain: its first node may be fed by several nodes (its copies
are fed by them all), each of the others by the one before it alone, and its
last node feeds one node alone, where the copies join. A node is in one
train at most, and not in nodes as well; given trains, nodes is by
default none, and a component may be both copied alone and in a train only
in separate searches. Each design is scored exactly, the copies drawn out as
trains of their own. The search's bound on what a copy can save is that the
n + 1-th train changes the system only when at most k − 1 of the n
there work: it saves at most H · downtime_cost_rate · A · P(at most k − 1
of n work), A the train's availability.
Costs, including cost distributions and acquisition costs, and maintenance and inspection schedules are saved with the RBD.