Host-only stdlib definition of the per-hop identity dM + Q_exact = C + H with Q_exact from pre-staging owner populations and H = sum over every annulus cell/direction of (pulled - ideal owner input), split into seam (internal) and edge parts. Synthetic tests: ideal pull, collision source, interior-seam and edge perturbations, the double count of staged flux + full H, a late owner snapshot hiding staging, and a wrong pull direction. No solver change, no GPU. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
76 lines
3.1 KiB
Python
76 lines
3.1 KiB
Python
"""Per-hop mass ledger of a fluid cell set A (host-only, stdlib, D2Q9 pull scheme).
|
|
|
|
Diagnostic definition only; no solver code, no GPU. Arrays are indexed [q][y][x] on a
|
|
periodic nx x ny lattice; A is a set of (x, y) ACTIVE cells.
|
|
|
|
O owner populations before any staging (canonical owner slots, start of the hop)
|
|
P populations each cell actually pulls: P[q](x) is what x reads for direction q
|
|
(after halo copy, BFL/coalesce staging, ...)
|
|
D populations written by the cell after collision
|
|
|
|
With M0 = sum_{A,q} O and M1 = sum_{A,q} D (both owner snapshots of A):
|
|
|
|
M1 - M0 + Q_exact = C + H (exact)
|
|
|
|
C = sum_{x in A, q} (D_q(x) - P_q(x)) collision source
|
|
H = sum_{x in A, q} (P_q(x) - O_q(x - c_q)) actual minus ideal owner input,
|
|
split into H_seam (x - c_q in A: internal pulls, e.g. tile seams) and
|
|
H_edge (x - c_q not in A: inputs crossing the edge of A)
|
|
Q_exact = sum_{y in A, y+c_q not in A} O_q(y) - sum_{y not in A, y+c_q in A} O_q(y)
|
|
owner-based net outward flux (outgoing minus incoming)
|
|
|
|
Proof: sum_{A,q} O_q(x - c_q) - M0 = -Q_exact (relabel y = x - c_q per direction), and
|
|
sum_{A,q} D = C + H + sum_{A,q} O_q(x - c_q). Q_exact must use O, not staged values: a
|
|
flux built from staged inputs already contains H_edge, so adding the full H counts it
|
|
twice (see staged_flux). The edge of A must not cross cut links, otherwise O at the solid
|
|
end enters Q_exact.
|
|
"""
|
|
|
|
CX = (0, 1, -1, 0, 0, 1, -1, 1, -1)
|
|
CY = (0, 0, 0, 1, -1, 1, -1, -1, 1)
|
|
|
|
|
|
def _src(x, y, q, nx, ny):
|
|
return (x - CX[q]) % nx, (y - CY[q]) % ny
|
|
|
|
|
|
def pull(O, nx, ny):
|
|
"""Ideal pulled input: P[q](x) = O[q](x - c_q)."""
|
|
return [[[O[q][_src(x, y, q, nx, ny)[1]][_src(x, y, q, nx, ny)[0]]
|
|
for x in range(nx)] for y in range(ny)] for q in range(9)]
|
|
|
|
|
|
def ledger(O, P, D, A, nx, ny):
|
|
A = set(A)
|
|
out = dict(dM=0.0, C=0.0, H_seam=0.0, H_edge=0.0, Q_exact=0.0)
|
|
for (x, y) in A:
|
|
for q in range(9):
|
|
out["dM"] += D[q][y][x] - O[q][y][x]
|
|
out["C"] += D[q][y][x] - P[q][y][x]
|
|
sx, sy = _src(x, y, q, nx, ny)
|
|
key = "H_seam" if (sx, sy) in A else "H_edge"
|
|
out[key] += P[q][y][x] - O[q][sy][sx]
|
|
if (sx, sy) not in A:
|
|
out["Q_exact"] -= O[q][sy][sx] # incoming owner population
|
|
tx, ty = (x + CX[q]) % nx, (y + CY[q]) % ny
|
|
if (tx, ty) not in A:
|
|
out["Q_exact"] += O[q][y][x] # outgoing owner population
|
|
out["H"] = out["H_seam"] + out["H_edge"]
|
|
out["residual"] = out["dM"] + out["Q_exact"] - out["C"] - out["H"]
|
|
return out
|
|
|
|
|
|
def staged_flux(O, P, A, nx, ny):
|
|
"""Outward flux with incoming terms from the staged inputs P (the wrong combination)."""
|
|
A = set(A)
|
|
q_out = 0.0
|
|
for (x, y) in A:
|
|
for q in range(9):
|
|
sx, sy = _src(x, y, q, nx, ny)
|
|
if (sx, sy) not in A:
|
|
q_out -= P[q][y][x]
|
|
tx, ty = (x + CX[q]) % nx, (y + CY[q]) % ny
|
|
if (tx, ty) not in A:
|
|
q_out += O[q][y][x]
|
|
return q_out
|