Jets: one shared assignment check for dense and fine levels

Move surface_link_velocity (pure host code) from lbm/curved_links.py to
body/jets.py, re-exported by curved_links, so refinement.py can reuse it
without importing PyCUDA. prepare_fine_boundary now runs the same
assignment validation as the steppers when jets are given, rejecting
jet-vs-SurfaceActuation overlap (root's C(12.25,12.25) R2.15 case) as
well as empty/overlapping jets. Test the overlap and a disjoint pair.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-15 08:35:12 +00:00
1 parent f7753be0e4
commit 038a759ff1
4 files changed
+70 -51

No files matched your search

+51
View File
@@ -11,6 +11,10 @@ One physical link (receiver, direction) may be driven by at most one jet.
Edge rule: the lateral bound |s| < 1 is exclusive (the profile vanishes
there for parabolic/quartic); Sector r_min/r_max and Tube 0 <= along <= length
are inclusive. Jets are immutable; an amplitude change replaces the Jet.
``surface_link_velocity`` is the single host-only assignment/validation point
for legacy apertures plus jets (no PyCUDA import), shared by dense and fine
levels.
"""
from __future__ import annotations
@@ -181,3 +185,50 @@ def fine_hits_to_edge_frame(hits_local, fine_origin, dx):
def edge_to_node_frame(points):
"""Coarse cell-edge frame -> Simulation integer-node frame (shift by -1/2)."""
return np.asarray(points, np.float64) - .5
def surface_link_velocity(objects, cfg, flags, fluid_idx, dir, body_id, rx, ry,
jets=(), hits=None):
"""Per-link prescribed surface velocity (n, 2) FP32 and aperture mask.
Host-only and storage-neutral: rows follow the given physical cut-link
order. Returns (None, None) when neither an object carries surface
actuation nor a jet is given. Jets need true FP64 ``hits`` (n, 2) and may
not share a link with each other or with a legacy aperture.
"""
enabled = [obj for obj in objects if obj.surface_actuation is not None]
if not enabled and not jets:
return None, None
from ..lbm.descriptors import FLUID, SOLID
if np.any((flags[fluid_idx] & FLUID) == 0) or np.any(flags[fluid_idx] & SOLID):
raise ValueError("Surface cut links must have active fluid receivers")
if len(np.unique(fluid_idx.astype(np.uint64)*cfg.nq+dir)) != len(dir):
raise ValueError("Surface boundary requires unique cut-link writers")
surface_velocity = np.zeros((len(dir), 2), np.float32)
surface_aperture = np.zeros(len(dir), bool)
for obj in enabled:
obj.validate_surface_actuation(cfg)
selected = body_id == obj.obj_id
velocity, aperture = obj.surface_actuation.project(rx[selected], ry[selected])
if not aperture.any():
raise ValueError("Surface aperture hits no resolved cut links")
surface_velocity[selected] = velocity
surface_aperture[selected] = aperture
if jets:
if hits is None or len(hits) != len(dir):
raise ValueError("Jets require true hit points row-aligned with the cut links")
if (cfg is None or not cfg.is_d2q9 or cfg.store_precision != "FP32"
or cfg.streaming != "double_buffer" or cfg.ddf_shifting):
raise ValueError("Jets require D2Q9/FP32/unshifted/double_buffer")
shape, jet_id = jet_link_shapes(jets, hits)
driven = jet_id >= 0
owners = {obj.obj_id: obj for obj in objects}
for bid in np.unique(body_id[driven]):
state = vars(owners[int(bid)].state)
if any(state.get(k, 0.) != 0 for k in ("vx", "vy", "omega")):
raise ValueError("Jets may only drive links of stationary bodies")
if np.any(surface_aperture & driven):
raise ValueError("Jet overlaps a SurfaceActuation aperture")
surface_velocity[driven] = jet_link_velocity(jets, shape, jet_id)[driven]
surface_aperture |= driven
return surface_velocity, surface_aperture