RadCluster
Graph-based cluster dynamics for irradiated materials
Loading...
Searching...
No Matches
check_coalescence_product_population.py
Go to the documentation of this file.
1"""
2check_coalescence_product_population.py — Phase-1 unit tests for the
3cross-population COALESCENCE extension (½⟨111⟩ → ⟨100⟩ loop-conversion work).
4
5Covers the Layer-1 core change in `core/{edge_classes,rag,graph_walker}.py`:
6
7 1. Same-population coalescence conserves signed-defect content
8 (regression for the factor-2 gain fix in `_c_coalescence`).
9 2. Cross-population *junction* coalescence bulk-111 × bulk-111 → bulk-100
10 conserves TOTAL SIA content summed over both populations.
11 3. Cross-population *absorption* coalescence bulk-100 × bulk-111 → bulk-100
12 (partner ≠ population, product = population) conserves content.
13 4. Splitting one coalescence edge into φ·(→bulk-100) + (1−φ)·(→bulk-111)
14 reproduces the single-edge total content flux (the φ-split is conservative).
15 5. A COALESCENCE product_population of the *wrong* polarity is rejected.
16
17Run: python codes/Python_Testing/check_coalescence_product_population.py
18Exit code 0 on success, 1 on any failure. Also importable as pytest tests.
19"""
20import os
21import sys
22
23import numpy as np
24
25sys.path.insert(0, os.path.abspath(
26 os.path.join(os.path.dirname(__file__), "..", "..")))
27
28from py_utils.core.cluster_identifier import Polarity, Population
29from py_utils.core.rag import ReactionAdmissibilityGraph, Edge
30from py_utils.core.edge_classes import EdgeClass
31from py_utils.core.state_layout import StateLayout
32from py_utils.core.graph_walker import GraphWalker
33
34ATOL = 1e-12
35
36
37# ── helpers ───────────────────────────────────────────────────────────────────
38def _content_rate(dydt, layout, *blocks):
39 """d/dt of the signed-defect content Σ_n n·c_n summed over named blocks."""
40 total = 0.0
41 for name in blocks:
42 sl = layout.slice(name)
43 seg = dydt[sl]
44 sizes = np.arange(1, seg.size + 1)
45 total += float(np.dot(sizes, seg))
46 return total
47
48
49def _one_pop_rag(N, kernel):
50 rag = ReactionAdmissibilityGraph("one_pop")
51 sia = rag.add_population(Population("bulk", Polarity.SIA, n_min=1, mobile_max=N))
52 rag.set_monomer_population(sia)
53 rag.register_kernel("Kii", kernel)
54 return rag, sia
55
56
57def _two_pop_rag(N, kernel):
58 """bulk-111 (mobile) + bulk-100 (sessile), shared monomer pool on 111."""
59 rag = ReactionAdmissibilityGraph("two_pop")
60 p111 = rag.add_population(Population("bulk-111", Polarity.SIA, n_min=1, mobile_max=N))
61 p100 = rag.add_population(Population("bulk-100", Polarity.SIA, n_min=1, mobile_max=0))
62 rag.set_monomer_population(p111)
63 rag.register_kernel("Kii", kernel)
64 return rag, p111, p100
65
66
67def _layout_two(N, p111, p100):
68 layout = StateLayout()
69 layout.add_discrete("SIA111", N, population=p111)
70 layout.add_discrete("SIA100", N, population=p100)
71 layout.freeze()
72 return layout
73
74
75# Initial state with only small sizes populated, so every coalescence product
76# fits inside the size axis (no over-the-top loss) and content is exactly
77# conserved by an ideal accumulator.
78def _small_state(N, *segments):
79 y = np.zeros(N * len(segments))
80 return y
81
82
83# ── tests ─────────────────────────────────────────────────────────────────────
85 N = 8
86 rag, sia = _one_pop_rag(N, np.full((N, N), 0.1))
87 rag.add_edge(Edge(EdgeClass.COALESCENCE, "self", sia, kernel="Kii"))
88 layout = StateLayout(); layout.add_discrete("SIA", N, population=sia); layout.freeze()
89 gw = GraphWalker(rag, layout, boundary="reflection")
90 y = np.zeros(N); y[0], y[1], y[2] = 1.0, 0.7, 0.4 # max product 6 < N
91 dydt = gw.assemble(0.0, y)
92 rate = _content_rate(dydt, layout, "SIA")
93 assert abs(rate) < ATOL, f"same-pop coalescence not conservative: {rate}"
94 return rate
95
96
98 """bulk-111 × bulk-111 → bulk-100 : Σn over BOTH pops must be flat."""
99 N = 8
100 rag, p111, p100 = _two_pop_rag(N, np.full((N, N), 0.1))
101 rag.add_edge(Edge(EdgeClass.COALESCENCE, "junction", p111,
102 kernel="Kii", product_population=p100))
103 layout = _layout_two(N, p111, p100)
104 gw = GraphWalker(rag, layout, boundary="reflection")
105 y = np.zeros(2 * N)
106 y[0], y[1], y[2] = 1.0, 0.7, 0.4 # 111 sizes 1..3
107 dydt = gw.assemble(0.0, y)
108 total = _content_rate(dydt, layout, "SIA111", "SIA100")
109 only111 = _content_rate(dydt, layout, "SIA111")
110 assert abs(total) < ATOL, f"junction not globally conservative: {total}"
111 # 111 must strictly lose content (it is the only source); 100 must gain it.
112 assert only111 < -ATOL, f"expected net 111 loss, got {only111}"
113 return total, only111
114
115
117 """bulk-100 × bulk-111 → bulk-100 : product = population (no redirect)."""
118 N = 8
119 rag, p111, p100 = _two_pop_rag(N, np.full((N, N), 0.1))
120 rag.add_edge(Edge(EdgeClass.COALESCENCE, "absorb", p100,
121 kernel="Kii", partner_population=p111))
122 layout = _layout_two(N, p111, p100)
123 gw = GraphWalker(rag, layout, boundary="reflection")
124 y = np.zeros(2 * N)
125 y[0], y[1] = 0.6, 0.3 # 111 sizes 1,2 (mobile partner)
126 y[N + 2] = 0.5 # 100 size 3
127 dydt = gw.assemble(0.0, y)
128 total = _content_rate(dydt, layout, "SIA111", "SIA100")
129 assert abs(total) < ATOL, f"absorption not conservative: {total}"
130 return total
131
132
134 """Redirecting the product to bulk-100 must move ONLY the gain.
135
136 With the same kernel and reactants, a self-coalescence (product → 111)
137 and a junction (product → 100) consume 111 identically; they differ only
138 in where the product is deposited. Hence, element-wise,
139
140 d111(self) == d111(junction)[pure loss] + d100(junction)[pure gain]
141
142 so the redirect is content-preserving relative to the un-redirected edge.
143 """
144 N = 8
145 K = np.full((N, N), 0.1)
146 y111 = np.array([1.0, 0.7, 0.4] + [0.0] * (N - 3))
147
148 # A: self-coalescence, product back to 111 (loss + gain mixed on 111).
149 ragA, p111A, p100A = _two_pop_rag(N, K)
150 ragA.add_edge(Edge(EdgeClass.COALESCENCE, "self", p111A, kernel="Kii"))
151 layA = _layout_two(N, p111A, p100A)
152 dA = GraphWalker(ragA, layA, boundary="reflection").assemble(
153 0.0, np.concatenate([y111, np.zeros(N)]))
154
155 # C: junction, same K, product → 100 (pure loss on 111, pure gain on 100).
156 ragC, p111C, p100C = _two_pop_rag(N, K)
157 ragC.add_edge(Edge(EdgeClass.COALESCENCE, "junction", p111C,
158 kernel="Kii", product_population=p100C))
159 layC = _layout_two(N, p111C, p100C)
160 dC = GraphWalker(ragC, layC, boundary="reflection").assemble(
161 0.0, np.concatenate([y111, np.zeros(N)]))
162
163 d111_A = dA[layA.slice("SIA111")]
164 d111_C = dC[layC.slice("SIA111")] # pure 111 reactant loss
165 d100_C = dC[layC.slice("SIA100")] # pure product gain
166 recon = d111_C + d100_C # gain re-added at product sizes
167 assert np.allclose(d111_A, recon, atol=ATOL), \
168 f"redirect changed the 111 loss:\n A={d111_A}\n recon={recon}"
169 # Sanity: junction's 111 channel is pure loss (≤ 0 everywhere).
170 assert np.all(d111_C <= ATOL), f"junction 111 channel not pure-loss: {d111_C}"
171 return float(np.max(np.abs(d111_A - recon)))
172
173
175 rag = ReactionAdmissibilityGraph("bad")
176 sia = rag.add_population(Population("bulk", Polarity.SIA, n_min=1, mobile_max=4))
177 vac = rag.add_population(Population("bulk", Polarity.VACANCY, n_min=1, mobile_max=4))
178 raised = False
179 try:
180 Edge(EdgeClass.COALESCENCE, "bad", sia, kernel="Kii", product_population=vac)
181 except ValueError:
182 raised = True
183 assert raised, "opposite-polarity COALESCENCE product_population not rejected"
184 return raised
185
186
187# ── runner ────────────────────────────────────────────────────────────────────
188def main():
189 tests = [
190 ("same-pop content conserved (factor-2 fix)", test_same_population_conserves_content),
191 ("junction globally conservative", test_junction_conserves_total_content),
192 ("absorption conservative", test_absorption_conserves_content),
193 ("redirect moves only the gain", test_product_redirect_moves_only_gain),
194 ("wrong-polarity product rejected", test_wrong_polarity_product_rejected),
195 ]
196 fails = 0
197 for name, fn in tests:
198 try:
199 out = fn()
200 print(f" PASS {name} {out!r}")
201 except AssertionError as e:
202 fails += 1
203 print(f" FAIL {name} {e}")
204 print(f"\n{len(tests) - fails}/{len(tests)} passed")
205 return 1 if fails else 0
206
207
208if __name__ == "__main__":
209 sys.exit(main())