Skip to content

Commit 8ca2680

Browse files
mountaindustclaude
andcommitted
Reach the last testable corners of the collision code
Five branches of _ibc.py had no coverage, and each turned out to be the mirror image of a covered one, told apart only by a choice the caller never makes deliberately. So each is pinned as an invariance rather than a value. The 3D slider names the edge an agent leaves by its position in the triangle -- Q0Q1, Q1Q2 or Q2Q0 -- and branches on which. Every 3D case in the suite writes the shared edge first, so the middle branch was unreachable by construction. Cyclically rolling one triangle's vertices walks all three in turn, and the resolved position is bit-identical across them, which is the actual contract: where a vertex list starts is an accident of the mesh file. Rotation rather than an arbitrary permutation, so the winding and hence the normal are preserved. The moving slider has the same shape twice over. Which of an adjacent element's two vertices is the shared one decides a branch, so the wedged-agent case now runs both orderings and compares them directly -- the only way to see the two disagree. That case also leaves its element by the second vertex rather than the first, which nothing else in the moving suite did. The travel-reversal critical time only binds when the element turns through the perpendicular to the agent's travel, which no translating or tilting wall does; a pivot from 60 to 120 degrees flips the projection from +2 to -2. The test asserts that sign change, so it cannot quietly stop exercising the branch. Contact arriving at the very end of the step is covered too, where the slider skips the projection entirely and only backs the agent off. The moving stack-exhaustion re-raise gets its own case: it is a different routine from the static one, so covering that said nothing about this. It does not exhaust the real stack -- every element the moving slider crosses costs an ODE solve, so doing that naturally took about three seconds. The limit is lowered relative to the depth already in use instead, which runs in 0.27s and does not depend on the platform's stack size. RecursionError subclasses RuntimeError, so the explained failure is told from a bare one by inspecting it rather than by the except clause. Also fixes a typo in a raise the tests cannot reach: Brenq -> Brentq. Coverage of _ibc.py goes from 96% to 99%. The seven lines left are three placeholders behind the 3D-moving NotImplementedError, two numerical corners documented in place as unreachable, and two solver non-convergence raises. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent eb6bd3e commit 8ca2680

4 files changed

Lines changed: 199 additions & 1 deletion

File tree

planktos/_ibc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1241,7 +1241,7 @@ def x_DE(t,x):
12411241
sol = optimize.root_scalar(spd_diff, method='brentq', bracket=(t_I,t_end))
12421242

12431243
if not sol.converged:
1244-
raise RuntimeError("Brenq did not converge.")
1244+
raise RuntimeError("Brentq did not converge.")
12451245
t_rot = sol.root
12461246
# for debugging
12471247
# print(f't_rot = {t_rot}')

