From 4d8620326f33ca8de6b80b3047061807d19ab999 Mon Sep 17 00:00:00 2001 From: Evan Kiefl Date: Sun, 17 May 2026 21:47:26 -0700 Subject: [PATCH] Add ball-table detection --- docs/resources/custom_physics.md | 6 +- pooltool/ani/constants.py | 6 +- pooltool/evolution/engine.py | 7 +-- pooltool/evolution/event_based/cache.py | 6 +- pooltool/evolution/event_based/config.py | 8 +-- .../evolution/event_based/detect/__init__.py | 6 ++ .../event_based/detect/ball_table.py | 47 ++++++++++++++++ .../evolution/event_based/detect/detector.py | 19 ++++++- pooltool/physics/dimensionality.py | 7 +++ pooltool/physics/motion/solve.py | 19 ++++++- pooltool/physics/resolve/ball_table/core.py | 9 +-- .../frictional_inelastic/__init__.py | 2 - .../frictionless_inelastic/__init__.py | 2 - .../evolution/event_based/test_ball_table.py | 56 +++++++++++++++++++ .../event_based/test_introspection.py | 16 +++--- tests/evolution/test_engine.py | 22 +++----- .../ball_ball/test_frictional_mathavan.py | 11 ++-- 17 files changed, 195 insertions(+), 54 deletions(-) create mode 100644 pooltool/evolution/event_based/detect/ball_table.py create mode 100644 tests/evolution/event_based/test_ball_table.py diff --git a/docs/resources/custom_physics.md b/docs/resources/custom_physics.md index 2fae0b50..d0397af9 100644 --- a/docs/resources/custom_physics.md +++ b/docs/resources/custom_physics.md @@ -201,7 +201,11 @@ dim: Dim = attrs.field(default=Dim.TWO, init=False, repr=False) - `Dim.TWO` — your model is safe only when [](#pooltool.evolution.engine.SimulationEngine)'s `is_3d` is `False`. Use this if your model assumes balls are on the table surface (z=R, vz=0). - `Dim.THREE` — your model is safe only when `is_3d` is `True`. Use this if your model assumes 3D ball state (e.g. it produces or handles airborne balls). -- `Dim.BOTH` — your model behaves identically in either mode. This is a strong promise: a `Dim.BOTH` model doesn't care or know whether it's handling a 2D/3D simulation. +- `Dim.BOTH` — your model is safe in either mode. It may still take different code paths depending on the input it receives (e.g. a branch on `state == const.airborne` is dead in 2D and live in 3D), as long as neither path is incorrect for the mode it runs under. + +:::{note} +**Ball-table resolvers are an exception:** they do not declare a `dim` attribute. Ball-table events do not exist as a concept in 2D, so a 2D-vs-3D capability declaration is not meaningful for them. +::: Great, now we are done with the boilerplate code. But `resolve` currently does *nothing*, it just returns what is handed to it. Let's change that. diff --git a/pooltool/ani/constants.py b/pooltool/ani/constants.py index c2bfdf25..f875f423 100644 --- a/pooltool/ani/constants.py +++ b/pooltool/ani/constants.py @@ -4,7 +4,7 @@ from pathlib import Path -import pooltool as pt +import pooltool from pooltool.utils import panda_path menu_text_scale = 0.07 @@ -41,9 +41,9 @@ "shadow_scale_amplitude": 0.4, } -model_dir: Path = Path(pt.__file__).parent / "models" +model_dir: Path = Path(pooltool.__file__).parent / "models" -logo_dir = Path(pt.__file__).parent / "logo" +logo_dir = Path(pooltool.__file__).parent / "logo" logo_paths = { "default": panda_path(logo_dir / "logo.png"), "small": panda_path(logo_dir / "logo_small.png"), diff --git a/pooltool/evolution/engine.py b/pooltool/evolution/engine.py index f6327025..4865b058 100644 --- a/pooltool/evolution/engine.py +++ b/pooltool/evolution/engine.py @@ -4,9 +4,8 @@ import attrs -from pooltool.evolution.event_based.config import DORMANT_IN_2D from pooltool.evolution.event_based.detect import EventDetector -from pooltool.physics.dimensionality import Dim +from pooltool.physics.dimensionality import SKIP_DIMENSION, Dim from pooltool.physics.resolve import Resolver @@ -40,11 +39,11 @@ def _validate_dimensionality(self) -> None: required = Dim.THREE if self.is_3d else Dim.TWO for bundle in (self.resolver, self.detector): for field in attrs.fields(type(bundle)): + if field.name in SKIP_DIMENSION: + continue strategy = getattr(bundle, field.name) if not attrs.has(type(strategy)): continue - if not self.is_3d and field.name in DORMANT_IN_2D: - continue if not hasattr(strategy, "dim"): raise AttributeError( f"{type(bundle).__name__}.{field.name} " diff --git a/pooltool/evolution/event_based/cache.py b/pooltool/evolution/event_based/cache.py index b0dba780..ca2528e6 100644 --- a/pooltool/evolution/event_based/cache.py +++ b/pooltool/evolution/event_based/cache.py @@ -71,7 +71,9 @@ def create(cls, shot: System) -> TransitionCache: def _next_transition(ball: Ball) -> Event: - if ball.state.s == const.stationary or ball.state.s == const.pocketed: + if ball.state.s in {const.stationary, const.pocketed, const.airborne}: + # Stationary and airborne states can only be changed via collisions, and + # pocketed states can never be changed. return null_event(time=np.inf) elif ball.state.s == const.spinning: @@ -131,7 +133,7 @@ class CollisionCache: event caching, see :class:`TransitionCache`. """ - times: dict[EventType, dict[tuple[str, str], float]] = attrs.field(factory=dict) + times: dict[EventType, dict[tuple[str, ...], float]] = attrs.field(factory=dict) @property def size(self) -> int: diff --git a/pooltool/evolution/event_based/config.py b/pooltool/evolution/event_based/config.py index 288be937..10dbdfd6 100644 --- a/pooltool/evolution/event_based/config.py +++ b/pooltool/evolution/event_based/config.py @@ -6,16 +6,10 @@ EventType.BALL_LINEAR_CUSHION, EventType.BALL_CIRCULAR_CUSHION, EventType.BALL_POCKET, + EventType.BALL_TABLE, EventType.STICK_BALL, EventType.SPINNING_STATIONARY, EventType.ROLLING_STATIONARY, EventType.ROLLING_SPINNING, EventType.SLIDING_ROLLING, } - -DORMANT_IN_2D: frozenset[str] = frozenset({"ball_table"}) -"""Resolver/EventDetector field names that are dormant in 2D mode because the detection -layer doesn't emit their associated event types. In 2D, the ``dim`` of these fields is -not validated against ``SimulationEngine.is_3d`` - any tag is safe because the strategy -will never be invoked. In 3D, they are validated normally so a misdeclared ball-table -strategy is still caught.""" diff --git a/pooltool/evolution/event_based/detect/__init__.py b/pooltool/evolution/event_based/detect/__init__.py index 310131f5..54942975 100644 --- a/pooltool/evolution/event_based/detect/__init__.py +++ b/pooltool/evolution/event_based/detect/__init__.py @@ -12,6 +12,10 @@ BallPocketDetection, BallPocketDetectionStrategy, ) +from pooltool.evolution.event_based.detect.ball_table import ( + BallTableDetection, + BallTableDetectionStrategy, +) from pooltool.evolution.event_based.detect.detector import EventDetector from pooltool.evolution.event_based.detect.stick_ball import ( StickBallDetection, @@ -28,6 +32,8 @@ "BallLCushionDetectionStrategy", "BallPocketDetection", "BallPocketDetectionStrategy", + "BallTableDetection", + "BallTableDetectionStrategy", "StickBallDetection", "StickBallDetectionStrategy", ] diff --git a/pooltool/evolution/event_based/detect/ball_table.py b/pooltool/evolution/event_based/detect/ball_table.py new file mode 100644 index 00000000..cba3ae2f --- /dev/null +++ b/pooltool/evolution/event_based/detect/ball_table.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Protocol + +import attrs + +from pooltool.events import Event, EventType, ball_table_collision +from pooltool.evolution.event_based.cache import CollisionCache +from pooltool.physics.motion.solve import ball_table_collision_time +from pooltool.system.datatypes import System + + +class BallTableDetectionStrategy(Protocol): + """Ball-table detection models must satisfy this protocol. + + Unlike the other detection-strategy protocols, this one does not declare a + ``dim`` attribute. + """ + + def get_next(self, shot: System, collision_cache: CollisionCache) -> Event: ... + + +@attrs.define +class BallTableDetection: + """Detects the next ball-table collision in the system.""" + + def get_next(self, shot: System, collision_cache: CollisionCache) -> Event: + cache = collision_cache.times.setdefault(EventType.BALL_TABLE, {}) + + for ball in shot.balls.values(): + obj_ids = (ball.id,) + if obj_ids in cache: + continue + dtau_E = ball_table_collision_time( + rvw=ball.state.rvw, + s=ball.state.s, + g=ball.params.g, + R=ball.params.R, + ) + cache[obj_ids] = shot.t + dtau_E + + obj_ids = min(cache, key=lambda k: cache[k]) + + return ball_table_collision( + ball=shot.balls[obj_ids[0]], + time=cache[obj_ids], + ) diff --git a/pooltool/evolution/event_based/detect/detector.py b/pooltool/evolution/event_based/detect/detector.py index 082825d7..ef1cf56e 100644 --- a/pooltool/evolution/event_based/detect/detector.py +++ b/pooltool/evolution/event_based/detect/detector.py @@ -12,6 +12,7 @@ BallLCushionDetection, ) from pooltool.evolution.event_based.detect.ball_pocket import BallPocketDetection +from pooltool.evolution.event_based.detect.ball_table import BallTableDetection from pooltool.evolution.event_based.detect.stick_ball import StickBallDetection from pooltool.physics.utils import get_ball_energy from pooltool.system.datatypes import System @@ -27,7 +28,7 @@ def _get_event_priority(event: Event, shot: System) -> tuple[int, float]: Priority tiers: - Tier 1: STICK_BALL (always first) - Tier 2: Transitions and BALL_POCKET (can resolve without affecting others) - - Tier 3: BALL_BALL and ball-cushion collisions + - Tier 3: BALL_BALL, ball-cushion collisions, and BALL_TABLE Args: event: The event to compute priority for. @@ -69,6 +70,17 @@ def _get_event_priority(event: Event, shot: System) -> tuple[int, float]: energy = get_ball_energy(ball.state.rvw, ball.params.R, ball.params.m) return (3, energy) + # TODO: tier and energy choice for BALL_TABLE has not been well thought + # through or tested. Mirroring the cushion-collision semantics, but + # BALL_TABLE-vs-other ties only become real once 3D activation lands and + # airborne balls actually arise. Revisit once break / aerial trajectories + # exercise this path. + if event_type == EventType.BALL_TABLE: + ball_id = event.ids[0] + ball = shot.balls[ball_id] + energy = get_ball_energy(ball.state.rvw, ball.params.R, ball.params.m) + return (3, energy) + return (99, 0.0) @@ -95,6 +107,9 @@ class EventDetector: Strategy for detecting the next ball-vs-circular-cushion-segment collision. ball_pocket: Strategy for detecting the next ball-pocket collision. + ball_table: + Strategy for detecting the next ball-table collision (airborne ball + landing on the table surface). """ stick_ball: StickBallDetection = attrs.field(factory=StickBallDetection) @@ -106,6 +121,7 @@ class EventDetector: factory=BallCCushionDetection ) ball_pocket: BallPocketDetection = attrs.field(factory=BallPocketDetection) + ball_table: BallTableDetection = attrs.field(factory=BallTableDetection) @classmethod def default(cls) -> EventDetector: @@ -143,6 +159,7 @@ def get_next_event( candidates.append(self.ball_circular_cushion.get_next(shot, collision_cache)) candidates.append(self.ball_linear_cushion.get_next(shot, collision_cache)) candidates.append(self.ball_pocket.get_next(shot, collision_cache)) + candidates.append(self.ball_table.get_next(shot, collision_cache)) min_time = min(event.time for event in candidates) diff --git a/pooltool/physics/dimensionality.py b/pooltool/physics/dimensionality.py index 678ae2ab..50528d6b 100644 --- a/pooltool/physics/dimensionality.py +++ b/pooltool/physics/dimensionality.py @@ -26,3 +26,10 @@ class Dim(StrEnum): TWO = auto() THREE = auto() BOTH = auto() + + +SKIP_DIMENSION: frozenset[str] = frozenset({"ball_table"}) +"""Resolver/EventDetector field names whose strategies don't carry a ``dim`` +attribute. ``SimulationEngine._validate_dimensionality`` skips these fields +entirely (in either mode). Used for slots whose events have no meaning in 2D +(currently just ``ball_table``: airborne balls only exist in 3D).""" diff --git a/pooltool/physics/motion/solve.py b/pooltool/physics/motion/solve.py index 6b1af5c9..feda9c70 100644 --- a/pooltool/physics/motion/solve.py +++ b/pooltool/physics/motion/solve.py @@ -7,7 +7,7 @@ import pooltool.constants as const import pooltool.physics.evolve as evolve import pooltool.ptmath as ptmath -from pooltool.physics.utils import rel_velocity +from pooltool.physics.utils import get_airborne_time, rel_velocity from pooltool.ptmath.roots import quartic from pooltool.ptmath.roots.core import get_real_positive_smallest_root @@ -421,3 +421,20 @@ def ball_pocket_collision_time( ) ) ) + + +@jit(nopython=True, cache=const.use_numba_cache) +def ball_table_collision_time( + rvw: NDArray[np.float64], + s: int, + g: float, + R: float, +) -> float: + """Time until an airborne ball's bottom touches the table plane. + + Returns ``np.inf`` if the ball is not airborne (no ball-table collision can + occur for any other motion state). + """ + if s != const.airborne: + return np.inf + return get_airborne_time(rvw=rvw, R=R, g=g) diff --git a/pooltool/physics/resolve/ball_table/core.py b/pooltool/physics/resolve/ball_table/core.py index 2082508c..e93a1673 100644 --- a/pooltool/physics/resolve/ball_table/core.py +++ b/pooltool/physics/resolve/ball_table/core.py @@ -6,7 +6,6 @@ import pooltool.constants as const from pooltool.objects.ball.datatypes import Ball -from pooltool.physics.dimensionality import Dim from pooltool.physics.utils import on_table, rel_velocity from pooltool.ptmath.utils import norm2d, norm3d @@ -40,15 +39,17 @@ def final_ball_motion_state(rvw: NDArray[np.float64], R: float) -> int: class _BaseStrategy(Protocol): - dim: Dim - def resolve(self, ball: Ball, inplace: bool = False) -> Ball: ... def make_kiss(self, ball: Ball) -> Ball: ... class BallTableCollisionStrategy(_BaseStrategy, Protocol): - """Ball-table collision models must satisfy this protocol""" + """Ball-table collision models must satisfy this protocol. + + Unlike the other resolver-strategy protocols, this one does not declare a + ``dim`` attribute. + """ def solve(self, ball: Ball) -> Ball: """Resolves a ball-table collision""" diff --git a/pooltool/physics/resolve/ball_table/frictional_inelastic/__init__.py b/pooltool/physics/resolve/ball_table/frictional_inelastic/__init__.py index 17e3f9a8..813c3ad7 100644 --- a/pooltool/physics/resolve/ball_table/frictional_inelastic/__init__.py +++ b/pooltool/physics/resolve/ball_table/frictional_inelastic/__init__.py @@ -7,7 +7,6 @@ import pooltool.physics as physics import pooltool.ptmath as ptmath from pooltool.objects.ball.datatypes import Ball, BallState -from pooltool.physics.dimensionality import Dim from pooltool.physics.resolve.ball_table.core import ( CoreBallTableCollision, bounce_height, @@ -74,7 +73,6 @@ class FrictionalInelasticTable(CoreBallTableCollision): model: BallTableModel = attrs.field( default=BallTableModel.FRICTIONAL_INELASTIC, init=False, repr=False ) - dim: Dim = attrs.field(default=Dim.THREE, init=False, repr=False) def solve(self, ball: Ball) -> Ball: """Resolves the collision.""" diff --git a/pooltool/physics/resolve/ball_table/frictionless_inelastic/__init__.py b/pooltool/physics/resolve/ball_table/frictionless_inelastic/__init__.py index 992efa7c..68f0178c 100644 --- a/pooltool/physics/resolve/ball_table/frictionless_inelastic/__init__.py +++ b/pooltool/physics/resolve/ball_table/frictionless_inelastic/__init__.py @@ -1,7 +1,6 @@ import attrs from pooltool.objects.ball.datatypes import Ball, BallState -from pooltool.physics.dimensionality import Dim from pooltool.physics.resolve.ball_table.core import ( CoreBallTableCollision, bounce_height, @@ -36,7 +35,6 @@ class FrictionlessInelasticTable(CoreBallTableCollision): model: BallTableModel = attrs.field( default=BallTableModel.FRICTIONLESS_INELASTIC, init=False, repr=False ) - dim: Dim = attrs.field(default=Dim.THREE, init=False, repr=False) def solve(self, ball: Ball) -> Ball: """Resolves the collision.""" diff --git a/tests/evolution/event_based/test_ball_table.py b/tests/evolution/event_based/test_ball_table.py new file mode 100644 index 00000000..f3a73cc5 --- /dev/null +++ b/tests/evolution/event_based/test_ball_table.py @@ -0,0 +1,56 @@ +import numpy as np +import pytest + +import pooltool.constants as const +from pooltool.events import EventType +from pooltool.evolution.event_based.cache import CollisionCache +from pooltool.evolution.event_based.detect.ball_table import BallTableDetection +from pooltool.physics.utils import get_airborne_time +from pooltool.system.datatypes import System + + +@pytest.fixture +def system() -> System: + return System.example() + + +def test_no_airborne_balls_returns_inf_time(system: System): + """In a default 2D scene no ball is airborne, so the emitted event has time=inf.""" + event = BallTableDetection().get_next(system, CollisionCache()) + assert event.event_type == EventType.BALL_TABLE + assert event.time == np.inf + + +def test_airborne_ball_returns_finite_time(system: System): + """An airborne ball at apex over the table returns the physics-derived drop time.""" + ball = next(iter(system.balls.values())) + ball.state.rvw[0, 2] = ball.params.R + 0.1 + ball.state.rvw[1, 2] = 0.0 + ball.state.s = const.airborne + + event = BallTableDetection().get_next(system, CollisionCache()) + + expected = get_airborne_time(ball.state.rvw, ball.params.R, ball.params.g) + assert event.event_type == EventType.BALL_TABLE + assert event.time == pytest.approx(expected) + + +def test_returns_soonest_ball(system: System): + """When multiple balls are airborne, the one with the shortest drop time wins.""" + balls = list(system.balls.values()) + assert len(balls) >= 2 + + high, low = balls[0], balls[1] + + high.state.rvw[0, 2] = high.params.R + 0.5 + high.state.rvw[1, 2] = 0.0 + high.state.s = const.airborne + + low.state.rvw[0, 2] = low.params.R + 0.05 + low.state.rvw[1, 2] = 0.0 + low.state.s = const.airborne + + event = BallTableDetection().get_next(system, CollisionCache()) + + assert event.event_type == EventType.BALL_TABLE + assert event.ids[0] == low.id diff --git a/tests/evolution/event_based/test_introspection.py b/tests/evolution/event_based/test_introspection.py index 186aa3b0..8d354fc0 100644 --- a/tests/evolution/event_based/test_introspection.py +++ b/tests/evolution/event_based/test_introspection.py @@ -1,23 +1,23 @@ import tempfile from pathlib import Path -import pooltool as pt from pooltool.evolution.event_based.introspection import ( SimulationSnapshotSequence, simulate_with_snapshots, ) from pooltool.evolution.event_based.simulate import simulate +from pooltool.system.datatypes import System def test_simulate_with_snapshots_equivalence_with_simulate(): """Tests that `simulates_with_snapshots` returns same thing as `simulate`.""" - system = pt.System.example() + system = System.example() result, _ = simulate_with_snapshots(system) assert result == simulate(system) def test_snapshot_sequence_roundtrip(): - system = pt.System.example() + system = System.example() with tempfile.TemporaryDirectory() as tmpdir: _, seq = simulate_with_snapshots(system) @@ -27,7 +27,7 @@ def test_snapshot_sequence_roundtrip(): def test_selected_event_in_all_possible_events(): - system = pt.System.example() + system = System.example() _, seq = simulate_with_snapshots(system) for step in range(len(seq) - 1): @@ -41,7 +41,7 @@ def test_selected_event_in_all_possible_events(): def test_pre_evolve_equals_snapshot_system(): """pre_evolve_system should return the same system as stored in snapshot.""" - system = pt.System.example() + system = System.example() _, seq = simulate_with_snapshots(system) for step in range(len(seq)): @@ -54,7 +54,7 @@ def test_pre_evolve_equals_snapshot_system(): def test_post_evolve_advances_time(): """post_evolve_system should advance time to the selected event.""" - system = pt.System.example() + system = System.example() _, seq = simulate_with_snapshots(system) for step in range(len(seq)): @@ -67,7 +67,7 @@ def test_post_evolve_advances_time(): def test_post_resolve_of_n_equals_pre_evolve_of_n_plus_1(): """post_resolve_system of step n should equal pre_evolve_system of step n+1.""" - system = pt.System.example() + system = System.example() _, seq = simulate_with_snapshots(system) for step in range(len(seq) - 1): @@ -82,7 +82,7 @@ def test_post_resolve_of_n_equals_pre_evolve_of_n_plus_1(): def test_system_state_progression(): """Test the full progression: pre_evolve -> post_evolve -> post_resolve.""" - system = pt.System.example() + system = System.example() _, seq = simulate_with_snapshots(system) for step in range(len(seq)): diff --git a/tests/evolution/test_engine.py b/tests/evolution/test_engine.py index 23b755de..03076132 100644 --- a/tests/evolution/test_engine.py +++ b/tests/evolution/test_engine.py @@ -87,20 +87,14 @@ def test_dim_both_strategy_accepted_in_3d(engine_3d: SimulationEngine): ) -def test_dormant_ball_table_skipped_in_2d(): - """In 2D, ball_table's dim is not validated (the slot is dormant).""" +def test_ball_table_exempt_from_dim_validation(): + """ball_table strategies don't carry a `dim` attribute. The validator + skips both Resolver.ball_table and EventDetector.ball_table fields in + either mode via SKIP_DIMENSION.""" resolver = SimulationEngine().resolver - assert resolver.ball_table.dim == Dim.THREE - SimulationEngine(resolver=resolver, is_3d=False) - + detector = SimulationEngine().detector -def test_misdeclared_ball_table_still_caught_in_3d(engine_3d: SimulationEngine): - """In 3D, ball_table's dim *is* validated — a Dim.TWO ball_table is a bug.""" - engine_3d.resolver.ball_table.dim = Dim.TWO + assert not hasattr(resolver.ball_table, "dim") + assert not hasattr(detector.ball_table, "dim") - with pytest.raises(ValueError, match=r"ball_table.*incompatible with is_3d=True"): - SimulationEngine( - resolver=engine_3d.resolver, - detector=engine_3d.detector, - is_3d=True, - ) + SimulationEngine(resolver=resolver, detector=detector, is_3d=False) diff --git a/tests/physics/resolve/ball_ball/test_frictional_mathavan.py b/tests/physics/resolve/ball_ball/test_frictional_mathavan.py index 38340a20..5114f559 100644 --- a/tests/physics/resolve/ball_ball/test_frictional_mathavan.py +++ b/tests/physics/resolve/ball_ball/test_frictional_mathavan.py @@ -1,8 +1,9 @@ import numpy as np import pytest -import pooltool as pt from pooltool.physics.resolve.ball_ball.frictional_mathavan import _collide_balls +from pooltool.physics.utils import get_slide_time, rel_velocity +from pooltool.ptmath.utils import norm2d DEG2RAD = np.pi / 180 RAD2DEG = 180 / np.pi @@ -56,14 +57,14 @@ def test_collide_balls(initial_conditions, expected): def calc_rolling_velocity(v, w): rvw = np.zeros((3, 3), dtype=np.float64) rvw[1], rvw[2] = v, w - u = pt.physics.rel_velocity(rvw, R) + u = rel_velocity(rvw, R) a = -mu_s * g * u / np.linalg.norm(u) - return v + a * pt.physics.get_slide_time(rvw, R, mu_s, g) + return v + a * get_slide_time(rvw, R, mu_s, g) v_iS = calc_rolling_velocity(v_i1, w_i1) v_jS = calc_rolling_velocity(v_j1, w_j1) - v_iS_mag = pt.ptmath.norm2d(v_iS) - v_jS_mag = pt.ptmath.norm2d(v_jS) + v_iS_mag = norm2d(v_iS) + v_jS_mag = norm2d(v_jS) assert abs(v_iS_mag - v_iS_mag_ex) / abs(v_iS_mag_ex) < 1e-2 assert abs(v_jS_mag - v_jS_mag_ex) / abs(v_jS_mag_ex) < 1e-2