Timelines¶
A timeline is a unit's up/down history over a window: whether it is up at the start, and when it goes down and comes back. RePyability builds them from records (an outage log), merges components' timelines into a system's through its diagram, simulates them, and reads any measure off them: the time up, the failures and who caused them, the first failure, the longest outage.
A unit's timeline¶
Timeline takes the times a unit changes state, in
order: down, up, down, ... (up first if it starts down). An outage log is
easier to give as outages, (start, end); an end of None runs to the end
of the window. A pump's year, one outage of which was planned maintenance:
from repyability import Timeline
pump = Timeline.from_outages(
[(1200, 1236), (4100, 4108), (7000, 7072)],
end=8760,
planned=[False, True, False],
name="pump",
)
pump.availability # -> 0.9868 (8,760 - 116 hours down) / 8,760
pump.failures # -> 2 the planned outage is counted apart
pump.planned_outages # -> 1
pump.first_failure # -> 1200
pump.down_intervals # array([[1200., 1236.], [4100., 4108.], [7000., 7072.]])
pump.state([1000, 1220]) # array([ True, False])
Timeline.from_durations(up_times, down_times, end) takes the durations up
and down in turn instead, as a simulation draws lives and repairs. A change
at the window's end or after it is outside the window. Two changes at one
time are a change and its undoing at once, such as a failure repaired in no
time: they count as a failure and a restoration, with an outage of length
0.
Merging timelines¶
Timelines combine as a diagram's structure does:
series is up while all its inputs are
(a & b for short), parallel while any
is (a | b), and k_out_of_n while at
least k are. ~a is up while a is down. Each change of a merged
timeline keeps its cause, the input whose change made it, and whether it
was planned. Two pumps in parallel feeding a valve, from their logs:
from repyability.timelines import k_out_of_n, parallel, series
a = Timeline.from_outages([(100, 300)], end=1000, name="a")
b = Timeline.from_outages([(250, 260), (600, 700)], end=1000, name="b")
v = Timeline.from_outages([(900, 905)], end=1000, name="v")
plant = (a | b) & v
plant.down_intervals # array([[250., 260.], [900., 905.]])
plant.causes # ['b', 'b', 'v', 'v']
plant.failures_by_cause() # {'a': 0, 'b': 1, 'v': 1}
plant.availability # -> 0.985
Inputs given one by one keep their own causes (a timeline's own changes
have its name, a merged one's the causes it was merged from), so plant
credits its outage at 250 to pump b, whose failure took the pair down.
Given as one mapping, each input is a cause of its own:
series({"pumps": a | b, "valve": v}) credits that outage to "pumps".
Changes at the same time are taken one after another, in the order of the inputs and each input's own changes in their order: a unit that fails and is repaired at once takes the system down and back up at that instant, as a simulation does.
A system's timeline from its diagram¶
RBD.system_timeline merges every
component's timeline up a diagram, {node: timeline}, through its modules
(series, parallel and k-out-of-n) and what is left of it (a bridge, say,
up while one of its minimal path sets is all up). Every block diagram has
it (RBD, NonRepairableRBD and RepairableRBD), and it takes any
timelines: logs, a what-if edit of one, or simulated histories. The plant
again, as a diagram:
from repyability import RBD
rbd = RBD([("s", "a"), ("s", "b"), ("a", "v"), ("b", "v"), ("v", "t")])
rbd.system_timeline({"a": a, "b": b, "v": v}).downtime # -> 15
Had pump b's first outage taken 5 hours rather than 10:
shorter = Timeline.from_outages([(250, 255), (600, 700)], end=1000, name="b")
rbd.system_timeline({"a": a, "b": shorter, "v": v}).downtime # -> 10
The system's changes are credited to its components, and changes at the same time are taken in the order of the diagram's components. A repeated node takes the timeline of the node it repeats; a component no path set needs, and a drawing junction (a perfect node), need none.
Simulated timelines¶
RepairableRBD.simulate_timelines
simulates a repairable system and keeps each simulation's histories whole:
every component's and the system's, as
Timelines, one history per simulation, whose
measures are arrays with a value per simulation. They are the simulations
availability runs with the same
seed, so any measure of a history can be had, not only the averages
availability keeps. The two pumps (Weibull lives, MTTF about 1,780 hours)
and the valve (MTTF 20,000 hours) over a year:
import numpy as np
import surpyval as surv
from repyability import RepairableRBD
pump = {"reliability": surv.Weibull.from_params([2000.0, 1.8]),
"repairability": surv.LogNormal.from_params([3.0, 0.6])} # about 24 h
valve = {"reliability": surv.Exponential.from_params([1 / 20000.0]),
"repairability": surv.LogNormal.from_params([2.0, 0.4])} # about 8 h
plant = RepairableRBD(
[("s", "a"), ("s", "b"), ("a", "v"), ("b", "v"), ("v", "t")],
{"a": pump, "b": pump, "v": valve},
)
runs = plant.simulate_timelines(8760.0, mc_samples=2000, seed=1)
year = runs.system
np.mean(year.failures == 0) # -> 0.5870 no plant failure in the year
year.failures.mean() # -> 0.5170
year.failures_by_cause()["v"].mean() # -> 0.4000 most of them the valve's
runs.components["a"].failures.mean() # -> 4.59 a pump fails often
The pumps fail four or five times a year each, but rarely together: most of the plant's failures are the valve's. The longest outage of each year, from each simulation's own history:
longest = np.array(
[np.max(np.diff(h.down_intervals, axis=1), initial=0.0) for h in year]
)
np.percentile(longest, 95) # -> 14.85
The result is a TimelineSimulation:
system, components (by node), and how they were made (engine and
method). They are availability's simulations with the same seed (and
working_nodes, broken_nodes and antithetic), and their histories are
the event loop's, whichever engine makes them: each one's time up is the
same, to the last bit, and so is every change, with the component it is
credited to.
result = plant.availability(8760.0, mc_samples=2000, seed=1)
bool(np.array_equal(year.uptime, result.uptimes)) # True
Engines and speed¶
engine takes the values availability's does, and makes the histories
in one of these ways:
- Recorded by the compiled event loop (
engine="numba", withpip install "repyability[fast]"): the fastest, for any system the compiled engine simulates (plain components, maintenance, tests, repair crews, standby groups, nested RBDs).engine="auto", the default, runs a long run on it, asavailabilitydoes. - Recorded by the Python event loop (
engine="python", and for what the compiled engine does not simulate: replacement on condition, maintenance groups, imperfect repair, models whose draws cannot be streamed). Recording adds about a quarter to the loop's own time; the loop itself, several times slower than numba's, is the cost.
n_jobs runs on that many threads (numba) or processes (the Python
loop), and the histories are the same however many. On systems
of 3 to 70 components, with and without repair crews, standby groups and
maintenance, numba recorded the histories in 3% to 32% more time than
availability's run of the same simulations took on it, and 5 to 16
times as fast as the Python loop for the systems it alone could record
before.
Parts of a run¶
start makes simulations start to start + mc_samples - 1 of the run
the seed seeds, so parts of one run can be made apart (in other
processes, or on other machines, pickling the results), and
TimelineSimulation.join joins
them into the run:
from repyability import TimelineSimulation
halves = [
plant.simulate_timelines(8760.0, mc_samples=1000, seed=1, start=s)
for s in (0, 1000)
]
bool(TimelineSimulation.join(halves).system == year) # True
Timelines of one unit join the same way: Timelines([first, second]).
Each history keeps every change, about 25 bytes of memory each: for a
summary of a long run, availability keeps less. A diagram with
common-cause groups is refused, as the simulations refuse it.
From the plant as it is now¶
By default the components start new. state starts them as they are now,
as availability(state=...) does (see From the plant as it is
now): their ages, a repair under
way, where they are in their calendars. A component down at 0 starts its
history down, and the system its own in its state then, with no change at
0. Pump a ten hours into a repair and pump b 1,500 hours old, the next
quarter:
from repyability import NodeState
now = {"a": NodeState(alive=False, down_for=10.0), "b": NodeState(age=1500.0)}
quarter = plant.simulate_timelines(2190.0, mc_samples=2000, seed=1, state=now)
np.mean(quarter.system.failures == 0) # -> 0.875 0.887 from new
quarter.system.failures_by_cause()["b"].mean() # -> 0.023 the worn pump's
These are availability(state=now)'s simulations, each history its own. A
component started part way through a life or repair draws it from a stream
of its own, which the event loop follows, in Python.
Many histories at once¶
Timelines holds many histories of one unit over
one window (one per simulation, or one per site from logs) and works out
each measure for all of them at once. Indexing gives one history's
Timeline (a slice, a Timelines), and Timelines merge as Timeline
objects do, history by history; a single Timeline among them stands for
each history.
from repyability import Timelines
sites = Timelines(
[
Timeline.from_outages([(10, 12)], end=100),
Timeline.from_outages([(50, 80)], end=100),
Timeline.from_outages([], end=100),
]
)
sites.availability # array([0.98, 0.7 , 1. ])
sites.point_availability(60) # -> 0.6667 two of the three up at 60
time, fraction = sites.availability_curve()
availability_curve() gives the fraction of histories up after each time
any of them changes, as availability's result gives it.