Jets: explicit cell-edge/node frame conversion, same-physical-hit test

Replace fine_hits_to_physical with fine_hits_to_edge_frame (patch
cell-edge frame, coarse centers at I+1/2, as FixedPatchStepper uses) and
edge_to_node_frame (Simulation integer-node frame, shift -1/2). Test one
physical circle through both frames: fine hits converted to node frame
lie on the Simulation circle and one node-frame Jet selects the same arc
on coarse and fine links; relabelling without conversion is detected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-10-15 08:23:28 +00:00
co-authored by Claude Opus 5.5
parent b1cd555b0f
commit da8bfb0d4a
2 changed files with 45 additions and 5 deletions
+13 -2
View File
@@ -156,6 +156,17 @@ def jet_link_velocity(jets, shape, jet_id):
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)."""
# Two coarse-unit frames coexist (see FixedPatchStepper docs): the patch
# cell-edge frame puts coarse cell I's center at I + 1/2, while Simulation
# geometry uses the integer-node frame with that center at I. Jets are
# evaluated in the frame their region/field were written in; convert hits
# explicitly, never by relabelling.
def fine_hits_to_edge_frame(hits_local, fine_origin, dx):
"""Fine-local cell-index hits -> coarse cell-edge frame: origin + dx*(hit + 1/2)."""
return np.asarray(fine_origin, np.float64) + dx*(np.asarray(hits_local, np.float64) + .5)
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
+32 -3
View File
@@ -6,7 +6,8 @@ import numpy as np
import pytest
from CelerisLab.body.jets import (Jet, Parallel, Radial, Sector, Tube,
fine_hits_to_physical, jet_link_shapes)
edge_to_node_frame, fine_hits_to_edge_frame,
jet_link_shapes)
from CelerisLab.body.objects import SurfaceActuation
from CelerisLab.lbm.curved_links import CurvedLinkSoA, surface_link_velocity
from .test_state_regressions import device, make_field, manager
@@ -85,8 +86,36 @@ def test_selection_uses_true_hit_not_clipped_q():
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)
edge = fine_hits_to_edge_frame([[0., 0.], [3., 1.5]], (4., 6.), .5)
np.testing.assert_array_equal(edge, [[4.25, 6.25], [5.75, 7.]])
np.testing.assert_array_equal(edge_to_node_frame(edge), [[3.75, 5.75], [5.25, 6.5]])
def test_same_physical_hit_in_simulation_and_patch_frames():
# One physical circle: Simulation node-frame centre C; patch edge-frame
# centre C + 1/2; fine-local centre (C + 1/2 - origin)/dx - 1/2 as in
# prepare_fine_boundary. Fine hits mapped edge -> node must lie on the
# Simulation circle, and one node-frame Jet selects the same arc on both.
from CelerisLab.body.geometry.circle import CircleGeometry
C, R, origin, dx = np.array([10.3, 9.6]), 2.15, np.array([4., 3.]), .5
local = (C + .5 - origin)/dx - .5
fine = CircleGeometry(*local, R/dx).build_cut_links(40, 40)
coarse = CircleGeometry(*C, R).build_cut_links(24, 20)
fine_hits = edge_to_node_frame(fine_hits_to_edge_frame(
[(l.hit_x, l.hit_y) for l in fine], origin, dx))
coarse_hits = np.array([(l.hit_x, l.hit_y) for l in coarse])
np.testing.assert_allclose(np.hypot(*(fine_hits - C).T), R, rtol=0, atol=1e-12)
np.testing.assert_allclose(np.hypot(*(coarse_hits - C).T), R, rtol=0, atol=1e-12)
jet = Jet(Sector(tuple(C), .4, .5, R + .01, R - .01), Radial(tuple(C), inward=False))
for hits in (fine_hits, coarse_hits):
shape, jet_id = jet_link_shapes([jet], hits)
d = hits - C
angle = np.arctan2(d[:, 1], d[:, 0])
assert np.array_equal(jet_id == 0, np.abs(angle - .4) < .5)
np.testing.assert_allclose(shape[jet_id == 0], d[jet_id == 0]/R, atol=1e-12)
# Relabelling instead of converting moves every hit off the circle.
wrong = fine_hits_to_edge_frame([(l.hit_x, l.hit_y) for l in fine], origin, dx)
assert np.abs(np.hypot(*(wrong - C).T) - R).max() > .3
def test_links_are_distinct_by_receiver_and_direction_and_overlap_rejected():