RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
cluster_identifier.py
Go to the documentation of this file.
1"""
2core/cluster_identifier.py — Abstract cluster state-space primitives (Layer 1).
3
4Host-independent. Implements the indexed cluster state space of
5Ghoniem (2026), *A Generalized Graph-Based Cluster Dynamics Framework
6for Irradiated Materials*, Section 2.1.
7
8A cluster identifier is the four-tuple
9
10 sigma = (chi, n, p, c)
11
12 chi : polarity index in {-1, +1} (-1 vacancy-type, +1 SIA-type)
13 n : size >= 1, the count of host Frenkel-pair sites of that polarity
14 carried by the cluster
15 p : population label (a :class:`Population`)
16 c : composition vector, the counts of trapped non-host species
17 (solutes / gases), e.g. (c_He, c_H)
18
19The polarity index classifies a cluster by the sign of its net
20Frenkel-pair content with respect to the host lattice. It is unaffected
21by solute trapping: a helium-vacancy cluster He_j V_n retains chi = -1
22regardless of j.
23
24Nothing here is host-specific. A host material declares *which*
25populations and *which* (chi, n, p, c) vertices are admissible; this
26module only provides the vocabulary.
27"""
28from __future__ import annotations
29
30from dataclasses import dataclass, replace
31from enum import IntEnum
32from typing import Tuple
33
34
35class Polarity(IntEnum):
36 """Sign of a cluster's net Frenkel-pair content w.r.t. the host lattice.
37
38 The polarity index splits the state space into two sub-spaces without
39 a host-specific taxonomy. It also serves as the *species-class*
40 discriminator of the paper (C_V for vacancy-type, C_I for SIA-type).
41 """
42
43 VACANCY = -1
44 SIA = +1
45
46 @property
47 def label(self) -> str:
48 return "vacancy" if self is Polarity.VACANCY else "sia"
49
50 @property
51 def signed_defect(self) -> int:
52 """Signed lattice-defect content per unit size (-1 vacancy, +1 SIA)."""
53 return int(self.value)
54
55 @property
56 def opposite(self) -> "Polarity":
57 return Polarity.SIA if self is Polarity.VACANCY else Polarity.VACANCY
58
59
60@dataclass(frozen=True, order=True)
62 """A defect population within one polarity layer (paper Section 2.1/3.1).
63
64 A population is a subcollection of clusters of the same polarity that
65 share a mobility law, orientation state, microstructural environment,
66 or sink coupling. Populations are physically distinct *phases* of the
67 cluster (different binding energies, mobility, energetic stability),
68 not mere spatial coordinates; transport between populations is itself
69 a CD reaction (an inter-population edge).
70
71 The host material declares its population set, e.g.
72 vacancy: {bulk, GB, disl, ppt}
73 bcc SIA: {bulk-111, bulk-100, disl-trapped}
74 The abstract core never enumerates populations itself.
75 """
76
77 name: str
78 polarity: Polarity
79 n_min: int = 1 # minimum admissible size for this population
80 mobile_max: int = 1 # largest size that is mobile (>= 0)
81
82 def __post_init__(self) -> None:
83 if not self.name:
84 raise ValueError("Population.name must be non-empty")
85 if self.n_min < 1:
86 raise ValueError(
87 f"Population {self.name!r}: n_min must be >= 1 (got {self.n_min})")
88 if self.mobile_max < 0:
89 raise ValueError(
90 f"Population {self.name!r}: mobile_max must be >= 0 "
91 f"(got {self.mobile_max})")
92
93 def admits_size(self, n: int) -> bool:
94 """True if size ``n`` is admissible for this population."""
95 return n >= self.n_min
96
97 def is_mobile(self, n: int) -> bool:
98 """True if a size-``n`` cluster of this population is mobile."""
99 return self.n_min <= n <= self.mobile_max
100
101
102@dataclass(frozen=True)
104 """The four-tuple sigma = (chi, n, p, c) identifying one cluster vertex.
105
106 Instances are immutable and hashable, so they can be used directly as
107 keys of the reaction admissibility graph's vertex set.
108 """
109
110 polarity: Polarity
111 size: int
112 population: Population
113 composition: Tuple[int, ...] = ()
114
115 def __post_init__(self) -> None:
116 if self.population.polarity is not self.polarity:
117 raise ValueError(
118 f"polarity {self.polarity.label!r} inconsistent with "
119 f"population {self.population.name!r} "
120 f"(polarity {self.population.polarity.label!r})")
121 if self.sizesize < 1:
122 raise ValueError(f"cluster size must be >= 1 (got {self.size})")
123 if any(int(x) < 0 for x in self.compositioncomposition):
124 raise ValueError(
125 f"composition counts must be >= 0 (got {self.composition})")
126
127 # ── derived quantities ────────────────────────────────────────────────
128 @property
129 def is_monomer(self) -> bool:
130 """True for a bare point-defect monomer (size 1, no trapped solute)."""
131 return self.sizesize == 1 and not any(self.compositioncomposition)
132
133 @property
134 def signed_defect(self) -> int:
135 """Signed lattice-defect content q(sigma) = chi * n (paper Eq. 49).
136
137 Used by the host conservation laws. A trapped gas atom carries
138 zero signed defect, so the composition does not enter here.
139 """
140 return self.polarity.signed_defect * self.sizesize
141
142 @property
143 def total_solute(self) -> int:
144 """Total number of trapped non-host atoms across all species."""
145 return sum(int(x) for x in self.compositioncomposition)
146
147 # ── constructors that derive a neighbour vertex ───────────────────────
148 def with_size(self, n: int) -> "ClusterIdentifier":
149 """Return the same identifier at a different size ``n``."""
150 return replace(self, size=n)
151
152 def with_composition(self, c) -> "ClusterIdentifier":
153 """Return the same identifier with a different composition vector."""
154 return replace(self, composition=tuple(int(x) for x in c))
155
156 def with_population(self, p: Population) -> "ClusterIdentifier":
157 """Return the same cluster moved to a different population ``p``."""
158 if p.polarity is not self.polarity:
159 raise ValueError(
160 "cannot move a cluster to a population of opposite polarity")
161 return replace(self, population=p)
162
163 def __repr__(self) -> str: # compact, e.g. <sia n=4 bulk-111 c=()>
164 return (f"<{self.polarity.label} n={self.size} "
165 f"{self.population.name} c={self.composition}>")
"ClusterIdentifier" with_population(self, Population p)