alidenewade's picture
Initial release: 7-tab simulator with synced animations on Reliability / OEP / Pricing / Economics + 16 paper figures
c561a2a verified
Raw
History Blame Contribute Delete
3.96 kB
"""4-state plant Markov chain — paper §5.
States: OK -> Deg1 -> Deg2 -> F. Generator Q from eq. (10):
Q = [-2λ 2λ 0 0 ]
[ μ -(μ+λ) λ 0 ]
[ 0 μ -(μ+λ_f) λ_f ]
[ 0 0 μ_r -μ_r ]
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
# Uptime Institute Tier → typical (μ, μ_r) calibration in 1/hour.
# Values from Table 1 of dc_paper and §5.3 of the paper.
TIER_CALIBRATION = {
"Tier I (N)": {"avail": 0.99671, "mu": 1/24, "mu_r": 1/72},
"Tier II (N+1)": {"avail": 0.99741, "mu": 1/12, "mu_r": 1/48},
"Tier III (concurrent maint.)": {"avail": 0.99982, "mu": 1/6, "mu_r": 1/24},
"Tier IV (2N fault-tolerant)": {"avail": 0.99995, "mu": 1/4, "mu_r": 1/12},
}
@dataclass(frozen=True)
class MarkovParams:
lam: float # per-train degradation rate, 1/hour
mu: float # per-train repair rate, 1/hour
lam_f: float # degraded -> failed rate, 1/hour
mu_r: float # full-site restoration rate, 1/hour
def Q_matrix(p: MarkovParams) -> np.ndarray:
return np.array([
[-2 * p.lam, 2 * p.lam, 0, 0],
[p.mu, -(p.mu + p.lam), p.lam, 0],
[0, p.mu, -(p.mu + p.lam_f), p.lam_f],
[0, 0, p.mu_r, -p.mu_r],
], dtype=np.float64)
def stationary_distribution(Q: np.ndarray) -> np.ndarray:
"""Solve π Q = 0 subject to π · 1 = 1."""
n = Q.shape[0]
A = np.vstack([Q.T, np.ones(n)])
b = np.concatenate([np.zeros(n), [1.0]])
pi, _, _, _ = np.linalg.lstsq(A, b, rcond=None)
return pi
def availability(pi: np.ndarray) -> float:
"""A = 1 - π_F = 1 - π[3]."""
return float(1.0 - pi[3])
def claim_frequency_per_year(pi: np.ndarray, p: MarkovParams) -> float:
"""λ^out = π_2 · λ_f, converted from /hour to /year (eq. 17)."""
return float(pi[2] * p.lam_f * 8760)
def mtbf_hours(pi: np.ndarray, p: MarkovParams) -> float:
"""Mean time between site outages, in hours."""
rate = pi[2] * p.lam_f
return float(np.inf if rate <= 0 else 1.0 / rate)
def simulate_markov_walk(
p: MarkovParams,
hours: float,
rng: np.random.Generator,
start_state: int = 0,
) -> tuple[np.ndarray, np.ndarray]:
"""Direct (Gillespie-style) CTMC sample on {OK, Deg1, Deg2, F}.
Returns
-------
states : np.ndarray of int in {0, 1, 2, 3}
times : np.ndarray of cumulative hours at which each state began.
The trajectory is a right-continuous step function: at time `times[i]`
the chain entered `states[i]` and stays there until `times[i+1]`.
"""
Q = Q_matrix(p)
exit_rates = -np.diag(Q)
n = Q.shape[0]
# Per-row transition probabilities (off-diagonal renormalised).
P_jump = np.zeros_like(Q)
for i in range(n):
if exit_rates[i] > 0:
for j in range(n):
if i != j:
P_jump[i, j] = Q[i, j] / exit_rates[i]
s = int(start_state)
t = 0.0
states: list[int] = [s]
times: list[float] = [t]
while t < hours:
rate = exit_rates[s]
if rate <= 0:
break
dt = float(rng.exponential(1.0 / rate))
t += dt
if t >= hours:
break
probs = P_jump[s].copy()
total = probs.sum()
if total <= 0:
break
probs /= total
s = int(rng.choice(n, p=probs))
states.append(s)
times.append(t)
# cap at the horizon so the step plot extends visibly past the last jump
states.append(states[-1])
times.append(hours)
return np.asarray(states, dtype=int), np.asarray(times, dtype=float)