Jet review fixes: frozen Jet, validated amplitude replacement, test selector

Jet is a frozen dataclass; with_amplitude returns a validated copy, so
region/field/profile cannot drift from the cached per-link shapes and
amplitude cannot bypass validation. CurvedLinkSoA.set_jet_amplitude and
Simulation.set_jet_amplitude replace the Jet (Simulation also updates the
registered list so later geometry syncs keep the amplitude). Reject
non-finite hits; document edge rule (|s|<1 exclusive, radial/along
bounds inclusive). Fix the clipped-q test selector to receiver (9,5)
dir 8, as in test_surface_actuation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-10-15 08:18:29 +00:00
co-authored by Claude Opus 5.5
parent 932702f779
commit b1cd555b0f
4 changed files with 37 additions and 15 deletions
+13 -3
View File
@@ -7,12 +7,16 @@ 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 is the region's lateral coordinate. Velocities are lattice units of the level
whose links are evaluated; callers convert frames/units before evaluation. whose links are evaluated; callers convert frames/units before evaluation.
One physical link (receiver, direction) may be driven by at most one jet. 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.
""" """
from __future__ import annotations from __future__ import annotations
import math import math
from dataclasses import dataclass from dataclasses import dataclass, replace
import numpy as np import numpy as np
@@ -100,11 +104,12 @@ class Parallel:
return np.broadcast_to([math.cos(self.angle), math.sin(self.angle)], hits.shape) return np.broadcast_to([math.cos(self.angle), math.sin(self.angle)], hits.shape)
@dataclass @dataclass(frozen=True)
class Jet: class Jet:
"""Region (Sector|Tube), field (Radial|Parallel), profile name, signed amplitude. """Region (Sector|Tube), field (Radial|Parallel), profile name, signed amplitude.
Only ``amplitude`` may change after registration; it never rebuilds links. Frozen: change amplitude with ``with_amplitude`` (validated, new object);
the cached per-link shapes stay valid because region/field/profile cannot change.
""" """
region: object region: object
field: object field: object
@@ -118,6 +123,9 @@ class Jet:
if abs(self.amplitude) >= 1: if abs(self.amplitude) >= 1:
raise ValueError("Jet amplitude must satisfy abs(amplitude) < 1 (lattice units)") raise ValueError("Jet amplitude must satisfy abs(amplitude) < 1 (lattice units)")
def with_amplitude(self, amplitude):
return replace(self, amplitude=float(amplitude))
def jet_link_shapes(jets, hits): def jet_link_shapes(jets, hits):
"""Unit-amplitude per-link vectors (n, 2) FP64 and jet index (n,) int32 (-1 = none). """Unit-amplitude per-link vectors (n, 2) FP64 and jet index (n,) int32 (-1 = none).
@@ -126,6 +134,8 @@ def jet_link_shapes(jets, hits):
links. Raises if a jet selects nothing or two jets select the same link. links. Raises if a jet selects nothing or two jets select the same link.
""" """
hits = np.asarray(hits, np.float64).reshape(-1, 2) hits = np.asarray(hits, np.float64).reshape(-1, 2)
if not np.isfinite(hits).all():
raise ValueError("Jet hit points must be finite")
shape = np.zeros_like(hits) shape = np.zeros_like(hits)
jet_id = np.full(len(hits), -1, np.int32) jet_id = np.full(len(hits), -1, np.int32)
for j, jet in enumerate(jets): for j, jet in enumerate(jets):
+4 -4
View File
@@ -149,10 +149,10 @@ class CurvedLinkSoA:
def set_jet_amplitude(self, index, amplitude, stream=None): def set_jet_amplitude(self, index, amplitude, stream=None):
"""Rewrite jet rows from cached shapes; re-upload only the velocity column.""" """Rewrite jet rows from cached shapes; re-upload only the velocity column."""
from ..body.jets import Jet, jet_link_velocity from ..body.jets import jet_link_velocity
jet = self.jets[index] jets = list(self.jets)
Jet(jet.region, jet.field, jet.profile, amplitude) # validate before mutation jets[index] = jets[index].with_amplitude(amplitude) # validates before any change
jet.amplitude = float(amplitude) self.jets = tuple(jets)
driven = self.jet_id >= 0 driven = self.jet_id >= 0
self.surface_velocity[driven] = jet_link_velocity( self.surface_velocity[driven] = jet_link_velocity(
self.jets, self.jet_shape, self.jet_id)[driven] self.jets, self.jet_shape, self.jet_id)[driven]
+2 -5
View File
@@ -174,13 +174,10 @@ class Simulation:
@context_bound @context_bound
def set_jet_amplitude(self, index: int, amplitude: float) -> None: def set_jet_amplitude(self, index: int, amplitude: float) -> None:
"""Change one jet's signed amplitude; re-uploads per-link velocity only.""" """Change one jet's signed amplitude; re-uploads per-link velocity only."""
jet = self.bodies.jets[index].with_amplitude(amplitude)
if self._initialized: if self._initialized:
self.field.curved.set_jet_amplitude(index, amplitude, self.stream) self.field.curved.set_jet_amplitude(index, amplitude, self.stream)
return self.bodies.jets[index] = jet # later geometry syncs keep the new amplitude
from .body.jets import Jet
jet = self.bodies.jets[index]
Jet(jet.region, jet.field, jet.profile, amplitude) # validate before mutation
jet.amplitude = float(amplitude)
def remove_body(self, id: int) -> None: def remove_body(self, id: int) -> None:
"""Remove a body from the simulation. """Remove a body from the simulation.
+18 -3
View File
@@ -74,8 +74,8 @@ def test_selection_uses_true_hit_not_clipped_q():
t = (6-math.sqrt(36-8*(9-2.999**2)))/4 t = (6-math.sqrt(36-8*(9-2.999**2)))/4
angle = math.atan2(t, 3-t) angle = math.atan2(t, 3-t)
_, cl, _, _, hits = scene() _, cl, _, _, hits = scene()
row = np.flatnonzero((cl.fluid_idx == 5*12 + 9) & np.isclose(cl.q, .01)) row = np.flatnonzero((cl.fluid_idx == 5*12 + 9) & (cl.dir == 8))
assert len(row) == 1 assert len(row) == 1 and np.isclose(cl.q[row[0]], .01)
clipped = np.array([9., 5.]) + .01*np.array([-1., 1.]) clipped = np.array([9., 5.]) + .01*np.array([-1., 1.])
jet = Jet(Sector(CENTER, angle, 1e-4, RADIUS + .1), Radial(CENTER)) jet = Jet(Sector(CENTER, angle, 1e-4, RADIUS + .1), Radial(CENTER))
_, jet_id = jet_link_shapes([jet], hits) _, jet_id = jet_link_shapes([jet], hits)
@@ -120,7 +120,22 @@ def test_amplitude_update_reuses_cached_shapes():
assert np.array_equal(cl.aperture, ids >= 0) assert np.array_equal(cl.aperture, ids >= 0)
with pytest.raises(ValueError): with pytest.raises(ValueError):
cl.set_jet_amplitude(0, 1.5) cl.set_jet_amplitude(0, 1.5)
assert jet.amplitude == -2e-3 assert cl.jets[0].amplitude == -2e-3 and jet.amplitude == 1e-3 # registered Jet untouched
np.testing.assert_allclose(cl.surface_velocity, -2*before, rtol=1e-6, atol=0)
def test_jet_definition_is_immutable_and_amplitude_validated():
jet = Jet(Sector(CENTER, 0., .5, 4.), Radial(CENTER), "parabolic", 1e-3)
for name, value in (("field", Parallel(0.)), ("region", Tube(CENTER, 0., 1., 1.)),
("profile", "uniform"), ("amplitude", float("nan"))):
with pytest.raises(AttributeError):
setattr(jet, name, value)
for bad in (float("nan"), float("inf"), 1.0):
with pytest.raises(ValueError):
jet.with_amplitude(bad)
assert jet.with_amplitude(-1e-3).field is jet.field and jet.amplitude == 1e-3
with pytest.raises(ValueError, match="finite"):
jet_link_shapes([jet], [[np.nan, 5.]])
@pytest.mark.parametrize("spec", [SurfaceActuation(0., .6, .002), SurfaceActuation(2., 1.1, -.003)]) @pytest.mark.parametrize("spec", [SurfaceActuation(0., .6, .002), SurfaceActuation(2., 1.1, -.003)])