58 """bulk-111 (mobile) + bulk-100 (sessile), shared monomer pool on 111."""
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
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
91 dydt = gw.assemble(0.0, y)
93 assert abs(rate) < ATOL, f
"same-pop coalescence not conservative: {rate}"
98 """bulk-111 × bulk-111 → bulk-100 : Σn over BOTH pops must be flat."""
101 rag.add_edge(
Edge(EdgeClass.COALESCENCE,
"junction", p111,
102 kernel=
"Kii", product_population=p100))
104 gw =
GraphWalker(rag, layout, boundary=
"reflection")
106 y[0], y[1], y[2] = 1.0, 0.7, 0.4
107 dydt = gw.assemble(0.0, y)
110 assert abs(total) < ATOL, f
"junction not globally conservative: {total}"
112 assert only111 < -ATOL, f
"expected net 111 loss, got {only111}"
113 return total, only111
134 """Redirecting the product to bulk-100 must move ONLY the gain.
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,
140 d111(self) == d111(junction)[pure loss] + d100(junction)[pure gain]
142 so the redirect is content-preserving relative to the un-redirected edge.
145 K = np.full((N, N), 0.1)
146 y111 = np.array([1.0, 0.7, 0.4] + [0.0] * (N - 3))
150 ragA.add_edge(
Edge(EdgeClass.COALESCENCE,
"self", p111A, kernel=
"Kii"))
152 dA =
GraphWalker(ragA, layA, boundary=
"reflection").assemble(
153 0.0, np.concatenate([y111, np.zeros(N)]))
157 ragC.add_edge(
Edge(EdgeClass.COALESCENCE,
"junction", p111C,
158 kernel=
"Kii", product_population=p100C))
160 dC =
GraphWalker(ragC, layC, boundary=
"reflection").assemble(
161 0.0, np.concatenate([y111, np.zeros(N)]))
163 d111_A = dA[layA.slice(
"SIA111")]
164 d111_C = dC[layC.slice(
"SIA111")]
165 d100_C = dC[layC.slice(
"SIA100")]
166 recon = d111_C + d100_C
167 assert np.allclose(d111_A, recon, atol=ATOL), \
168 f
"redirect changed the 111 loss:\n A={d111_A}\n recon={recon}"
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)))
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))
180 Edge(EdgeClass.COALESCENCE,
"bad", sia, kernel=
"Kii", product_population=vac)
183 assert raised,
"opposite-polarity COALESCENCE product_population not rejected"
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),
197 for name, fn
in tests:
200 print(f
" PASS {name} {out!r}")
201 except AssertionError
as e:
203 print(f
" FAIL {name} {e}")
204 print(f
"\n{len(tests) - fails}/{len(tests)} passed")
205 return 1
if fails
else 0