Initial release: 7-tab simulator with synced animations on Reliability / OEP / Pricing / Economics + 16 paper figures
c561a2a verified | """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}, | |
| } | |
| 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) | |