Add body-independent Jet: true-hit spatial selection, fields, profiles

Jet(region, field, profile, amplitude) with Sector/Tube regions,
Radial/Parallel fields and uniform/parabolic/quartic profiles. Selection
uses true FP64 hit points over all scene cut links, no body association.
Overlap between jets or with a SurfaceActuation aperture is rejected.
CurvedLinkSoA caches unit-amplitude shapes; set_jet_amplitude rewrites
and re-uploads only the surface-velocity column. SurfaceActuation.as_jet
is the legacy adapter (equivalence measured in tests). Kernel and SoA
geometry columns unchanged. Manager/Simulation/fine-path wiring pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-15 08:10:31 +00:00
1 parent ca5a145107
commit 611a64c54a
5 files changed
+343 -6

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
# CelerisLab/body/jets.py
"""Body-independent jets: spatial region + direction field + profile + amplitude.
A Jet selects cut links purely by the true wall hit point (FP64, before SoA
packing), over every link in the scene; it carries no body association.
Per-link velocity = amplitude * profile(s) * field(hit), where ``s`` in [-1, 1]
is the region's lateral coordinate. Velocities are lattice units of the level
whose links are evaluated; callers convert frames/units before evaluation.
One physical link (receiver, direction) may be driven by at most one jet.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
PROFILES = {
"uniform": lambda s: np.ones_like(s),
"parabolic": lambda s: 1 - s*s,
"quartic": lambda s: (1 - s*s)**2,
}
def _finite(*values):
if not all(math.isfinite(v) for v in values):
raise ValueError("Jet parameters must be finite")
@dataclass(frozen=True)
class Sector:
"""Annular sector about ``origin``: |angle offset| < half_width, r_min <= r <= r_max."""
origin: tuple
angle: float
half_width: float
r_max: float
r_min: float = 0.0
def __post_init__(self):
_finite(*self.origin, self.angle, self.half_width, self.r_max, self.r_min)
if not (0 < self.half_width <= math.pi and 0 <= self.r_min <= self.r_max):
raise ValueError("Sector requires 0 < half_width <= pi and 0 <= r_min <= r_max")
def select(self, hits):
d = hits - np.asarray(self.origin, np.float64)
r = np.hypot(d[:, 0], d[:, 1])
delta = np.angle(np.exp(1j*(np.arctan2(d[:, 1], d[:, 0]) - self.angle)))
return (np.abs(delta) < self.half_width) & (r >= self.r_min) & (r <= self.r_max), \
delta/self.half_width
@dataclass(frozen=True)
class Tube:
"""Rectangle from ``origin`` along ``axis_angle`` for ``length``, |lateral| < half_width."""
origin: tuple
axis_angle: float
length: float
half_width: float
def __post_init__(self):
_finite(*self.origin, self.axis_angle, self.length, self.half_width)
if self.length <= 0 or self.half_width <= 0:
raise ValueError("Tube requires positive length and half_width")
def select(self, hits):
d = hits - np.asarray(self.origin, np.float64)
c, s = math.cos(self.axis_angle), math.sin(self.axis_angle)
along, lateral = d[:, 0]*c + d[:, 1]*s, -d[:, 0]*s + d[:, 1]*c
return (along >= 0) & (along <= self.length) & (np.abs(lateral) < self.half_width), \
lateral/self.half_width
@dataclass(frozen=True)
class Radial:
"""Unit vector along hit - origin (outward) or its reverse (inward)."""
origin: tuple
inward: bool = True
def __post_init__(self):
_finite(*self.origin)
def direction(self, hits):
d = hits - np.asarray(self.origin, np.float64)
r = np.hypot(d[:, 0], d[:, 1])[:, None]
if np.any(r <= 0):
raise ValueError("Radial field undefined at its origin")
return (-d if self.inward else d)/r
@dataclass(frozen=True)
class Parallel:
"""Uniform unit vector at ``angle`` (radians, counterclockwise from +x)."""
angle: float
def __post_init__(self):
_finite(self.angle)
def direction(self, hits):
return np.broadcast_to([math.cos(self.angle), math.sin(self.angle)], hits.shape)
@dataclass
class Jet:
"""Region (Sector|Tube), field (Radial|Parallel), profile name, signed amplitude.
Only ``amplitude`` may change after registration; it never rebuilds links.
"""
region: object
field: object
profile: str = "uniform"
amplitude: float = 0.0
def __post_init__(self):
if self.profile not in PROFILES:
raise ValueError(f"Jet profile must be one of {sorted(PROFILES)}")
_finite(self.amplitude)
if abs(self.amplitude) >= 1:
raise ValueError("Jet amplitude must satisfy abs(amplitude) < 1 (lattice units)")
def jet_link_shapes(jets, hits):
"""Unit-amplitude per-link vectors (n, 2) FP64 and jet index (n,) int32 (-1 = none).
``hits`` are true FP64 wall hit points (n, 2) row-aligned with the packed
links. Raises if a jet selects nothing or two jets select the same link.
"""
hits = np.asarray(hits, np.float64).reshape(-1, 2)
shape = np.zeros_like(hits)
jet_id = np.full(len(hits), -1, np.int32)
for j, jet in enumerate(jets):
selected, s = jet.region.select(hits)
if not selected.any():
raise ValueError(f"Jet {j} selects no resolved cut links")
if np.any(jet_id[selected] >= 0):
raise ValueError(f"Jet {j} overlaps links already driven by another jet")
jet_id[selected] = j
shape[selected] = (PROFILES[jet.profile](s[selected])[:, None]
* jet.field.direction(hits[selected]))
return shape, jet_id
def jet_link_velocity(jets, shape, jet_id):
"""FP32 (n, 2) velocity = amplitude[jet_id] * shape; O(n), no geometry work."""
amplitude = np.array([jet.amplitude for jet in jets] + [0.0], np.float64)
return (amplitude[jet_id][:, None]*shape).astype(np.float32)
def fine_hits_to_physical(hits_local, fine_origin, dx):
"""Fine-local cell-index hits -> physical frame: origin + dx*(hit + 1/2)."""
return np.asarray(fine_origin, np.float64) + dx*(np.asarray(hits_local, np.float64) + .5)