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:
co-authored by
Claude Opus 5.5
parent
932702f779
commit
b1cd555b0f
@@ -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):
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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)])
|
||||||
|
|||||||
Reference in New Issue
Block a user