Maintaining a system¶
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 8 (maintaining on purpose).
A component of a RepairableRBD can be
replaced before it fails (on age, on a calendar, on its condition, or with
others when they stop), and a component whose failures are hidden can be
tested. This page covers each policy, what it costs and how its interval is
chosen; the costs themselves are priced as in Costs. For one
unit on its own, see Maintenance policies.
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 (but under "calendar"): 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). "calendar": at the first time the unit's age reaches a calendar policy's threshold (see below). |
calendar |
Under "calendar": the CalendarPolicy of NonRepairable.optimal_calendar_policy. |
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 (under "calendar", none: the calendar's price is charged). |
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:
import numpy as np
import surpyval as surv
from repyability import BetaFactor, CCFGroup, RepairableRBD
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 a calendar policy¶
A part whose replacement costs differently at different times (cheap in a
shut, dearer on any other day) is best replaced on a threshold for each
time: the policy NonRepairable.optimal_calendar_policy
works out (see Maintenance policies).
Give it to the component, and the simulation replaces each unit at the first
time its age reaches the threshold, charging the calendar's price there; a
failure is charged the component's own replace_cost:
from repyability import Calendar, NonRepairable, Opportunity
life = surv.Weibull.from_params([1000.0, 2.5])
plant = Calendar(
[Opportunity("quarterly", 2.0, period=91.0), Opportunity("annual", 1.0, period=364.0)],
failure=20.0, anytime=4.0,
)
policy = NonRepairable(life).optimal_calendar_policy(plant)
rbd = RepairableRBD(
[("s", "bearing"), ("bearing", "t")],
{"bearing": {
"reliability": life, "repairability": "instant", "replace_cost": 20.0,
"preventive": {"policy": "calendar", "calendar": policy},
}},
)
rbd.cost(t_simulation=3640.0, mc_samples=500) # simulated, in Python
Only the simulations take it (in the Python loop): the exact and numerical
analyses refuse a component on a calendar policy, as its replacements depend
on when each unit was put in, and a diagram with one is not saved. In a
maintenance group (with a "group", and an "opportunity" age of any size)
it is a member like any other: see
Opportunistic maintenance.
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, since_scheduled=100.0)}
inspected.expected_cost(720.0, state=worn).total # -> 10265 against 8517 new
Replacing on the measured level¶
A unit whose life is a degradation process fitted in surpyval
(surpyval.WienerProcess.fit or surpyval.GammaProcess.fit, with its
failure threshold) can be replaced on what an inspection measures rather
than on its age (#271): "level" in place of "threshold". Each
inspection reads the unit's level, and replaces the unit if the level is at
or past "level"; a unit kept goes on from the level found.
| Key | Meaning |
|---|---|
level |
With "condition", in place of threshold: the degradation level at or past which an inspection replaces the unit, below the process's failure threshold. The life must be a surpyval Wiener or gamma process. |
g = np.random.default_rng(7)
hours = np.tile(np.arange(1.0, 11.0) * 30.0, 12) # 12 units, 10 readings
units = np.repeat(np.arange(12), 10)
worn = np.concatenate([np.cumsum(g.gamma(2.0, 0.25, 10)) for _ in range(12)])
wear = surv.GammaProcess.fit(hours, worn, units, threshold=8.0)
def liner(level):
preventive = {"interval": 60.0, "policy": "condition", "level": level,
"cost": 5.0, "inspection_cost": 0.5}
spec = {"reliability": wear, "repair_cost": 50.0, "preventive": preventive,
"repairability": surv.Exponential.from_params([1 / 20])}
return RepairableRBD([("s", "a"), ("a", "t")], {"a": spec})
liner(6.0).cost(5000.0, mc_samples=400, seed=1).mean # -> 95.06
Run to failure, the liner costs about 402 over the 5000 hours. Replaced at a level of 7.5, 7, 6 or 5 it costs about 250, 133, 95 and 103: a lower level means fewer failures but more replacements.
- By simulation only. The level a unit is found at depends on the
levels before it, which only the simulation follows: the exact and
numerical methods (the long run, over time, the importance measures)
refuse such a component, and
availability()andcost()simulate it, on the Python engine. A start state cannot give the level either, so a run from one refuses it. - How a level is drawn. Between inspections a unit's failure is drawn from the level last measured (surpyval's first-passage quantile from it). If that failure falls before the next inspection, the unit fails then. Otherwise the inspection's level is drawn given that the unit has not failed by then: for a gamma process, a gamma increment truncated at the threshold; for a Wiener process, the path's level killed at the threshold (the method of images). surpyval does not give this level yet (SurPyval#836), so RePyability works it out from the fitted parameters.
- Without a level, a degradation process is a life like any other, its first-passage time to the threshold, and every exact method takes it.
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 or calendar policy) |
The age, from 0 to interval (any age under a calendar policy), 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. - A component on a calendar policy
is a member like any other. Each of its planned replacements is a stop
of its group, and at a stop it is renewed early from its
opportunityage, charged the calendar's price for a planned replacement on that day (the opportunity's if one falls then, else the price on any day; where the calendar allows none, its ownreplace_cost, as a failure is); the new unit's next planned replacement then counts from that day. The policy is the one worked out for the part alone: it is not re-optimised with the group's stops as opportunities, so the thresholds assume the calendar's prices, and the group's stops are extra chances it did not plan for. - 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.