tests/test_collisions_junctions.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
in space or on the units it is expressed in.
3737
'''
3838

39+
import sys
3940
import warnings
4041

4142
import numpy as np
@@ -489,6 +490,58 @@ def test_stack_exhaustion_message_quantifies_the_mismatch():
489490
assert 'elements in one step' in msg
490491

491492

493+
def _stack_depth():
494+
'''Frames currently on the stack. Cheaper than inspect.stack(), which builds
495+
a full record per frame.'''
496+
depth, frame = 0, sys._getframe()
497+
while frame is not None:
498+
depth += 1
499+
frame = frame.f_back
500+
return depth
501+
502+
503+
def test_moving_slide_exhausting_the_stack_reports_the_cause():
504+
'''The moving slider has its own re-raise, and needs its own case: a slide
505+
along a deforming mesh recurses through a different routine than the static
506+
one, so covering one says nothing about the other.
507+
508+
The interpreter's real stack is not exhausted here. Every element the moving
509+
slider crosses costs an ODE solve, so running it out naturally takes seconds;
510+
the limit is lowered relative to the depth already in use instead. What is
511+
under test is the re-raise, not how many frames the interpreter starts with,
512+
and pinning it to a relative depth keeps the test independent of the platform
513+
stack size.
514+
'''
515+
# ~270 elements across the slide, comfortably more than the depth allowed
516+
start_mesh = h.wall_segments(300, 5.0, y_lo=0.0, y_hi=1.0)
517+
end_mesh = h.wall_segments(300, 5.02, y_lo=0.0, y_hi=1.0)
518+
start = np.array([4.995, 0.05])
519+
end = np.array([5.1, 0.95])
520+
521+
original = sys.getrecursionlimit()
522+
sys.setrecursionlimit(_stack_depth() + 100)
523+
try:
524+
h.call_moving(start, end, start_mesh, end_mesh)
525+
raised = None
526+
except RuntimeError as err:
527+
# RecursionError subclasses RuntimeError, so this catches both the
528+
# explained failure and a bare one leaking through; they are told apart
529+
# below rather than by the except clause.
530+
raised = err
531+
finally:
532+
sys.setrecursionlimit(original)
533+
534+
assert raised is not None, 'the slide should have run out of stack'
535+
assert not isinstance(raised, RecursionError), \
536+
f'bare RecursionError escaped instead of being explained: {raised}'
537+
msg = str(raised)
538+
assert 'sliding an agent' in msg, f'should name what ran out; got: {msg}'
539+
assert 'dt' in msg, f'should say what to change; got: {msg}'
540+
assert 'setrecursionlimit' in msg, f'should offer the escape hatch: {msg}'
541+
assert isinstance(raised.__cause__, RecursionError), \
542+
'the original RecursionError should be chained as the cause'
543+
544+
492545
# --------------------------------------------------------------------------- #
493546
# general sanity checks, any geometry #
494547
# --------------------------------------------------------------------------- #

tests/test_collisions_moving.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,123 @@ def test_release_from_a_turning_obstacle_keeps_the_agent_outside():
356356
h.assert_displacement_bounded_moving(start, end, newend, m0, m1)
357357

358358

359+
# --------------------------------------------------------------------------- #
360+
# contact arriving at the very end of the step #
361+
# --------------------------------------------------------------------------- #
362+
363+
def test_contact_at_the_very_end_of_the_step_just_backs_off():
364+
'''With essentially none of the step left to slide through, the slider skips
365+
the whole projection and simply places the agent off the boundary it just
366+
reached. Worth its own case because that shortcut returns before any of the
367+
machinery the other tests exercise.
368+
369+
A wall rising from y=0 to y=0.1 and an agent dropping from y=0.5 to a hair
370+
under y=0.1 meet at t = 0.5/(0.5+1e-9), which is 1 to within 2e-9.
371+
'''
372+
start_mesh = h.segment((0.0, 0.0), (1.0, 0.0))
373+
end_mesh = h.segment((0.0, 0.1), (1.0, 0.1))
374+
start = np.array([0.5, 0.5])
375+
end = np.array([0.5, 0.1 - 1e-9])
376+
377+
newend, dx, idx = h.call_moving(start, end, start_mesh, end_mesh, 'sliding')
378+
379+
assert idx == 0, 'expected contact with the rising wall'
380+
assert np.allclose(newend, [0.5, 0.1], atol=POS_ATOL), \
381+
f'expected the agent left at the contact point (0.5,0.1), got {newend}'
382+
assert newend[1] >= 0.1, 'agent was left below the wall it just met'
383+
384+
385+
# --------------------------------------------------------------------------- #
386+
# the travel reversal as the binding critical time #
387+
# --------------------------------------------------------------------------- #
388+
# The slider re-checks two critical times when an agent is still on its element
389+
# at the end of the step: the element's shortest moment, and where the agent's
390+
# direction of travel along it reverses. The reversal is the binding one when it
391+
# comes first, which needs the element to turn through the perpendicular to the
392+
# agent's travel -- something no translating or gently tilting wall does.
393+
394+
def test_travel_reversal_can_be_the_binding_critical_time():
395+
'''An element pivoting from 60 to 120 degrees while the agent travels
396+
straight in +x. Its projection onto the agent's travel goes from +2 to -2,
397+
so the direction of sliding reverses partway through the step.
398+
399+
There is no closed form for where the agent lands, so this asserts the
400+
invariants plus equivariance. As with the release branch, the usual
401+
no-penetration check does not apply to a lone pivoting element: it sweeps
402+
across the agent's own start point.
403+
'''
404+
def pivot(degrees, length=2.0):
405+
theta = np.radians(degrees)
406+
offset = np.array([np.cos(theta), np.sin(theta)])*length/2
407+
return np.array([[-offset, offset]])
408+
409+
m0, m1 = pivot(60), pivot(120)
410+
start = np.array([-1.0, 0.5]); end = np.array([1.0, 0.5])
411+
vec = end - start
412+
413+
# the reversal exists: the element's projection onto the travel flips sign
414+
assert (np.dot(vec, m0[0, 1] - m0[0, 0]) *
415+
np.dot(vec, m1[0, 1] - m1[0, 0])) < 0, \
416+
'this element does not turn through the perpendicular to the travel'
417+
418+
newend, dx, idx = h.call_moving(start, end, m0, m1)
419+
420+
assert idx is not None, 'expected contact with the pivoting element'
421+
h.assert_finite(newend, 'slide through a travel reversal')
422+
h.assert_displacement_bounded_moving(start, end, newend, m0, m1)
423+
424+
T = h.rigid_2D(0.7, (3.0, -2.0))
425+
moved, _, _ = h.call_moving(T(start), T(end), T(m0), T(m1))
426+
assert np.allclose(np.asarray(moved, float), T(newend), atol=1e-5), \
427+
f'rotated problem gave {np.asarray(moved)}, expected {T(newend)}'
428+
429+
430+
# --------------------------------------------------------------------------- #
431+
# a wedged agent on a moving joint, either vertex order #
432+
# --------------------------------------------------------------------------- #
433+
434+
def test_moving_wedge_rides_the_joint_whatever_the_vertex_order():
435+
'''An agent that slides into a moving joint it cannot advance past is left
436+
at that joint and carried with it.
437+
438+
Which of the adjacent element's two vertices is the shared one is an
439+
accident of how the mesh file was written, and the slider has a branch per
440+
case. Both must give the same answer, so both are run here and compared:
441+
that is the only way to see the two branches disagree.
442+
443+
The focal element is traversed left to right, so the agent leaves by its
444+
*second* vertex -- the other of a similar pair of branches, and the one no
445+
other moving case reaches.
446+
'''
447+
joint = np.array([5.0, 5.0])
448+
shift = np.array([0.05, 0.0])
449+
focal = h.segment((2.0, 5.0), tuple(joint)) # agent slides off its Q1
450+
ramp_tip = (5.6, 7.5) # leans back over the focal
451+
start = np.array([4.2, 6.4]) # diving steeply into focal
452+
end = np.array([5.2, 4.2]) # overshooting the joint
453+
454+
results = {}
455+
for label, ramp in (('joint first', h.segment(tuple(joint), ramp_tip)),
456+
('joint second', h.segment(ramp_tip, tuple(joint)))):
457+
m0 = np.concatenate([focal, ramp])
458+
m1 = m0 + shift
459+
newend, dx, idx = h.call_moving(start, end, m0, m1)
460+
h.assert_finite(newend, f'moving wedge ({label})')
461+
# wedged at the joint, which has itself moved with the mesh
462+
assert np.allclose(newend, joint + shift, atol=POS_ATOL), \
463+
f'{label}: expected the agent at the joint {joint+shift}, got {newend}'
464+
# backed off it, not sitting exactly on it
465+
offset = float(np.linalg.norm(newend - (joint + shift)))
466+
assert 0 < offset < POS_ATOL, \
467+
f'{label}: agent left {offset:.3e} from the vertex'
468+
results[label] = newend
469+
470+
assert np.allclose(results['joint first'], results['joint second'],
471+
atol=1e-12), \
472+
('the resolved position changed with the order the adjacent element\'s '
473+
f'vertices were stored: {results}')
474+
475+
359476
# --------------------------------------------------------------------------- #
360477
# golden multi-step trajectory (drift detector for moving collisions) #
361478
# --------------------------------------------------------------------------- #

tests/test_collisions_static_3d.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,34 @@ def test_coplanar_tiled_surface_slides_across_internal_edge():
158158
assert np.allclose(newend[:2], [1.0, 3.0], atol=POS_ATOL) # slid to slide_pt in t2
159159

160160

161+
@pytest.mark.parametrize('roll', [0, 1, 2])
162+
def test_slide_across_internal_edge_ignores_vertex_rotation(roll):
163+
'''Where a triangle's vertices start in its stored list is arbitrary -- a
164+
mesh file may list any of the three first -- so rotating them must not move
165+
the agent.
166+
167+
It is worth checking because the slider identifies the edge an agent leaves
168+
by its position in the triangle (Q0Q1, Q1Q2 or Q2Q0) and takes a different
169+
branch for each. Rolling the vertices walks all three: the same physical
170+
edge is the first, second and third in turn. Rotation is used rather than an
171+
arbitrary permutation because it preserves the winding, and so the normal.
172+
173+
Every other 3D case in the suite builds its triangles with the shared edge
174+
written first, which is why the middle branch went unreached.
175+
'''
176+
t1 = np.array([[0., 0., 0.], [4., 0., 0.], [4., 4., 0.]]) # lower-right
177+
t2 = np.array([[0., 0., 0.], [4., 4., 0.], [0., 4., 0.]]) # upper-left
178+
start = np.array([3., 1., 0.3]); end = np.array([1., 3., -0.5])
179+
180+
mesh = np.array([np.roll(t1, roll, axis=0), t2])
181+
newend, dx, idx = h.call_static(start, end, mesh, 'sliding')
182+
183+
assert newend[2] >= -POS_ATOL, "penetrated the flat surface"
184+
assert newend[1] - newend[0] > 1.0, "stuck at the internal diagonal edge"
185+
assert np.allclose(newend[:2], [1.0, 3.0], atol=POS_ATOL), \
186+
f"vertex rotation {roll} moved the agent to {newend}"
187+
188+
161189
@pytest.mark.parametrize('slope', [0.05, 0.2, 0.5])
162190
def test_gentle_concave_fold_slides_onto_neighbor(slope):
163191
# A gentle concave valley (faces z = slope*|x|). An agent sliding down one

0 commit comments

Comments
 (0)