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>
135 lines
6.0 KiB
Python
135 lines
6.0 KiB
Python
"""Body-independent Jet: true-hit spatial selection, fields, profiles, overlap, amplitude."""
|
|
import math
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from CelerisLab.body.jets import (Jet, Parallel, Radial, Sector, Tube,
|
|
fine_hits_to_physical, jet_link_shapes)
|
|
from CelerisLab.body.objects import SurfaceActuation
|
|
from CelerisLab.lbm.curved_links import CurvedLinkSoA, surface_link_velocity
|
|
from .test_surface_actuation import circle, projected
|
|
|
|
CENTER, RADIUS = (6., 5.), 2.999
|
|
|
|
|
|
def scene(spec=None):
|
|
"""Single circle links plus their true FP64 hits, row-aligned with the SoA."""
|
|
obj = circle(spec)
|
|
cl, cfg, flags = projected(obj)
|
|
links = obj.geometry.build_cut_links(cfg.nx, cfg.ny, domain_flags=flags)
|
|
hits = np.array([(link.hit_x, link.hit_y) for link in links])
|
|
assert len(hits) == len(cl.dir)
|
|
return obj, cl, cfg, flags, hits
|
|
|
|
|
|
def arc(hits, angle, half_width):
|
|
d = hits - CENTER
|
|
delta = np.angle(np.exp(1j*(np.arctan2(d[:, 1], d[:, 0]) - angle)))
|
|
return np.abs(delta) < half_width
|
|
|
|
|
|
def test_concentric_sector_gives_inward_radial_and_tube_parallel_on_same_links():
|
|
*_, hits = scene()
|
|
half = math.radians(10)
|
|
expected = arc(hits, 0., half)
|
|
assert expected.sum() >= 2
|
|
sector = Jet(Sector(CENTER, 0., half, RADIUS + .1), Radial(CENTER), "uniform", 1e-3)
|
|
shape, jet_id = jet_link_shapes([sector], hits)
|
|
assert np.array_equal(jet_id >= 0, expected)
|
|
d = hits[expected] - CENTER
|
|
np.testing.assert_allclose(shape[expected], -d/np.hypot(*d.T)[:, None], atol=1e-15)
|
|
# Tube along +x through the same arc: lateral |y-5| < R sin(10 deg).
|
|
tube = Jet(Tube((CENTER[0], CENTER[1]), 0., RADIUS + .1, RADIUS*math.sin(half)),
|
|
Parallel(math.pi), "uniform", 1e-3)
|
|
shape_t, id_t = jet_link_shapes([tube], hits)
|
|
assert np.array_equal(id_t >= 0, expected)
|
|
np.testing.assert_allclose(shape_t[expected], [[-1., 0.]]*expected.sum(), atol=1e-15)
|
|
|
|
|
|
def test_off_centre_radial_points_to_field_origin_not_body_centre():
|
|
*_, hits = scene()
|
|
origin = (12., 9.)
|
|
jet = Jet(Sector(CENTER, .1, math.pi, 10.), Radial(origin, inward=False))
|
|
shape, jet_id = jet_link_shapes([jet], hits)
|
|
assert np.all(jet_id == 0)
|
|
d = hits - origin
|
|
np.testing.assert_allclose(shape, d/np.hypot(*d.T)[:, None], atol=1e-15)
|
|
assert not np.allclose(shape, (hits - CENTER)/RADIUS, atol=1e-3)
|
|
|
|
|
|
@pytest.mark.parametrize("profile,expected", [("uniform", 1.), ("parabolic", .75), ("quartic", .5625)])
|
|
def test_profiles_on_lateral_coordinate(profile, expected):
|
|
hits = np.array([[1., .5]])
|
|
jet = Jet(Tube((0., 0.), 0., 2., 1.), Parallel(0.), profile)
|
|
shape, _ = jet_link_shapes([jet], hits)
|
|
np.testing.assert_allclose(shape[0], [expected, 0.], rtol=1e-15)
|
|
|
|
|
|
def test_selection_uses_true_hit_not_clipped_q():
|
|
# Same link as test_aperture_uses_true_hit_not_fluid_node_or_clamped_q:
|
|
# receiver (9,5), direction (-1,+1); true t < .01 but packed q == .01.
|
|
t = (6-math.sqrt(36-8*(9-2.999**2)))/4
|
|
angle = math.atan2(t, 3-t)
|
|
_, cl, _, _, hits = scene()
|
|
row = np.flatnonzero((cl.fluid_idx == 5*12 + 9) & np.isclose(cl.q, .01))
|
|
assert len(row) == 1
|
|
clipped = np.array([9., 5.]) + .01*np.array([-1., 1.])
|
|
jet = Jet(Sector(CENTER, angle, 1e-4, RADIUS + .1), Radial(CENTER))
|
|
_, jet_id = jet_link_shapes([jet], hits)
|
|
assert jet_id[row[0]] == 0
|
|
with pytest.raises(ValueError, match="selects no"):
|
|
jet_link_shapes([jet], clipped[None, :])
|
|
|
|
|
|
def test_fine_half_cell_frame():
|
|
np.testing.assert_allclose(fine_hits_to_physical([[0., 0.], [3., 1.5]], (4., 6.), .5),
|
|
[[4.25, 6.25], [5.75, 7.]], rtol=0, atol=0)
|
|
|
|
|
|
def test_links_are_distinct_by_receiver_and_direction_and_overlap_rejected():
|
|
# Two physical links (different receiver/direction) sharing one hit point
|
|
# are two rows, both driven; two jets on one link are rejected.
|
|
hits = np.array([[1., 1.], [1., 1.]])
|
|
a = Jet(Tube((0., 1.), 0., 2., .5), Parallel(0.))
|
|
shape, jet_id = jet_link_shapes([a], hits)
|
|
assert jet_id.tolist() == [0, 0]
|
|
b = Jet(Sector((1., 0.), math.pi/2, .1, 2.), Parallel(0.))
|
|
with pytest.raises(ValueError, match="overlaps"):
|
|
jet_link_shapes([a, b], hits)
|
|
obj, cl, cfg, flags, real = scene(SurfaceActuation(0., .3, .002))
|
|
with pytest.raises(ValueError, match="SurfaceActuation"):
|
|
surface_link_velocity([obj], cfg, flags, cl.fluid_idx, cl.dir, cl.body_id, cl.rx, cl.ry,
|
|
jets=[Jet(Sector(CENTER, 0., .1, 4.), Radial(CENTER))], hits=real)
|
|
with pytest.raises(ValueError, match="row-aligned"):
|
|
surface_link_velocity([], cfg, flags, cl.fluid_idx, cl.dir, cl.body_id, cl.rx, cl.ry,
|
|
jets=[a], hits=real[:-1])
|
|
|
|
|
|
def test_amplitude_update_reuses_cached_shapes():
|
|
obj, cl, cfg, flags, hits = scene()
|
|
jet = Jet(Sector(CENTER, 0., .5, 4.), Radial(CENTER), "parabolic", 1e-3)
|
|
cl.assign_surface([obj], cfg, flags, jets=[jet], hits=hits)
|
|
shape, ids = cl.jet_shape, cl.jet_id
|
|
before = cl.surface_velocity.copy()
|
|
cl.set_jet_amplitude(0, -2e-3)
|
|
assert cl.jet_shape is shape and cl.jet_id is ids
|
|
np.testing.assert_allclose(cl.surface_velocity, -2*before, rtol=1e-6, atol=0)
|
|
assert np.array_equal(cl.aperture, ids >= 0)
|
|
with pytest.raises(ValueError):
|
|
cl.set_jet_amplitude(0, 1.5)
|
|
assert jet.amplitude == -2e-3
|
|
|
|
|
|
@pytest.mark.parametrize("spec", [SurfaceActuation(0., .6, .002), SurfaceActuation(2., 1.1, -.003)])
|
|
def test_legacy_adapter_measured_equivalence(spec):
|
|
obj, legacy, cfg, flags, hits = scene(spec)
|
|
cl = CurvedLinkSoA()
|
|
cl.assign_host(legacy.fluid_idx, legacy.dir, legacy.q, legacy.body_id,
|
|
legacy.rx, legacy.ry, legacy.rz, legacy.fallback_class)
|
|
cl.assign_surface([circle()], cfg, flags, jets=[spec.as_jet(CENTER, RADIUS)], hits=hits)
|
|
assert np.array_equal(cl.aperture, legacy.aperture)
|
|
diff = np.abs(cl.surface_velocity - legacy.surface_velocity).max()
|
|
print(f"legacy adapter max |dU| = {diff:.3e} (exact={diff == 0})")
|
|
assert diff <= 4*np.finfo(np.float32).eps*abs(spec.normal_velocity)
|