"""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