From a925795bf6a149f68c2d328dc6435706283deef3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 01:46:54 +0000 Subject: [PATCH 1/2] Add common-cause failures: beta-factor model (#44) Redundant components often fail from a shared root cause, so their failures are correlated; the exact engine assumes independence and over-estimates redundant systems. Add the beta-factor CCF model: a fraction beta of a symmetric group's failures come from a cause shared across the group (failing every member at once), the rest are independent. NonRepairableRBD gains a `ccf_groups` argument taking CCFGroup(members, BetaFactor(beta)). System reliability is computed *exactly* by conditioning on each group's shared-cause event (fires -> whole group down; else each member fails only independently) and summing the 2**groups branches, each an ordinary independent system-probability evaluation. So beta=0 reproduces the independent result exactly and beta=1 collapses a group to a single component. - New repyability/rbd/ccf.py: BetaFactor and CCFGroup, with validation. - NonRepairableRBD: ccf_groups validated (members exist, non-repeated, not in/out, disjoint, symmetric); sf/ff route through the conditioning engine; RBD serialisation captures the groups. - The probability-dependent importance/sensitivity and condition-based methods raise a clear NotImplementedError on a CCF RBD (CCF is honoured by sf/ff); structural_importance is probability-free and stays available. - Tests anchor beta=0 == independent and the exact 2-parallel conditioning against the textbook beta*Q + ((1-beta)Q)^2, plus multiple groups, time-varying, serialisation and validation. Docs (guide + api) and CHANGELOG updated. Alpha-factor / MGL (partial common-cause) are a planned extension. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NUGaeQXKxjjiQ1ULT1ApXS --- CHANGELOG.md | 13 ++ docs/api.md | 4 + docs/guide.md | 49 +++++ repyability/__init__.py | 3 + repyability/rbd/ccf.py | 86 +++++++++ repyability/rbd/non_repairable_rbd.py | 158 +++++++++++++++- repyability/rbd/serialisation.py | 42 ++++- repyability/tests/test_ccf.py | 254 ++++++++++++++++++++++++++ 8 files changed, 606 insertions(+), 3 deletions(-) create mode 100644 repyability/rbd/ccf.py create mode 100644 repyability/tests/test_ccf.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 771835f..6bf2708 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`is_simulated` reports which). With no load effect (`phi == 1`) the group reduces exactly to the ordinary k-out-of-n parallel result. Plugs into an RBD as a single simulation-backed node and serialises with it. (#38) +- **Common-cause failures (CCF) — beta-factor model.** `NonRepairableRBD` gains + a `ccf_groups` argument taking `CCFGroup(members, BetaFactor(beta))`: a + fraction `beta` of a symmetric redundant group's failures come from a shared + cause that fails every member at once, the rest are independent. System + reliability is computed **exactly** by conditioning on each group's + shared-cause event and reusing the ordinary independent engine per branch, so + `beta = 0` reproduces the independent result and `beta = 1` collapses the + group to a single component. Honoured by `sf()`/`ff()` (and quantities derived + from them) and persisted through serialisation; the probability-dependent + importance/sensitivity and condition-based methods raise a clear error on a + CCF RBD for now (`structural_importance`, being probability-free, is + unaffected). Alpha-factor and Multiple Greek Letter (partial common-cause) are + a planned extension. (#44) ## [0.7.0] - 2026-07-20 diff --git a/docs/api.md b/docs/api.md index f44f816..e0960ee 100644 --- a/docs/api.md +++ b/docs/api.md @@ -31,6 +31,10 @@ the top-level `repyability` package). ::: repyability.NodeState +::: repyability.CCFGroup + +::: repyability.BetaFactor + ::: repyability.PerfectReliability ::: repyability.PerfectUnreliability diff --git a/docs/guide.md b/docs/guide.md index 2ddbd09..fddf397 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -272,6 +272,55 @@ group.mean() # mean group lifetime - Like any dynamic node it plugs into an RBD as a single simulation-backed node and serialises with it (each unit round-trips via `surpyval.from_dict`). +## Common-cause failures (CCF) + +Redundancy only helps if the redundant units fail *independently*. In practice +they often share a root cause — a common manufacturing defect, a shared power +supply, one maintenance error applied to every unit — so a single cause takes +them all down at once. The exact engine assumes independence and therefore +**over-estimates** redundant systems; a common-cause model injects the shared +coupling. + +RePyability provides the **beta-factor** model, the workhorse of +probabilistic-risk assessment: a fraction `beta` of each component's failures +come from a cause shared across the group (failing every member together), and +the remaining `1 - beta` are independent. Declare a +[`CCFGroup`][repyability.CCFGroup] over the coupled nodes and pass it via +`ccf_groups`: + +```python +from surpyval import FixedEventProbability +from repyability import NonRepairableRBD, CCFGroup, BetaFactor + +# Two redundant pumps, each 99% reliable +rbd = NonRepairableRBD( + [("s", "p1"), ("s", "p2"), ("p1", "t"), ("p2", "t")], + {"p1": FixedEventProbability.from_params(0.01), + "p2": FixedEventProbability.from_params(0.01)}, + ccf_groups=[CCFGroup(["p1", "p2"], BetaFactor(0.1))], +) +rbd.sf() # ~0.9989 — a ~10x higher failure probability than the + # independent estimate (0.9999), because the shared cause dominates +``` + +The result is **exact**: internally the system reliability is computed by +conditioning on each group's shared-cause event (fires → the whole group is +down; doesn't → each member fails only independently) and blending by +probability — each branch an ordinary system-reliability evaluation. So `beta = +0` reproduces the independent result exactly, and `beta = 1` makes a redundant +group no better than a single component. + +- Groups must be **symmetric** (members carry identical component models — the + standard CCF assumption) and disjoint (a node is in at most one group). +- CCF is honoured by `sf()` / `ff()` (and quantities derived from them). The + probability-dependent importance / sensitivity measures and the + condition-based (`age`) methods do not yet account for CCF and raise a clear + error on a CCF RBD; `structural_importance` is probability-free and so is + unaffected. The group persists with the RBD through serialisation. +- **Partial** common-cause models — alpha-factor and Multiple Greek Letter, + where a cause fails *some but not all* of a group — build on the same + conditioning and are a planned extension. + ## Repairable systems and availability A [`RepairableRBD`][repyability.RepairableRBD] takes components with both a diff --git a/repyability/__init__.py b/repyability/__init__.py index ac03835..8fc3d4a 100644 --- a/repyability/__init__.py +++ b/repyability/__init__.py @@ -9,6 +9,7 @@ from repyability._version import __version__ from repyability.maintenance import FailureLimitPolicy, MaintenancePolicy from repyability.non_repairable import NonRepairable +from repyability.rbd.ccf import BetaFactor, CCFGroup from repyability.rbd.helper_classes import ( PerfectReliability, PerfectUnreliability, @@ -53,6 +54,8 @@ "PerfectUnreliability", "NodeState", "RegressionNode", + "BetaFactor", + "CCFGroup", "minimal_repair_time_to_nth_failure", # Result types "AvailabilityResult", diff --git a/repyability/rbd/ccf.py b/repyability/rbd/ccf.py new file mode 100644 index 0000000..9eb145e --- /dev/null +++ b/repyability/rbd/ccf.py @@ -0,0 +1,86 @@ +"""Common-cause failure (CCF) models for RBD nodes. + +Redundant components frequently fail from a *shared* root cause — a common +manufacturing defect, a shared power supply, one maintenance error applied to +every unit — so their failures are correlated rather than independent. The +exact RBD engine assumes independence, and therefore over-estimates redundant +systems; a CCF model injects the shared-cause coupling. + +This module provides the **beta-factor** model, the workhorse of +probabilistic-risk assessment: a fraction ``beta`` of a component's failures +come from a cause shared across the whole group (failing every member at once), +and the remaining ``1 - beta`` are independent. A :class:`CCFGroup` binds a set +of member nodes to such a model; pass groups to a ``NonRepairableRBD`` via its +``ccf_groups`` argument. + +See issue #44. Partial common-cause models (alpha-factor, Multiple Greek +Letter — where a cause fails *some but not all* of a group) build on the same +conditioning machinery and are a planned extension. +""" + +from typing import Any, Collection, Hashable + + +class BetaFactor: + """The beta-factor common-cause model. + + A fraction ``beta`` of each component's total failure probability is + attributed to a cause shared across the whole group (which fails every + member simultaneously); the remaining ``1 - beta`` is independent. + + Parameters + ---------- + beta : float + The common-cause fraction, in ``[0, 1]``. ``beta = 0`` is ordinary + independence; ``beta = 1`` makes the group fail entirely in unison. + """ + + def __init__(self, beta: float): + if not (0.0 <= beta <= 1.0): + raise ValueError(f"beta must be in [0, 1], got {beta!r}.") + self.beta = float(beta) + + def __eq__(self, other: object) -> bool: + return isinstance(other, BetaFactor) and other.beta == self.beta + + def __hash__(self) -> int: + return hash((type(self).__name__, self.beta)) + + def __repr__(self) -> str: + return f"BetaFactor(beta={self.beta})" + + +class CCFGroup: + """A common-cause group: member nodes coupled by a shared failure cause. + + Parameters + ---------- + members : collection of node names + The RBD nodes that share the common cause (at least two, distinct). + Standard CCF theory is for *symmetric* groups, so the members should + carry identical component models. + model : BetaFactor + The common-cause model coupling the members. (Alpha-factor / Multiple + Greek Letter are a planned extension.) + """ + + def __init__(self, members: Collection[Hashable], model: Any): + members = tuple(members) + if len(members) < 2: + raise ValueError( + f"A CCF group needs at least 2 members, got {len(members)}." + ) + if len(set(members)) != len(members): + raise ValueError( + f"CCF group members must be distinct, got {list(members)}." + ) + if not isinstance(model, BetaFactor): + raise ValueError( + "CCFGroup model must be a BetaFactor; alpha-factor and " + "Multiple Greek Letter models are not supported yet." + ) + self.members = members + self.model = model + + def __repr__(self) -> str: + return f"CCFGroup(members={list(self.members)}, model={self.model!r})" diff --git a/repyability/rbd/non_repairable_rbd.py b/repyability/rbd/non_repairable_rbd.py index fe8ad90..39178b4 100644 --- a/repyability/rbd/non_repairable_rbd.py +++ b/repyability/rbd/non_repairable_rbd.py @@ -25,6 +25,7 @@ from repyability.utils.wrappers import conditional_survival, numpy_seed from ._model_utils import is_fixed_probability, parametric_spec +from .ccf import CCFGroup from .helper_classes import PerfectReliability, PerfectUnreliability from .load_sharing_node import LoadSharingModel from .node_state import NodeState @@ -125,6 +126,7 @@ def __init__( input_node: Optional[Any] = None, output_node: Optional[Any] = None, on_infeasible_rbd: str = "raise", + ccf_groups: Optional[Iterable[CCFGroup]] = None, ): if on_infeasible_rbd not in ["raise", "warn", "ignore"]: raise ValueError( @@ -134,6 +136,7 @@ def __init__( # Capture the constructor inputs verbatim (before any mutation) so the # RBD can be faithfully serialised via to_dict()/to_json(). edges = list(edges) + ccf_groups = list(ccf_groups) if ccf_groups else [] self._init_args = { "edges": [tuple(e) for e in edges], "reliabilities": dict(reliabilities), @@ -141,6 +144,7 @@ def __init__( "input_node": input_node, "output_node": output_node, "on_infeasible_rbd": on_infeasible_rbd, + "ccf_groups": ccf_groups, } reliabilities = copy(reliabilities) for key, value in reliabilities.items(): @@ -238,6 +242,7 @@ def __init__( self.reliabilities = reliabilities self.repeated = repeated + self.ccf_groups = self._validate_ccf_groups(ccf_groups) fixed_flags = [] for _, node in self.reliabilities.items(): @@ -352,7 +357,20 @@ def sf( broken_nodes = set() if broken_nodes is None else set(broken_nodes) self._validate_node_overrides(working_nodes, broken_nodes) - # Collect node probabilities to pass to RBD class + node_probabilities = self._base_node_probabilities( + x, working_nodes, broken_nodes + ) + if self.ccf_groups: + return self._ccf_system_probability( + node_probabilities, working_nodes, broken_nodes, method + ) + return self.system_probability(node_probabilities, method=method) + + def _base_node_probabilities( + self, x, working_nodes, broken_nodes + ) -> Dict[Any, np.ndarray]: + """Per-node reliability at ``x``, honouring the working/broken + overrides (perfectly reliable / perfectly unreliable).""" node_probabilities: dict[Any, np.ndarray] = {} for node_name in self.reliabilities.keys(): if node_name in working_nodes: @@ -363,8 +381,131 @@ def sf( node_probabilities[node_name] = self.reliabilities[ node_name ].sf(x) + return node_probabilities - return self.system_probability(node_probabilities, method=method) + def _validate_ccf_groups(self, ccf_groups) -> list: + """Validate common-cause groups against the RBD structure. + + Members must be real, non-repeated component nodes (not the input or + output node), each node in at most one group, and each group symmetric + (identical component models). Returns the validated list. + """ + seen: set = set() + for group in ccf_groups: + if not isinstance(group, CCFGroup): + raise ValueError( + "ccf_groups must contain CCFGroup instances, got " + f"{type(group).__name__}." + ) + for member in group.members: + if member not in self.reliabilities: + raise ValueError( + f"CCF group member {member!r} is not a node in the " + "RBD." + ) + if member in (self.input_node, self.output_node): + raise ValueError( + f"CCF group member {member!r} cannot be the input or " + "output node." + ) + if member in self.repeated: + raise ValueError( + f"CCF group member {member!r} cannot be a repeated " + "node." + ) + if member in seen: + raise ValueError( + f"Node {member!r} appears in more than one CCF group." + ) + seen.add(member) + self._check_symmetric_group(group) + return list(ccf_groups) + + def _check_symmetric_group(self, group) -> None: + # Standard CCF theory is for symmetric groups, so the members must + # carry identical component models. Compare via the serialised form + # (exact); skip silently if a member is not serialisable. + from repyability.rbd.serialisation import serialise_model + + try: + specs = [ + serialise_model(self.reliabilities[m]) for m in group.members + ] + except Exception: + return + if any(spec != specs[0] for spec in specs[1:]): + raise ValueError( + f"CCF group {list(group.members)} is not symmetric: its " + "members must carry identical component models." + ) + + def _ccf_system_probability( + self, base_probabilities, working_nodes, broken_nodes, method + ) -> np.ndarray: + """Exact system reliability with common-cause groups, by conditioning + on each group's shared-cause event. + + For each beta-factor group the shared cause either fires (probability + ``beta * Q``, failing every member) or does not (each member fails only + independently, reliability ``1 - (1 - beta) * Q``). Conditioning on the + independent shared-cause events of every group and summing over the + ``2 ** len(groups)`` combinations gives the exact result — each term a + call to the ordinary independent engine. ``beta = 0`` recovers it + exactly. + """ + from itertools import product + + forced = working_nodes | broken_nodes + for group in self.ccf_groups: + if forced.intersection(group.members): + raise NotImplementedError( + "Forcing a CCF group member via working_nodes / " + "broken_nodes is not supported yet." + ) + + # Per-group failure probability Q(t), from a representative member + # (groups are symmetric). + group_Q = [ + 1.0 - np.atleast_1d(base_probabilities[g.members[0]]) + for g in self.ccf_groups + ] + + terms = [] + for combo in product((False, True), repeat=len(self.ccf_groups)): + node_probabilities = dict(base_probabilities) + weight = np.ones_like(group_Q[0]) + for group, Q, fired in zip(self.ccf_groups, group_Q, combo): + beta = group.model.beta + q_common = beta * Q + if fired: + weight = weight * q_common + for member in group.members: + node_probabilities[member] = np.zeros_like(Q) + else: + weight = weight * (1.0 - q_common) + r_independent = 1.0 - (1.0 - beta) * Q + for member in group.members: + node_probabilities[member] = r_independent + terms.append( + weight + * np.asarray( + self.system_probability(node_probabilities, method=method) + ) + ) + return np.sum(terms, axis=0) + + def _require_no_ccf(self) -> None: + """Raise if the RBD has CCF groups, for the probability-dependent + importance / sensitivity measures that do not yet account for them. + (Common-cause coupling is currently reflected only in ``sf()`` / + ``ff()``; ``structural_importance`` is probability-free and so is + unaffected.)""" + if self.ccf_groups: + raise NotImplementedError( + "Importance and sensitivity measures do not yet account for " + "common-cause (CCF) groups; CCF is currently supported by " + "sf() / ff()." + ) def ff( self, x: Optional[ArrayLike] = None, *args, **kwargs @@ -930,6 +1071,13 @@ def _state_node_probabilities(self, x, state) -> Dict[Any, ArrayLike]: a failed node contributes zero, and an alive node of age ``X`` contributes ``conditional_survival(model, x, X) = sf(X + x) / sf(X)``. """ + if self.ccf_groups: + raise NotImplementedError( + "Condition-based evaluation (sf_given_state / remaining_life " + "/ importances_given_state) does not yet account for " + "common-cause (CCF) groups; use sf()/ff() for CCF system " + "reliability." + ) self._validate_state(state) node_probabilities: Dict[Any, ArrayLike] = {} for node_name, model in self.reliabilities.items(): @@ -1193,6 +1341,7 @@ def birnbaum_importance( >>> {k: round(v, 4) for k, v in sorted(bi.items())} {'a': 0.1, 'b': 0.1} """ + self._require_no_ccf() node_probabilities = self._probabilities_with_overrides( self.node_sf(x), working_nodes, broken_nodes ) @@ -1225,6 +1374,7 @@ def improvement_potential( Dictionary with node names as keys and improvement potentials as values (floats for scalar ``x``, arrays for array ``x``) """ + self._require_no_ccf() node_probabilities = self._probabilities_with_overrides( self.node_sf(x), working_nodes, broken_nodes ) @@ -1259,6 +1409,7 @@ def risk_achievement_worth( Dictionary with node names as keys and RAW importances as values (floats for scalar ``x``, arrays for array ``x``) """ + self._require_no_ccf() node_probabilities = self._probabilities_with_overrides( self.node_sf(x), working_nodes, broken_nodes ) @@ -1293,6 +1444,7 @@ def risk_reduction_worth( Dictionary with node names as keys and RRW importances as values (floats for scalar ``x``, arrays for array ``x``) """ + self._require_no_ccf() node_probabilities = self._probabilities_with_overrides( self.node_sf(x), working_nodes, broken_nodes ) @@ -1325,6 +1477,7 @@ def criticality_importance( Dictionary with node names as keys and criticality importances as values (floats for scalar ``x``, arrays for array ``x``) """ + self._require_no_ccf() node_probabilities = self._probabilities_with_overrides( self.node_sf(x), working_nodes, broken_nodes ) @@ -1378,6 +1531,7 @@ def fussell_vesely( ValueError If ``fv_type`` is not 'c' (cut-set) or 'p' (path-set). """ + self._require_no_ccf() rel_dict = {} for node_name, node in self.reliabilities.items(): rel_dict[node_name] = node.sf(x) diff --git a/repyability/rbd/serialisation.py b/repyability/rbd/serialisation.py index 7d554b8..00e8c21 100644 --- a/repyability/rbd/serialisation.py +++ b/repyability/rbd/serialisation.py @@ -201,6 +201,40 @@ def _k_from_list(k_list): return None if not k_list else {e["node"]: e["k"] for e in k_list} +def _ccf_to_list(ccf_groups): + from repyability.rbd.ccf import BetaFactor + + if not ccf_groups: + return None + out = [] + for group in ccf_groups: + if isinstance(group.model, BetaFactor): + model = {"kind": "beta_factor", "beta": group.model.beta} + else: + raise NotImplementedError( + f"Cannot serialise CCF model {type(group.model).__name__}." + ) + out.append({"members": list(group.members), "model": model}) + return out + + +def _ccf_from_list(ccf_list): + from repyability.rbd.ccf import BetaFactor, CCFGroup + + if not ccf_list: + return None + groups = [] + for entry in ccf_list: + model_dict = entry["model"] + kind = model_dict["kind"] + if kind == "beta_factor": + model = BetaFactor(model_dict["beta"]) + else: + raise ValueError(f"Unknown CCF model kind {kind!r}.") + groups.append(CCFGroup(entry["members"], model)) + return groups + + def rbd_to_dict(rbd: RBD) -> dict: """Serialise an RBD (NonRepairableRBD or RepairableRBD) to a dict.""" args = rbd._init_args @@ -227,6 +261,7 @@ def rbd_to_dict(rbd: RBD) -> dict: } for n, v in args["reliabilities"].items() ] + out["ccf_groups"] = _ccf_to_list(args.get("ccf_groups")) return out @@ -255,7 +290,12 @@ def rbd_from_dict(d: dict) -> RBD: e["node"]: _deserialise_reliability_value(e["model"]) for e in d["reliabilities"] } - return NonRepairableRBD(edges, reliabilities, **common) + return NonRepairableRBD( + edges, + reliabilities, + ccf_groups=_ccf_from_list(d.get("ccf_groups")), + **common, + ) raise ValueError(f"Unknown RBD type {rbd_type!r}.") diff --git a/repyability/tests/test_ccf.py b/repyability/tests/test_ccf.py new file mode 100644 index 0000000..ae30d72 --- /dev/null +++ b/repyability/tests/test_ccf.py @@ -0,0 +1,254 @@ +"""Tests for common-cause failures (CCF), issue #44: the beta-factor model on +``NonRepairableRBD``. + +The exact anchors use a two-component parallel system, whose system +unreliability under the beta-factor decomposition (a shared cause of +probability ``beta*Q`` failing both, independent failures ``(1-beta)*Q`` +otherwise) has the closed form the conditioning engine must reproduce, and +which collapses to the ordinary independent result at ``beta = 0``. +""" + +import numpy as np +import pytest +from surpyval import FixedEventProbability, Weibull + +from repyability import ( + BetaFactor, + CCFGroup, + NodeState, + NonRepairableRBD, + PerfectReliability, +) + +PARALLEL = [("s", "a"), ("s", "b"), ("a", "t"), ("b", "t")] + + +def _parallel(Q, ccf_groups=None): + return NonRepairableRBD( + PARALLEL, + { + "a": FixedEventProbability.from_params(Q), + "b": FixedEventProbability.from_params(Q), + }, + ccf_groups=ccf_groups, + ) + + +# -- beta = 0 reduces exactly to independence ------------------------------ + + +@pytest.mark.parametrize("Q", [0.01, 0.1, 0.3]) +def test_beta_zero_is_independent(Q): + indep = _parallel(Q) + with_ccf = _parallel(Q, [CCFGroup(["a", "b"], BetaFactor(0.0))]) + assert with_ccf.sf() == pytest.approx(indep.sf()) + + +def test_beta_zero_kofn(): + # A 2-out-of-3 structure: beta=0 must match the plain k-of-n reliability. + edges = [("s", n) for n in "abc"] + [(n, "t") for n in "abc"] + nodes = {n: FixedEventProbability.from_params(0.1) for n in "abc"} + k = {"t": 2} + indep = NonRepairableRBD(edges, nodes, k=k) + ccf = NonRepairableRBD( + edges, nodes, k=k, ccf_groups=[CCFGroup(list("abc"), BetaFactor(0.0))] + ) + assert ccf.sf() == pytest.approx(indep.sf()) + + +# -- the exact conditioning result ----------------------------------------- + + +@pytest.mark.parametrize("Q,beta", [(0.01, 0.1), (0.1, 0.2), (0.2, 0.05)]) +def test_parallel_matches_exact_conditioning(Q, beta): + ccf = _parallel(Q, [CCFGroup(["a", "b"], BetaFactor(beta))]) + # Exact conditioning: shared cause fires (prob beta*Q -> both down) or not + # (each fails independently with prob (1-beta)*Q). + q_common = beta * Q + r_independent_fail = (1 - beta) * Q + expected = (1 - q_common) * (1 - r_independent_fail**2) + assert float(ccf.sf()) == pytest.approx(expected) + # ...and it is close to the (rare-event) textbook value + # beta*Q + ((1-b)Q)^2 (they differ only at higher order). + textbook = beta * Q + ((1 - beta) * Q) ** 2 + assert 1 - float(ccf.sf()) == pytest.approx(textbook, abs=1e-3) + + +def test_beta_one_kills_redundancy(): + # beta = 1: the pair always fails together, so a parallel pair is no better + # than a single component. + Q = 0.1 + ccf = _parallel(Q, [CCFGroup(["a", "b"], BetaFactor(1.0))]) + assert float(ccf.sf()) == pytest.approx(1 - Q) + + +def test_ccf_lowers_redundant_reliability(): + Q = 0.05 + indep = float(_parallel(Q).sf()) + for beta in (0.05, 0.2, 0.5): + r = float(_parallel(Q, [CCFGroup(["a", "b"], BetaFactor(beta))]).sf()) + assert r < indep + + +def test_sf_monotone_decreasing_in_beta(): + Q = 0.1 + rs = [ + float(_parallel(Q, [CCFGroup(["a", "b"], BetaFactor(b))]).sf()) + for b in (0.0, 0.1, 0.3, 0.6, 1.0) + ] + assert all(a >= b for a, b in zip(rs, rs[1:])) + + +# -- multiple groups, time-varying ----------------------------------------- + + +def test_multiple_disjoint_groups(): + # Two independent parallel pairs in series, each its own CCF group. + edges = [ + ("s", "a1"), + ("s", "a2"), + ("a1", "m"), + ("a2", "m"), + ("m", "b1"), + ("m", "b2"), + ("b1", "t"), + ("b2", "t"), + ] + Q = 0.1 + nodes = { + n: FixedEventProbability.from_params(Q) + for n in ("a1", "a2", "b1", "b2") + } + nodes["m"] = PerfectReliability # perfect connector between the two stages + groups = [ + CCFGroup(["a1", "a2"], BetaFactor(0.1)), + CCFGroup(["b1", "b2"], BetaFactor(0.2)), + ] + rbd = NonRepairableRBD(edges, nodes, ccf_groups=groups) + # Each stage is a parallel pair with CCF; the series system is their + # product (the stages are independent). + stage_a = (1 - 0.1 * Q) * (1 - (0.9 * Q) ** 2) + stage_b = (1 - 0.2 * Q) * (1 - (0.8 * Q) ** 2) + assert float(rbd.sf()) == pytest.approx(stage_a * stage_b) + + +def test_time_varying_array(): + rbd = NonRepairableRBD( + PARALLEL, + { + "a": Weibull.from_params([100.0, 2.0]), + "b": Weibull.from_params([100.0, 2.0]), + }, + ccf_groups=[CCFGroup(["a", "b"], BetaFactor(0.1))], + ) + t = np.array([20.0, 60.0, 120.0]) + out = rbd.sf(t) + assert out.shape == (3,) + assert np.all(np.diff(out) < 0) # decreasing in time + # matches the per-time exact conditioning + Q = 1 - np.exp(-((t / 100.0) ** 2.0)) + expected = (1 - 0.1 * Q) * (1 - (0.9 * Q) ** 2) + assert np.allclose(out, expected) + + +# -- serialisation --------------------------------------------------------- + + +def test_serialisation_roundtrip(): + rbd = NonRepairableRBD( + PARALLEL, + { + "a": Weibull.from_params([100.0, 2.0]), + "b": Weibull.from_params([100.0, 2.0]), + }, + ccf_groups=[CCFGroup(["a", "b"], BetaFactor(0.12))], + ) + restored = NonRepairableRBD.from_json(rbd.to_json()) + assert len(restored.ccf_groups) == 1 + assert restored.ccf_groups[0].model == BetaFactor(0.12) + assert list(restored.ccf_groups[0].members) == ["a", "b"] + t = np.array([30.0, 90.0]) + np.testing.assert_allclose(restored.sf(t), rbd.sf(t)) + + +# -- unsupported combinations raise clearly -------------------------------- + + +def test_importance_and_state_guarded(): + rbd = NonRepairableRBD( + PARALLEL, + { + "a": Weibull.from_params([100.0, 2.0]), + "b": Weibull.from_params([100.0, 2.0]), + }, + ccf_groups=[CCFGroup(["a", "b"], BetaFactor(0.1))], + ) + for call in ( + lambda: rbd.birnbaum_importance(50.0), + lambda: rbd.criticality_importance(50.0), + lambda: rbd.fussell_vesely(50.0), + lambda: rbd.parameter_sensitivity(50.0), + lambda: rbd.sf_given_state(50.0, {"a": NodeState(age=10)}), + lambda: rbd.remaining_life(0.9, {"a": NodeState(age=10)}), + ): + with pytest.raises(NotImplementedError, match="CCF|common-cause"): + call() + + +def test_structural_importance_allowed_with_ccf(): + # Structural importance is probability-free, so CCF does not change it. + rbd = _parallel(0.1, [CCFGroup(["a", "b"], BetaFactor(0.3))]) + si = rbd.structural_importance() + assert si == pytest.approx({"a": 0.5, "b": 0.5}) + + +def test_forcing_ccf_member_raises(): + rbd = _parallel(0.1, [CCFGroup(["a", "b"], BetaFactor(0.1))]) + with pytest.raises(NotImplementedError, match="CCF|broken_nodes"): + rbd.sf(broken_nodes=["a"]) + + +# -- validation ------------------------------------------------------------ + + +def test_betafactor_validation(): + with pytest.raises(ValueError, match="beta"): + BetaFactor(-0.1) + with pytest.raises(ValueError, match="beta"): + BetaFactor(1.5) + assert BetaFactor(0.2) == BetaFactor(0.2) + assert BetaFactor(0.2) != BetaFactor(0.3) + + +def test_ccfgroup_validation(): + with pytest.raises(ValueError, match="at least 2"): + CCFGroup(["a"], BetaFactor(0.1)) + with pytest.raises(ValueError, match="distinct"): + CCFGroup(["a", "a"], BetaFactor(0.1)) + with pytest.raises(ValueError, match="BetaFactor"): + CCFGroup(["a", "b"], object()) + + +def test_group_validation_against_rbd(): + # Unknown member + with pytest.raises(ValueError, match="not a node"): + _parallel(0.1, [CCFGroup(["a", "z"], BetaFactor(0.1))]) + # Non-symmetric members + with pytest.raises(ValueError, match="symmetric"): + NonRepairableRBD( + PARALLEL, + { + "a": Weibull.from_params([100.0, 2.0]), + "b": Weibull.from_params([200.0, 2.0]), + }, + ccf_groups=[CCFGroup(["a", "b"], BetaFactor(0.1))], + ) + # A node in two groups + with pytest.raises(ValueError, match="more than one"): + _parallel( + 0.1, + [ + CCFGroup(["a", "b"], BetaFactor(0.1)), + CCFGroup(["a", "b"], BetaFactor(0.2)), + ], + ) From d1f2e94a8c677cddd357afd7e13c6943a3021d56 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 03:00:40 +0000 Subject: [PATCH 2/2] Add Multiple Greek Letter (MGL) common-cause model (#44) Extend the CCF support with the Multiple Greek Letter model alongside beta-factor. MGL captures partial common causes (a cause failing some but not all of a group) via a cascade of conditional probabilities beta, gamma, delta, ...; the number of letters fixes the group size (m-1 letters for m members). MGL(beta) on two members is exactly BetaFactor(beta). - New MGL model (repyability/rbd/ccf.py) with the standard MGL basic-event probabilities Q_k for a specific k-of-m subset. Both CCF models now share a decompose(members, Q) -> (independent failure, mutually-exclusive shocks) interface, and NonRepairableRBD's conditioning engine sums over each group's shock-outcome partition (generalising the beta-factor fires/not branches), reusing the ordinary independent engine per branch. - Serialisation and package export extended; group-size validation for MGL. - Tests: MGL(beta) == BetaFactor(beta) on two members; a 1-of-3 triple matches the textbook leading-order MGL result; gamma=1 collapses to all-or-nothing; serialisation round-trip; validation. Docs and CHANGELOG updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NUGaeQXKxjjiQ1ULT1ApXS --- CHANGELOG.md | 29 +++--- docs/api.md | 2 + docs/guide.md | 22 ++-- repyability/__init__.py | 3 +- repyability/rbd/ccf.py | 141 ++++++++++++++++++++++---- repyability/rbd/non_repairable_rbd.py | 61 +++++++---- repyability/rbd/serialisation.py | 10 +- repyability/tests/test_ccf.py | 69 +++++++++++++ 8 files changed, 273 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bf2708..19373f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,19 +18,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`is_simulated` reports which). With no load effect (`phi == 1`) the group reduces exactly to the ordinary k-out-of-n parallel result. Plugs into an RBD as a single simulation-backed node and serialises with it. (#38) -- **Common-cause failures (CCF) — beta-factor model.** `NonRepairableRBD` gains - a `ccf_groups` argument taking `CCFGroup(members, BetaFactor(beta))`: a - fraction `beta` of a symmetric redundant group's failures come from a shared - cause that fails every member at once, the rest are independent. System - reliability is computed **exactly** by conditioning on each group's - shared-cause event and reusing the ordinary independent engine per branch, so - `beta = 0` reproduces the independent result and `beta = 1` collapses the - group to a single component. Honoured by `sf()`/`ff()` (and quantities derived - from them) and persisted through serialisation; the probability-dependent - importance/sensitivity and condition-based methods raise a clear error on a - CCF RBD for now (`structural_importance`, being probability-free, is - unaffected). Alpha-factor and Multiple Greek Letter (partial common-cause) are - a planned extension. (#44) +- **Common-cause failures (CCF) — beta-factor and Multiple Greek Letter.** + `NonRepairableRBD` gains a `ccf_groups` argument taking + `CCFGroup(members, model)`, where the model couples a symmetric redundant + group through a shared failure cause. `BetaFactor(beta)` is the all-or-nothing + model (a fraction `beta` of failures fail the whole group at once, the rest + are independent); `MGL(beta, gamma, ...)` is the Multiple Greek Letter model, + which also captures *partial* common causes (a cause failing some but not all + of the group) — `MGL(beta)` on two members is exactly `BetaFactor(beta)`. + System reliability is computed **exactly** by conditioning on each group's + mutually-exclusive shock outcomes and reusing the ordinary independent engine + per branch, so `beta = 0` reproduces the independent result. Honoured by + `sf()`/`ff()` (and quantities derived from them) and persisted through + serialisation; the probability-dependent importance/sensitivity and + condition-based methods raise a clear error on a CCF RBD for now + (`structural_importance`, being probability-free, is unaffected). + Alpha-factor is a planned extension. (#44) ## [0.7.0] - 2026-07-20 diff --git a/docs/api.md b/docs/api.md index e0960ee..28cf43a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -35,6 +35,8 @@ the top-level `repyability` package). ::: repyability.BetaFactor +::: repyability.MGL + ::: repyability.PerfectReliability ::: repyability.PerfectUnreliability diff --git a/docs/guide.md b/docs/guide.md index fddf397..183c5cc 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -281,13 +281,20 @@ them all down at once. The exact engine assumes independence and therefore **over-estimates** redundant systems; a common-cause model injects the shared coupling. -RePyability provides the **beta-factor** model, the workhorse of -probabilistic-risk assessment: a fraction `beta` of each component's failures -come from a cause shared across the group (failing every member together), and -the remaining `1 - beta` are independent. Declare a -[`CCFGroup`][repyability.CCFGroup] over the coupled nodes and pass it via +RePyability provides two models, both declared with a +[`CCFGroup`][repyability.CCFGroup] over the coupled nodes and passed via `ccf_groups`: +- [`BetaFactor(beta)`][repyability.BetaFactor] — the workhorse of + probabilistic-risk assessment: a fraction `beta` of each component's failures + come from a cause shared across the *whole* group (failing every member + together), the rest are independent. +- [`MGL(beta, gamma, ...)`][repyability.MGL] — the Multiple Greek Letter model, + which also captures *partial* common causes (a cause failing some but not all + of the group) via a cascade of conditional probabilities. `MGL(beta)` on two + members is exactly `BetaFactor(beta)`; a group of `m` members takes `m - 1` + letters. + ```python from surpyval import FixedEventProbability from repyability import NonRepairableRBD, CCFGroup, BetaFactor @@ -317,9 +324,8 @@ group no better than a single component. condition-based (`age`) methods do not yet account for CCF and raise a clear error on a CCF RBD; `structural_importance` is probability-free and so is unaffected. The group persists with the RBD through serialisation. -- **Partial** common-cause models — alpha-factor and Multiple Greek Letter, - where a cause fails *some but not all* of a group — build on the same - conditioning and are a planned extension. +- **Alpha-factor** — a data-estimable reparameterisation of the same partial + common-cause multiplicities as MGL — is a planned extension. ## Repairable systems and availability diff --git a/repyability/__init__.py b/repyability/__init__.py index 8fc3d4a..31e5216 100644 --- a/repyability/__init__.py +++ b/repyability/__init__.py @@ -9,7 +9,7 @@ from repyability._version import __version__ from repyability.maintenance import FailureLimitPolicy, MaintenancePolicy from repyability.non_repairable import NonRepairable -from repyability.rbd.ccf import BetaFactor, CCFGroup +from repyability.rbd.ccf import MGL, BetaFactor, CCFGroup from repyability.rbd.helper_classes import ( PerfectReliability, PerfectUnreliability, @@ -55,6 +55,7 @@ "NodeState", "RegressionNode", "BetaFactor", + "MGL", "CCFGroup", "minimal_repair_time_to_nth_failure", # Result types diff --git a/repyability/rbd/ccf.py b/repyability/rbd/ccf.py index 9eb145e..c8b7de0 100644 --- a/repyability/rbd/ccf.py +++ b/repyability/rbd/ccf.py @@ -6,23 +6,39 @@ exact RBD engine assumes independence, and therefore over-estimates redundant systems; a CCF model injects the shared-cause coupling. -This module provides the **beta-factor** model, the workhorse of -probabilistic-risk assessment: a fraction ``beta`` of a component's failures -come from a cause shared across the whole group (failing every member at once), -and the remaining ``1 - beta`` are independent. A :class:`CCFGroup` binds a set -of member nodes to such a model; pass groups to a ``NonRepairableRBD`` via its -``ccf_groups`` argument. - -See issue #44. Partial common-cause models (alpha-factor, Multiple Greek -Letter — where a cause fails *some but not all* of a group) build on the same -conditioning machinery and are a planned extension. +Two models are provided, both consumed by ``NonRepairableRBD``'s ``ccf_groups`` +argument via a :class:`CCFGroup`: + +* :class:`BetaFactor` — a fraction ``beta`` of a component's failures come from + a cause shared across the *whole* group (all members fail together), the rest + are independent. The workhorse of probabilistic-risk assessment. +* :class:`MGL` — the Multiple Greek Letter model, which additionally captures + *partial* common causes (a cause failing some but not all of the group) via a + cascade of conditional probabilities ``beta, gamma, delta, ...``. Beta-factor + is the two-unit special case. + +Both expose :meth:`decompose`, which splits a group's failure probability into +an independent part plus a set of mutually-exclusive *shock* outcomes (each a +subset of members failing together); ``NonRepairableRBD`` evaluates the exact +system reliability by conditioning on those outcomes. See issue #44. + +Alpha-factor (a data-estimable reparameterisation of the same multiplicities) +is a planned extension. """ -from typing import Any, Collection, Hashable +from itertools import combinations +from math import comb +from typing import Any, Collection, Hashable, List, Tuple + +import numpy as np + +# A group's failure decomposition: the per-component independent failure +# probability, and a list of (members-failing-together, probability) shocks. +Decomposition = Tuple[np.ndarray, List[Tuple[frozenset, np.ndarray]]] class BetaFactor: - """The beta-factor common-cause model. + """The beta-factor common-cause model (all-or-nothing). A fraction ``beta`` of each component's total failure probability is attributed to a cause shared across the whole group (which fails every @@ -40,6 +56,15 @@ def __init__(self, beta: float): raise ValueError(f"beta must be in [0, 1], got {beta!r}.") self.beta = float(beta) + def required_group_size(self) -> Any: + return None # any group of >= 2 members + + def decompose(self, members: Collection[Hashable], Q) -> Decomposition: + Q = np.atleast_1d(np.asarray(Q, dtype=float)) + q_independent = (1.0 - self.beta) * Q + shocks = [(frozenset(members), self.beta * Q)] + return q_independent, shocks + def __eq__(self, other: object) -> bool: return isinstance(other, BetaFactor) and other.beta == self.beta @@ -50,6 +75,80 @@ def __repr__(self) -> str: return f"BetaFactor(beta={self.beta})" +class MGL: + """The Multiple Greek Letter common-cause model. + + The parameters are the conditional probabilities of a common-cause failure + escalating to the next level: ``beta = P(shared by >= 2 | failed)``, + ``gamma = P(>= 3 | >= 2)``, ``delta = P(>= 4 | >= 3)``, and so on. The + number of parameters fixes the group size: ``n`` letters describe a group + of ``n + 1`` members. ``MGL(beta)`` is exactly :class:`BetaFactor` on a + two-member group. + + The probability that a common cause fails a *specific* set of ``k`` of the + ``m`` members is the standard MGL basic-event probability + + ``Q_k = [1 / C(m-1, k-1)] * (rho_1 * ... * rho_k) * (1 - rho_{k+1}) * Q`` + + with ``rho_1 = 1``, ``rho_2 = beta``, ``rho_3 = gamma``, ..., and + ``rho_{m+1} = 0``. These partition each component's total failure + probability ``Q`` exactly. + + Parameters + ---------- + *letters : float + ``beta, gamma, delta, ...``, each in ``[0, 1]``; at least one. A group + of ``m`` members needs ``m - 1`` letters. + """ + + def __init__(self, *letters: float): + if len(letters) < 1: + raise ValueError("MGL needs at least one parameter (beta).") + for value in letters: + if not (0.0 <= value <= 1.0): + raise ValueError( + f"MGL parameters must be in [0, 1], got {value!r}." + ) + self.letters = tuple(float(v) for v in letters) + + @property + def group_size(self) -> int: + return len(self.letters) + 1 + + def required_group_size(self) -> int: + return self.group_size + + def _specific_set_prob(self, m: int, k: int, Q: np.ndarray) -> np.ndarray: + # rho[0..m-1] represents rho_1..rho_m (rho_1 = 1, then the letters). + rho = [1.0] + list(self.letters) + prod = 1.0 + for i in range(k): + prod *= rho[i] + rho_next = rho[k] if k < m else 0.0 + return (prod * (1.0 - rho_next) / comb(m - 1, k - 1)) * Q + + def decompose(self, members: Collection[Hashable], Q) -> Decomposition: + members = tuple(members) + m = len(members) + Q = np.atleast_1d(np.asarray(Q, dtype=float)) + q_independent = self._specific_set_prob(m, 1, Q) + shocks: List[Tuple[frozenset, np.ndarray]] = [] + for k in range(2, m + 1): + q_k = self._specific_set_prob(m, k, Q) + for subset in combinations(members, k): + shocks.append((frozenset(subset), q_k)) + return q_independent, shocks + + def __eq__(self, other: object) -> bool: + return isinstance(other, MGL) and other.letters == self.letters + + def __hash__(self) -> int: + return hash((type(self).__name__, self.letters)) + + def __repr__(self) -> str: + return f"MGL{self.letters}" + + class CCFGroup: """A common-cause group: member nodes coupled by a shared failure cause. @@ -59,9 +158,9 @@ class CCFGroup: The RBD nodes that share the common cause (at least two, distinct). Standard CCF theory is for *symmetric* groups, so the members should carry identical component models. - model : BetaFactor - The common-cause model coupling the members. (Alpha-factor / Multiple - Greek Letter are a planned extension.) + model : BetaFactor or MGL + The common-cause model coupling the members. An :class:`MGL` model + fixes the group size (``m - 1`` letters for ``m`` members). """ def __init__(self, members: Collection[Hashable], model: Any): @@ -74,10 +173,16 @@ def __init__(self, members: Collection[Hashable], model: Any): raise ValueError( f"CCF group members must be distinct, got {list(members)}." ) - if not isinstance(model, BetaFactor): + if not isinstance(model, (BetaFactor, MGL)): + raise ValueError( + "CCFGroup model must be a BetaFactor or MGL; alpha-factor is " + "not supported yet." + ) + required = model.required_group_size() + if required is not None and required != len(members): raise ValueError( - "CCFGroup model must be a BetaFactor; alpha-factor and " - "Multiple Greek Letter models are not supported yet." + f"{type(model).__name__} describes a group of {required} " + f"members, but this group has {len(members)}." ) self.members = members self.model = model diff --git a/repyability/rbd/non_repairable_rbd.py b/repyability/rbd/non_repairable_rbd.py index 39178b4..3541526 100644 --- a/repyability/rbd/non_repairable_rbd.py +++ b/repyability/rbd/non_repairable_rbd.py @@ -463,31 +463,50 @@ def _ccf_system_probability( "broken_nodes is not supported yet." ) - # Per-group failure probability Q(t), from a representative member - # (groups are symmetric). - group_Q = [ - 1.0 - np.atleast_1d(base_probabilities[g.members[0]]) - for g in self.ccf_groups - ] + # Each group's mutually-exclusive shock outcomes: (weight, {member: + # reliability}) for every subset that can fail together plus the + # no-shock case, from the model's decomposition of Q(t) (taken from a + # representative member, since groups are symmetric). + group_outcomes = [] + for group in self.ccf_groups: + Q = 1.0 - np.atleast_1d(base_probabilities[group.members[0]]) + q_independent, shocks = group.model.decompose(group.members, Q) + r_independent = 1.0 - q_independent + outcomes = [] + total_shock = np.zeros_like(Q) + for subset, prob in shocks: + total_shock = total_shock + prob + outcomes.append( + ( + prob, + { + member: ( + np.zeros_like(Q) + if member in subset + else r_independent + ) + for member in group.members + }, + ) + ) + # No common-cause shock: every member fails only independently. + outcomes.append( + ( + 1.0 - total_shock, + {member: r_independent for member in group.members}, + ) + ) + group_outcomes.append(outcomes) terms = [] - for combo in product((False, True), repeat=len(self.ccf_groups)): + for combo in product(*group_outcomes): node_probabilities = dict(base_probabilities) - weight = np.ones_like(group_Q[0]) - for group, Q, fired in zip(self.ccf_groups, group_Q, combo): - beta = group.model.beta - q_common = beta * Q - if fired: - weight = weight * q_common - for member in group.members: - node_probabilities[member] = np.zeros_like(Q) - else: - weight = weight * (1.0 - q_common) - r_independent = 1.0 - (1.0 - beta) * Q - for member in group.members: - node_probabilities[member] = r_independent + weight: Any = 1.0 + for outcome_weight, member_probs in combo: + weight = weight * outcome_weight + node_probabilities.update(member_probs) terms.append( - weight + np.asarray(weight) * np.asarray( self.system_probability(node_probabilities, method=method) ) diff --git a/repyability/rbd/serialisation.py b/repyability/rbd/serialisation.py index 00e8c21..b55d070 100644 --- a/repyability/rbd/serialisation.py +++ b/repyability/rbd/serialisation.py @@ -202,7 +202,7 @@ def _k_from_list(k_list): def _ccf_to_list(ccf_groups): - from repyability.rbd.ccf import BetaFactor + from repyability.rbd.ccf import MGL, BetaFactor if not ccf_groups: return None @@ -210,6 +210,8 @@ def _ccf_to_list(ccf_groups): for group in ccf_groups: if isinstance(group.model, BetaFactor): model = {"kind": "beta_factor", "beta": group.model.beta} + elif isinstance(group.model, MGL): + model = {"kind": "mgl", "letters": list(group.model.letters)} else: raise NotImplementedError( f"Cannot serialise CCF model {type(group.model).__name__}." @@ -219,7 +221,7 @@ def _ccf_to_list(ccf_groups): def _ccf_from_list(ccf_list): - from repyability.rbd.ccf import BetaFactor, CCFGroup + from repyability.rbd.ccf import MGL, BetaFactor, CCFGroup if not ccf_list: return None @@ -228,7 +230,9 @@ def _ccf_from_list(ccf_list): model_dict = entry["model"] kind = model_dict["kind"] if kind == "beta_factor": - model = BetaFactor(model_dict["beta"]) + model: object = BetaFactor(model_dict["beta"]) + elif kind == "mgl": + model = MGL(*model_dict["letters"]) else: raise ValueError(f"Unknown CCF model kind {kind!r}.") groups.append(CCFGroup(entry["members"], model)) diff --git a/repyability/tests/test_ccf.py b/repyability/tests/test_ccf.py index ae30d72..fbc8991 100644 --- a/repyability/tests/test_ccf.py +++ b/repyability/tests/test_ccf.py @@ -13,6 +13,7 @@ from surpyval import FixedEventProbability, Weibull from repyability import ( + MGL, BetaFactor, CCFGroup, NodeState, @@ -252,3 +253,71 @@ def test_group_validation_against_rbd(): CCFGroup(["a", "b"], BetaFactor(0.2)), ], ) + + +# -- Multiple Greek Letter (partial common cause) -------------------------- + + +def test_mgl_two_members_equals_beta_factor(): + # MGL(beta) on a two-member group is exactly the beta-factor model. + Q = 0.1 + bf = float(_parallel(Q, [CCFGroup(["a", "b"], BetaFactor(0.15))]).sf()) + mgl = float(_parallel(Q, [CCFGroup(["a", "b"], MGL(0.15))]).sf()) + assert mgl == pytest.approx(bf) + + +def test_mgl_1of3_matches_textbook(): + # A parallel triple (fails iff all three fail) with MGL(beta, gamma). The + # all-three-down cut set has leading-order contributions Q3 + 3*Q2*Q1 + + # Q1**3 from the MGL basic-event probabilities. + Q, beta, gamma = 0.1, 0.1, 0.3 + edges = [("s", n) for n in "abc"] + [(n, "t") for n in "abc"] + nodes = {n: FixedEventProbability.from_params(Q) for n in "abc"} + rbd = NonRepairableRBD( + edges, nodes, ccf_groups=[CCFGroup(list("abc"), MGL(beta, gamma))] + ) + q1 = (1 - beta) * Q + q2 = beta * (1 - gamma) / 2 * Q + q3 = beta * gamma * Q + textbook = q3 + 3 * q2 * q1 + q1**3 + assert 1 - float(rbd.sf()) == pytest.approx(textbook, abs=1e-4) + + +def test_mgl_gamma_one_is_all_or_nothing(): + # gamma = 1 forces every >=2 common cause to escalate to all 3, i.e. the + # group is all-or-nothing: identical to BetaFactor(beta) on the triple. + Q, beta = 0.1, 0.2 + edges = [("s", n) for n in "abc"] + [(n, "t") for n in "abc"] + nodes = {n: FixedEventProbability.from_params(Q) for n in "abc"} + mgl = NonRepairableRBD( + edges, nodes, ccf_groups=[CCFGroup(list("abc"), MGL(beta, 1.0))] + ) + beta_all = NonRepairableRBD( + edges, nodes, ccf_groups=[CCFGroup(list("abc"), BetaFactor(beta))] + ) + assert float(mgl.sf()) == pytest.approx(float(beta_all.sf())) + + +def test_mgl_serialisation_roundtrip(): + Q = 0.1 + edges = [("s", n) for n in "abc"] + [(n, "t") for n in "abc"] + nodes = {n: FixedEventProbability.from_params(Q) for n in "abc"} + rbd = NonRepairableRBD( + edges, nodes, ccf_groups=[CCFGroup(list("abc"), MGL(0.1, 0.3))] + ) + restored = NonRepairableRBD.from_json(rbd.to_json()) + assert restored.ccf_groups[0].model == MGL(0.1, 0.3) + assert float(restored.sf()) == pytest.approx(float(rbd.sf())) + + +def test_mgl_validation(): + with pytest.raises(ValueError, match="at least one"): + MGL() + with pytest.raises(ValueError, match=r"\[0, 1\]"): + MGL(0.1, 1.5) + assert MGL(0.1, 0.2).group_size == 3 + assert MGL(0.1) == MGL(0.1) + assert MGL(0.1, 0.2) != MGL(0.1, 0.3) + # An MGL model's parameter count must match the group size. + with pytest.raises(ValueError, match="members"): + CCFGroup(["a", "b"], MGL(0.1, 0.3)) # 2 letters -> needs 3 members