Repository: ekiefl/pooltool
Pull request #202: Parallel multisystem playback
Author: login: ekiefl
Created: 2025-03-31T02:57:35Z | Merged: 2025-03-31T03:13:37Z

# Parallel multisystem playback

This let's you visualize different shots simultaneously. By pressing Enter when passing a multisystem to `show`. Press *n* and *p* to highlight the shot of interest. Press Enter to return to serial viewing (toggle through different shots with *n* and *p*).

```python
import numpy as np

import pooltool as pt

template = pt.System.example()
template.balls["cue"].state.rvw[0, 0] = 0.3
template.cue.set_state(a=0, b=0.4, phi=pt.aim.at_ball(template, "1") + 2.5)

systems = []
for V0 in np.linspace(1, 3, 20):
    system = template.copy()
    system.cue.set_state(V0=V0)
    pt.simulate(system, inplace=True)
    systems.append(system)

pt.show(pt.MultiSystem(systems))
```

![asdf](https://github.com/user-attachments/assets/60934b9d-58be-40f2-9bd2-9bd0031d849e)

What you're seeing is 20 different shots played at the same time.

# Drivebys:

* Added a function to find the state of a ball at any time point.

---------

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Introduced a parallel visualization mode that displays multiple systems side-by-side with synchronized animations and adjustable opacity. Users can toggle this mode with keyboard shortcuts for a more dynamic viewing experience.
  - Enhanced visual transitions during shot mode to ensure consistent behavior when entering or exiting parallel mode.

- **Documentation**
  - Updated interactive instructions detailing keyboard controls for toggling parallel mode and navigating between systems.

- **Bug Fixes**
  - Improved state management and error handling for smoother playback and visualization transitions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/pooltool/__init__.py b/pooltool/__init__.py
index 8962f0a8..40c3a2f3 100755
--- a/pooltool/__init__.py
+++ b/pooltool/__init__.py
@@ -39,7 +39,7 @@
 import pooltool.terminal as terminal
 import pooltool.utils as utils
 from pooltool.events import EventType
-from pooltool.evolution import continuize, simulate
+from pooltool.evolution import continuize, interpolate_ball_states, simulate
 from pooltool.game.datatypes import GameType
 from pooltool.interact import Game, show
 from pooltool.layouts import generate_layout, get_rack
@@ -91,5 +91,6 @@
     "get_ruleset",
     "simulate",
     "continuize",
+    "interpolate_ball_states",
     "generate_layout",
 ]
diff --git a/pooltool/ani/animate.py b/pooltool/ani/animate.py
index 8f71c189..33993366 100755
--- a/pooltool/ani/animate.py
+++ b/pooltool/ani/animate.py
@@ -164,6 +164,10 @@ def _listen_constant_events(self):
         tasks.register_event("toggle-help", hud.toggle_help)
 
     def close_scene(self):
+        # Ensure parallel mode is exited
+        if visual.is_parallel_mode:
+            visual.exit_parallel_mode()
+
         visual.teardown()
 
         environment.unload_room()
@@ -366,7 +370,12 @@ def show(
 
                 Note:
                     If a multisystem is passed, the systems can be scrolled through by
-                    pressing *n* (next) and *p* (previous).
+                    pressing *n* (next) and *p* (previous). When using ``pt.show()``,
+                    press *Enter* to toggle parallel visualization mode where all systems
+                    play simultaneously with reduced opacity except the active one. In
+                    parallel mode, use *n* and *p* to change which system has full opacity.
+                    Note that parallel visualization is only available in ``pt.show()``
+                    and not when playing the game through ``run-pooltool``.
             title:
                 The title to display in the visualization. Defaults to an empty string.
             camera_state:
diff --git a/pooltool/ani/modes/shot.py b/pooltool/ani/modes/shot.py
index f00ba585..1f4dfd91 100755
--- a/pooltool/ani/modes/shot.py
+++ b/pooltool/ani/modes/shot.py
@@ -98,7 +98,10 @@ def enter(
         self.register_keymap_event("i-up", Action.introspect, False)
         self.register_keymap_event("n-up", Action.next_shot, True)
         self.register_keymap_event("p-up", Action.prev_shot, True)
-        self.register_keymap_event("enter-up", Action.parallel, True)
+
+        # Only register the parallel visualization mode if in ShotViewer mode (not in game)
+        if self.view_only:
+            self.register_keymap_event("enter-up", Action.parallel, True)
 
         tasks.add(self.shot_view_task, "shot_view_task")
         tasks.add(self.shot_animation_task, "shot_animation_task")
@@ -118,6 +121,11 @@ def exit(self, key="soft"):
         """
         assert key in {"advance", "reset", "soft"}
 
+        # Always ensure parallel mode is exited, regardless of view_only status
+        # This ensures clean exit even if view_only state changed
+        if visual.is_parallel_mode:
+            visual.exit_parallel_mode()
+
         if key == "advance":
             # New shot means new system. Depending how ShotMode was entered, it may
             # already exist in multisystem. If the most recently added system is
@@ -205,6 +213,7 @@ def shot_view_task(self, task):
         elif self.keymap[Action.aim] or visual.animation_finished:
             # Either the user has requested to start the next shot, or the animation has
             # finished
+
             Global.mode_mgr.change_mode(Mode.aim, exit_kwargs=dict(key="advance"))
 
         elif self.keymap[Action.zoom]:
@@ -247,10 +256,29 @@ def shot_animation_task(self, task):
                 enter_kwargs=dict(load_prev_cam=True),
             )
 
-        elif self.keymap[Action.parallel]:
-            # FIXME Implementing parallel would involve code here and throughout shot.py
-            # and view.py
-            raise NotImplementedError("This will one day be re-implemented")
+        elif (
+            self.keymap[Action.parallel] and self.view_only
+        ):  # Only process in ShotViewer mode
+            self.keymap[Action.parallel] = False
+            if visual.is_parallel_mode:
+                # Exit parallel mode
+                visual.exit_parallel_mode()
+                self.change_animation(multisystem.active_index)
+            else:
+                # First pause and reset any current animation
+                was_playing = not visual.paused
+                visual.pause_animation()
+                visual.reset_animation()
+
+                # Enter parallel mode
+                visual.setup_parallel_mode()
+
+                # Start the animation if it was previously playing
+                if was_playing:
+                    visual.animate(PlaybackMode.LOOP)
+                else:
+                    # Just build the animation but don't play it
+                    visual.paused = True
 
         elif self.keymap[Action.prev_shot]:
             self.keymap[Action.prev_shot] = False
@@ -285,8 +313,33 @@ def shot_animation_task(self, task):
     @staticmethod
     def change_animation(shot_index):
         """Switch to a different system in the system collection"""
+        # Was the animation playing before?
+        was_playing = not visual.paused
+
+        # Pause animation during transition to prevent visual glitches
+        visual.pause_animation()
+
         # Switch shots
         multisystem.set_active(shot_index)
+
+        if visual.is_parallel_mode:
+            # In parallel mode, just update which system is active
+            visual.update_parallel_active(shot_index)
+
+            # Set the HUD
+            system_cue = multisystem.active.cue
+            hud.update_cue(system_cue, multisystem.active.balls[system_cue.cue_ball_id])
+
+            # Resume animation if it was playing
+            if was_playing:
+                visual.resume_animation()
+
+            return
+
+        # Standard mode - clear any existing animation
+        visual.reset_animation()
+
+        # Setup new system
         visual.attach_system(multisystem.active)
         visual.buildup()
 
@@ -294,8 +347,12 @@ def change_animation(shot_index):
         visual.build_shot_animation()
 
         # Changing to a different shot is considered advanced maneuvering, so we enter
-        # loop mode.
-        visual.animate(PlaybackMode.LOOP)
+        # loop mode if it was previously playing
+        if was_playing:
+            visual.animate(PlaybackMode.LOOP)
+        else:
+            # Ready to play but maintain paused state
+            visual.playback(PlaybackMode.LOOP)
 
         cue_avoid.init_collisions()
 
diff --git a/pooltool/evolution/__init__.py b/pooltool/evolution/__init__.py
index 15467af5..db90aa97 100644
--- a/pooltool/evolution/__init__.py
+++ b/pooltool/evolution/__init__.py
@@ -1,9 +1,10 @@
 """Shot evolution algorithm routines"""
 
-from pooltool.evolution.continuize import continuize
+from pooltool.evolution.continuize import continuize, interpolate_ball_states
 from pooltool.evolution.event_based.simulate import simulate
 
 __all__ = [
     "continuize",
     "simulate",
+    "interpolate_ball_states",
 ]
diff --git a/pooltool/evolution/continuize.py b/pooltool/evolution/continuize.py
index 4ac1ccab..b1dec533 100644
--- a/pooltool/evolution/continuize.py
+++ b/pooltool/evolution/continuize.py
@@ -1,11 +1,16 @@
-"""Module for building a time-dense system trajectory
+"""Module for building a time-dense system trajectory and interpolating ball states
 
-For an explanation, see :func:`continuize`
+For an explanation, see :func:`continuize` and :func:`interpolate_ball_states`
 """
 
+from typing import List, Sequence, Union
+
+import numpy as np
+from numpy.typing import NDArray
+
 import pooltool.physics.evolve as evolve
 from pooltool.events import filter_ball
-from pooltool.objects.ball.datatypes import BallHistory, BallState
+from pooltool.objects.ball.datatypes import Ball, BallHistory, BallState
 from pooltool.system.datatypes import System
 
 
@@ -185,3 +190,114 @@ def continuize(system: System, dt: float = 0.01, inplace: bool = False) -> Syste
         ball.history_cts = history
 
     return system
+
+
+def interpolate_ball_states(
+    ball: Ball,
+    timestamps: Union[NDArray[np.float64], Sequence[float]],
+    *,
+    extrapolate: bool = False,
+) -> List[BallState]:
+    """Calculate exact ball states at arbitrary timestamps.
+
+    This function calculates the precise ball states at arbitrary timestamps by evolving
+    the ball from the nearest preceding event state using the same physics model as the
+    simulation. It provides physically accurate positions, velocities, and angular velocities
+    according to the ball's motion equations.
+
+    Args:
+        ball:
+            The Ball object containing the history and physical parameters.
+        timestamps:
+            A sequence or numpy array of timestamps at which to calculate ball states.
+            Should be in ascending order and within the history's time range.
+        extrapolate:
+            If True, timestamps outside the history's time range will use the nearest boundary
+            state (initial or final). If False (default), a ValueError is raised for timestamps
+            outside the range.
+
+    Returns:
+        A list of BallState objects corresponding to the given timestamps.
+
+    Raises:
+        ValueError:
+            If history is empty or if timestamps are out of range and extrapolate is False.
+
+    Examples:
+        >>> import pooltool as pt
+        >>> import numpy as np
+        >>> system = pt.simulate(pt.System.example())
+        >>> ball = system.balls["cue"]
+        >>> # Get ball states at specific timestamps
+        >>> timestamps = np.array([0.5, 1.0, 1.5])
+        >>> states = pt.interpolate_ball_states(ball, timestamps)
+        >>> # Use the states
+        >>> states[0].rvw[0]  # Position at t=0.5
+        array([x, y, z])
+    """
+    history = ball.history
+    params = ball.params
+
+    if history.empty:
+        raise ValueError("Cannot interpolate from empty history")
+
+    if not isinstance(timestamps, np.ndarray):
+        timestamps = np.array(timestamps, dtype=np.float64)
+
+    if not np.all(np.diff(timestamps) >= 0):
+        raise ValueError("Timestamps must be in ascending order")
+
+    min_time = history[0].t
+    max_time = history[-1].t
+
+    if not extrapolate and (timestamps[0] < min_time or timestamps[-1] > max_time):
+        raise ValueError(
+            f"Timestamps must be within history time range ({min_time}, {max_time})"
+        )
+
+    result_states = []
+    history_array = history.states
+    history_len = len(history_array)
+
+    idx = 0
+
+    for t in timestamps:
+        if t < min_time:
+            result_states.append(history_array[0].copy())
+            continue
+        elif t > max_time:
+            result_states.append(history_array[-1].copy())
+            continue
+
+        # Find the nearest preceding state in history
+        while idx < history_len - 1 and history_array[idx + 1].t <= t:
+            idx += 1
+
+        # Go back one step if we've advanced too far
+        if history_array[idx].t > t and idx > 0:
+            idx -= 1
+
+        # Get the reference state to evolve from
+        ref_state = history_array[idx]
+
+        if abs(ref_state.t - t) < 1e-10:
+            # The timestamp exactly matches a history state, use it directly
+            result_states.append(ref_state.copy())
+            continue
+
+        evolve_time = t - ref_state.t
+        rvw, s = evolve.evolve_ball_motion(
+            state=ref_state.s,
+            rvw=ref_state.rvw,
+            R=params.R,
+            m=params.m,
+            u_s=params.u_s,
+            u_sp=params.u_sp,
+            u_r=params.u_r,
+            g=params.g,
+            t=evolve_time,
+        )
+
+        result_states.append(BallState(rvw=rvw, s=s, t=t))
+
+    return result_states
diff --git a/pooltool/interact.py b/pooltool/interact.py
index b5180da6..771268ab 100644
--- a/pooltool/interact.py
+++ b/pooltool/interact.py
@@ -23,7 +23,12 @@ def show(*args, **kwargs):
 
             Note:
                 If a multisystem is passed, the systems can be scrolled through by
-                pressing *n* (next) and *p* (previous).
+                pressing *n* (next) and *p* (previous). When using ``pt.show()``,
+                press *Enter* to toggle parallel visualization mode where all systems
+                play simultaneously with reduced opacity except the active one. In
+                parallel mode, use *n* and *p* to change which system has full opacity.
+                Note that parallel visualization is only available in ``pt.show()``
+                and not when playing the game through ``run-pooltool``.
         title:
             The title to display in the visualization. Defaults to an empty string.
         camera_state:
diff --git a/pooltool/objects/ball/render.py b/pooltool/objects/ball/render.py
index 6eede4a3..d420a0db 100644
--- a/pooltool/objects/ball/render.py
+++ b/pooltool/objects/ball/render.py
@@ -203,7 +203,7 @@ def set_quats(self, history):
         ws = rvws[:, 2, :]
         self.quats = autils.as_quaternion(ws, ts)
 
-    def get_playback_sequence(self, playback_speed=1) -> MetaInterval:
+    def get_playback_sequence(self, playback_speed: float = 1.0) -> MetaInterval:
         """Creates the motion sequences of the ball for a given playback speed"""
         if self._ball.history_cts.empty:
             return Sequence()
diff --git a/pooltool/system/render.py b/pooltool/system/render.py
index 52a2e922..4e40763e 100644
--- a/pooltool/system/render.py
+++ b/pooltool/system/render.py
@@ -38,6 +38,7 @@ def from_system(system: System) -> SystemRender:
 class PlaybackMode(StrEnum):
     LOOP = auto()
     SINGLE = auto()
+    PARALLEL = auto()
 
 
 class SystemController:
@@ -49,6 +50,8 @@ def __init__(self) -> None:
         self.paused: bool = True
         self.playback_speed: float = 1
         self.playback_mode: PlaybackMode = PlaybackMode.SINGLE
+        self.parallel_systems: Dict[int, SystemRender] = {}
+        self.is_parallel_mode: bool = False
 
     @property
     def table(self):
@@ -88,8 +91,12 @@ def animation_finished(self):
         """Returns whether or not the animation is finished
 
         Returns true if the animation has stopped and it's not because the game has been
-        paused. The animation is never finished if it's playing in a loop.
+        paused. The animation is never finished if it's playing in a loop or in parallel mode.
         """
+        # Never consider animation finished in LOOP mode or in parallel mode
+        if self.playback_mode == PlaybackMode.LOOP or self.is_parallel_mode:
+            return False
+
         return not self.shot_animation.isPlaying() and not self.paused
 
     def buildup(self) -> None:
@@ -135,7 +142,8 @@ def animate(self, mode: Optional[PlaybackMode] = None):
         if mode is not None:
             self.playback(mode)
 
-        if self.playback_mode == PlaybackMode.LOOP:
+        # In parallel mode, always use LOOP behavior
+        if self.playback_mode == PlaybackMode.LOOP or self.is_parallel_mode:
             self.shot_animation.loop()
         elif self.playback_mode == PlaybackMode.SINGLE:
             self.shot_animation.start()
@@ -146,9 +154,21 @@ def animate(self, mode: Optional[PlaybackMode] = None):
 
     def restart_animation(self) -> None:
         """Set the animation to t=0"""
+        # If animation is completed (in final state), clear it before setting time. This
+        # avoids stdout warnings from Panda3D like:
+        # :interval(warning): CLerpNodePathInterval::priv_step() called for LerpPosQuatInterval-1 in state final.
+        if not self.shot_animation.isPlaying() and not self.paused:
+            self.shot_animation.clearToInitial()
+
         self.shot_animation.set_t(0)
 
     def restart_ball_animations(self) -> None:
+        # If animation is completed (in final state), clear it before setting time. This
+        # avoids stdout warnings from Panda3D like:
+        # :interval(warning): CLerpNodePathInterval::priv_step() called for LerpPosQuatInterval-1 in state final.
+        if not self.ball_animations.isPlaying() and not self.paused:
+            self.ball_animations.clearToInitial()
+
         self.ball_animations.set_t(0)
 
     def toggle_pause(self) -> None:
@@ -204,6 +224,176 @@ def advance_to_end_of_stroke(self):
 
         self.shot_animation.set_t(self.stroke_animation.get_duration())
 
+    def setup_parallel_mode(self) -> None:
+        """Setup all systems for parallel visualization with synchronized animations"""
+        if self.is_parallel_mode:
+            return
+
+        # First, clear any existing animations
+        self.reset_animation(reset_pause=True)
+
+        self.is_parallel_mode = True
+        self.parallel_systems = {}
+
+        # Store current system
+        current_index = multisystem.active_index
+
+        # Create SystemRender for each system in multisystem
+        for idx, system in enumerate(multisystem):
+            # Skip if system doesn't have events (unsimulated)
+            if len(system.events) == 0 and idx != len(multisystem) - 1:
+                continue
+
+            # If this is the active system, we already have it
+            if idx == current_index:
+                self.parallel_systems[idx] = self.system
+                continue
+
+            # Create SystemRender for this system
+            if system.simulated and not system.continuized:
+                continuize(system, inplace=True)
+
+            sys_render = SystemRender.from_system(system)
+            self.parallel_systems[idx] = sys_render
+
+        # Build animations for all systems
+        self._build_parallel_animations(current_index)
+
+    def _build_parallel_animations(self, active_index: int) -> None:
+        """Build synchronized animations for parallel visualization"""
+        if not self.is_parallel_mode:
+            return
+
+        # First, render all systems with appropriate opacity
+        for idx, sys_render in self.parallel_systems.items():
+            if idx == active_index:
+                # Set active system to full opacity
+                for ball in sys_render.balls.values():
+                    if not ball.rendered:
+                        ball.render()
+                    ball.set_alpha(1.0)
+            else:
+                # Render with reduced opacity
+                for ball_id, ball in sys_render.balls.items():
+                    if not ball.rendered:
+                        ball.render()
+                    ball.set_alpha(0.3)
+
+                # Hide cue for non-active systems
+                sys_render.cue.hide_nodes()
+
+        # Now build animations for all systems and combine them
+        all_ball_animations = Parallel()
+        longest_duration = 0
+
+        # First, build all ball animations and find the longest duration
+        for idx, sys_render in self.parallel_systems.items():
+            # For each ball in this system
+            for ball_id, ball in sys_render.balls.items():
+                # Set quaternions for animation
+                ball.set_quats(ball._ball.history_cts)
+
+                # Get ball animation
+                ball_animation = ball.get_playback_sequence(
+                    playback_speed=self.playback_speed
+                )
+                if len(ball_animation) > 0:
+                    all_ball_animations.append(ball_animation)
+
+                    # Check if this animation is longer than current max
+                    if ball_animation.getDuration() > longest_duration:
+                        longest_duration = ball_animation.getDuration()
+
+        # Now, build stroke animation only for active system
+        active_sys = self.parallel_systems[active_index]
+        stroke_animation = Sequence(
+            ShowInterval(active_sys.cue.get_node("cue_stick")),
+            active_sys.cue.get_stroke_sequence(),
+            HideInterval(active_sys.cue.get_node("cue_stick")),
+        )
+
+        # Combine stroke animation with all ball animations
+        self.stroke_animation = stroke_animation
+        self.ball_animations = all_ball_animations
+        self.shot_animation = Sequence(
+            Func(self.restart_ball_animations),
+            stroke_animation,
+            all_ball_animations,
+            Wait(0.5),  # Add a small buffer at the end
+        )
+
+    def _render_parallel_systems(self, active_index: int) -> None:
+        """Update opacity for all rendered systems"""
+        if not self.is_parallel_mode:
+            return
+
+        # Update opacity for all systems
+        for idx, sys_render in self.parallel_systems.items():
+            if idx == active_index:
+                # Set active system to full opacity
+                for ball in sys_render.balls.values():
+                    ball.set_alpha(1.0)
+                sys_render.cue.show_nodes()
+            else:
+                # Set non-active systems to reduced opacity
+                for ball in sys_render.balls.values():
+                    ball.set_alpha(0.3)
+                sys_render.cue.hide_nodes()
+
+    def exit_parallel_mode(self) -> None:
+        """Exit parallel visualization mode"""
+        if not self.is_parallel_mode:
+            return
+
+        # Store animation state
+        was_playing = not self.paused
+        current_time = 0
+        if len(self.shot_animation) > 0:
+            current_time = self.shot_animation.get_t()
+
+        # Pause the animation to prevent errors during transition
+        self.pause_animation()
+
+        # Clear the animation before removing nodes
+        self.reset_animation(reset_pause=True)
+
+        # Remove all non-active systems
+        active_idx = multisystem.active_index
+        for idx, sys_render in self.parallel_systems.items():
+            if idx != active_idx:
+                for ball in sys_render.balls.values():
+                    ball.remove_nodes()
+                sys_render.cue.remove_nodes()
+
+        # Keep reference to active system
+        active_system = self.parallel_systems.get(active_idx)
+        self.system = active_system  # Ensure system is still set properly
+
+        # Reset state
+        self.parallel_systems = {}
+        self.is_parallel_mode = False
+
+        # Rebuild animation for the active system only
+        self.build_shot_animation()
+
+        # Restore animation state
+        if was_playing:
+            self.animate(PlaybackMode.LOOP)
+            # Try to restore animation time position if possible
+            if current_time > 0 and current_time <= self.shot_animation.getDuration():
+                self.shot_animation.set_t(current_time)
+
+    def update_parallel_active(self, new_index: int) -> None:
+        """Update which system is active in parallel mode"""
+        if not self.is_parallel_mode:
+            return
+
+        # Update system opacities
+        self._render_parallel_systems(new_index)
+
+        # We don't need to rebuild animations since we're just changing opacities
+        # The animations should continue playing from where they were
+
     def build_shot_animation(
         self,
         animate_stroke: bool = True,
@@ -233,6 +423,9 @@ def build_shot_animation(
             )
             return
 
+        if not self.system.cue.rendered:
+            self.system.cue.render()
+
         self.stroke_animation = Sequence(
             ShowInterval(self.system.cue.get_node("cue_stick")),
             self.system.cue.get_stroke_sequence(),
diff --git a/tests/evolution/test_continuize.py b/tests/evolution/test_continuize.py
index e93e7ac4..ff0b03ed 100644
--- a/tests/evolution/test_continuize.py
+++ b/tests/evolution/test_continuize.py
@@ -1,4 +1,7 @@
-from pooltool.evolution.continuize import continuize
+import numpy as np
+import pytest
+
+from pooltool.evolution.continuize import continuize, interpolate_ball_states
 from pooltool.evolution.event_based.simulate import simulate
 from pooltool.system import System
 
@@ -30,3 +33,80 @@ def test_continuize_inplace():
 
     # They are the same object
     assert continuized_system is system
+
+
+def test_interpolate_ball_states_exact_match():
+    """Test interpolation at exact timestamps from history."""
+    # Simulate and continuize a system
+    system = simulate(System.example())
+    ball = system.balls["cue"]
+
+    # Pick specific timestamps from history
+    t0 = ball.history[3].t
+    t1 = ball.history[4].t
+    timestamps = np.array([t0, t1])
+
+    # Interpolate at those exact timestamps
+    states = interpolate_ball_states(ball, timestamps)
+
+    # Should match exactly
+    assert len(states) == 2
+    assert states[0] == ball.history[3]
+    assert states[1] == ball.history[4]
+
+
+def test_interpolate_ball_states_intermediate():
+    """Test interpolation at timestamps between history entries."""
+    system = simulate(System.example())
+    ball = system.balls["cue"]
+
+    # Pick a timestamp between two history entries
+    t0 = ball.history[0].t
+    t1 = ball.history[1].t
+    t_mid = (t0 + t1) / 2
+
+    # Interpolate at the midpoint
+    states = interpolate_ball_states(ball, [t_mid])
+
+    # Should be a valid state
+    assert states[0].t == t_mid
+
+
+def test_interpolate_ball_states_out_of_range():
+    """Test behavior when timestamps are outside the history range."""
+    # Simulate a system
+    system = simulate(System.example())
+    ball = system.balls["cue"]
+
+    # Timestamp before history starts
+    t_before = ball.history[0].t - 1.0
+
+    # Timestamp after history ends
+    t_after = ball.history[-1].t + 1.0
+
+    # Raises error when extrapolate is False
+    with pytest.raises(ValueError):
+        interpolate_ball_states(ball, [t_before])
+
+    with pytest.raises(ValueError):
+        interpolate_ball_states(ball, [t_after])
+
+    # Returns boundary states when extrapolate is True
+    before_states = interpolate_ball_states(ball, [t_before], extrapolate=True)
+    assert len(before_states) == 1
+    assert before_states[0] == ball.history[0]
+
+    after_states = interpolate_ball_states(ball, [t_after], extrapolate=True)
+    assert len(after_states) == 1
+    assert after_states[0] == ball.history[-1]
+
+
+def test_interpolate_ball_states_empty_history():
+    """Test behavior with empty history."""
+    # Create a ball with empty history
+    system = System.example()
+    ball = system.balls["cue"]
+
+    # Should raise error
+    with pytest.raises(ValueError, match="Cannot interpolate from empty history"):
+        interpolate_ball_states(ball, [0.5])


Reviews:
user: login: coderabbitai[bot]
body: **Actionable comments posted: 2**

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>pooltool/system/render.py (4)</summary><blockquote>

`53-54`: **Document parallel attributes**  
Consider adding docstrings or comments explaining how `parallel_systems` and `is_parallel_mode` are intended to be used, especially for future maintainers.

---

`161-166`: **Avoid repetition with ball animations**  
The logic to clear and reset animations is practically the same as in `restart_animation()`. Consider extracting a helper to keep the code DRY.

---

`258-319`: **Remove or parameterize hard-coded wait**  
The `Wait(0.5)` at the end is a magic number that may not be ideal across different scenarios. Consider making it configurable or removing it if unneeded.

---

`320-337`: **Consider smooth transitions**  
Currently, inactive systems are assigned a constant alpha of `0.3`. If you want a more polished user experience, consider using an animated fade or a configurable opacity constant.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Pro**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 641c030f0af62c30bda84b2a3a649b9d780ed7a3 and 1beaab8d938b248765c3ae54b08368f1b1c59d3c.

</details>

<details>
<summary>📒 Files selected for processing (5)</summary>

* `pooltool/ani/animate.py` (2 hunks)
* `pooltool/ani/modes/shot.py` (5 hunks)
* `pooltool/interact.py` (1 hunks)
* `pooltool/objects/ball/render.py` (1 hunks)
* `pooltool/system/render.py` (5 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>🧬 Code Definitions (2)</summary>

<details>
<summary>pooltool/objects/ball/render.py (1)</summary><blockquote>

<details>
<summary>pooltool/objects/ball/datatypes.py (1)</summary>

* `vectorize` (196-262)

</details>

</blockquote></details>
<details>
<summary>pooltool/ani/animate.py (1)</summary><blockquote>

<details>
<summary>pooltool/system/render.py (1)</summary>

* `exit_parallel_mode` (338-379)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (4)</summary>

* GitHub Check: test (3.9, 1.8.3, ubuntu-latest)
* GitHub Check: test (3.9, 1.8.3, windows-latest)
* GitHub Check: test (3.12, 1.8.3, ubuntu-latest)
* GitHub Check: test (3.12, 1.8.3, windows-latest)

</details>

<details>
<summary>🔇 Additional comments (13)</summary><blockquote>

<details>
<summary>pooltool/interact.py (1)</summary>

`26-31`: **Documentation enhancement for parallel visualization is clear and helpful.**

The updated docstring provides clear instructions on how to use the new parallel visualization feature, explaining that:
- Enter toggles parallel mode where all systems play simultaneously
- Only the active system has full opacity in parallel mode
- Users can navigate between systems with n/p keys
- This feature is only available in pt.show() and not in run-pooltool

This helps users understand and utilize the new functionality effectively.

</details>
<details>
<summary>pooltool/objects/ball/render.py (1)</summary>

`206-206`: **Type hint improvement enhances code clarity.**

Adding the explicit float type hint for playback_speed provides better documentation and clarity about the expected parameter type.

</details>
<details>
<summary>pooltool/ani/animate.py (2)</summary>

`167-169`: **Good cleanup implementation for parallel mode.**

Adding a check to exit parallel mode when closing a scene ensures proper cleanup and prevents potential rendering issues or state inconsistencies in subsequent scene openings.

---

`373-378`: **Documentation for parallel visualization enhances usability.**

The detailed explanation of how to use the parallel visualization mode in the docstring is clear and matches the documentation in `interact.py`, maintaining consistency across the codebase.

</details>
<details>
<summary>pooltool/ani/modes/shot.py (4)</summary>

`102-104`: **Mode-specific event registration ensures correct behavior.**

Conditionally registering the "enter-up" keymap event only when in ShotViewer mode correctly restricts parallel visualization to appropriate contexts, preventing it from being activated during gameplay.

---

`125-128`: **Enhanced exit logic prevents state inconsistencies.**

Always ensuring parallel mode is exited regardless of view_only status is a robust approach that prevents potential state leakage between mode transitions, particularly if the view_only status changes.

---

`259-282`: **Well-structured implementation of parallel mode toggle.**

The parallel mode toggle implementation:
1. Preserves animation state (was_playing) during mode transitions
2. Properly handles both entry and exit from parallel mode
3. Correctly resets and resumes animations as needed

This ensures smooth transitions between visualization modes.

---

`316-338`: **Animation state management handles both modes appropriately.**

The enhanced `change_animation` method now:
1. Preserves animation playing status during transitions
2. Uses different approaches for standard mode vs. parallel mode
3. Updates UI elements appropriately in both cases

This provides a seamless user experience when navigating between shots in either mode.

</details>
<details>
<summary>pooltool/system/render.py (5)</summary>

`41-41`: **Add parallel mode enum**  
Great addition of the `PARALLEL` enum value; no concerns here.

---

`150-157`: **Prevent Panda3D warnings**  
Good job clearing the animation before resetting time to avoid interval warnings. Implementation looks correct.

---

`381-391`: **Straightforward active system update**  
Switching the active system’s opacity without rebuilding animations is both efficient and clear. No further issues here.

---

`421-423`: **Initialize cue render**  
Smart check to render the cue if it wasn’t rendered previously. The logic is consistent with the rest of this method.

---

`338-380`: <details>
<summary>❓ Verification inconclusive</summary>

**Confirm cleanup of unused systems**  
The teardown correctly removes inactive system nodes. However, ensure there are no lingering references to ball or cue objects that could cause memory leaks.  

Run the following script to see if references to removed systems remain:

---

🏁 Script executed:

```shell
#!/bin/bash
# Searches for references to parallel systems or cues after exit_parallel_mode calls
rg "exit_parallel_mode" -A10
```

Length of output: 2789

---



</details>

**Action Required: Confirm Memory Cleanup for Removed Systems**

Based on the grep output, the `exit_parallel_mode` function is being correctly invoked from multiple locations, and it appears to clear inactive system nodes via calls to `ball.remove_nodes()` and `sys_render.cue.remove_nodes()`. However, please verify that these methods fully remove internal references to avoid potential memory leaks.

- **Review and confirm** that both `remove_nodes()` implementations for ball and cue objects completely deregister all references.
- **Double-check** with a memory profiling tool if possible to ensure no lingering references exist after exiting parallel mode.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-31T03:01:15Z

Review comments:
diff_hunk: @@ -203,12 +203,11 @@
         ws = rvws[:, 2, :]
         self.quats = autils.as_quaternion(ws, ts)
 
-    def get_playback_sequence(self, playback_speed=1) -> MetaInterval:
+    def get_playback_sequence(self, playback_speed: float = 1.0) -> MetaInterval:
         """Creates the motion sequences of the ball for a given playback speed"""
-        if self._ball.history_cts.empty:
-            return Sequence()
-
         vectors = self._ball.history_cts.vectorize()
+        if vectors is None:
+            return Sequence()
path: pooltool/objects/ball/render.py
user: login: coderabbitai[bot]
body: _💡 Verification agent_

<details>
<summary>🧩 Analysis chain</summary>

**Verify error handling logic for vectorize method.**

The error handling has been changed to check if `vectors is None`, but according to the `vectorize()` method implementation in `ball/datatypes.py`, it raises a `ValueError` when history is empty rather than returning `None`.

---

🏁 Script executed:

```shell
#!/bin/bash
# Check how vectorize is called and whether it could return None

# Search for any try/except blocks around vectorize calls
rg -B 5 -A 5 "try.*vectorize\(\)" --type py

# Look at the implementation of vectorize
rg -B 5 -A 10 "def vectorize" --type py
```

Length of output: 1201

---



</details>

**Attention: Inconsistent Error Handling for the `vectorize()` Method**

The check in `pooltool/objects/ball/render.py` (lines 209–210) is verifying if `vectors` is `None` to handle error cases. However, the implementation of `vectorize()` in `pooltool/objects/ball/datatypes.py` does not return `None`—it builds and returns a history object, and in cases where the history is empty, it is expected to raise a `ValueError`. This discrepancy means that the current `None` check will never catch the error condition as intended.

- **Location:** `pooltool/objects/ball/render.py` (lines 209–210)
- **Issue:** The error handling strategy does not match the behavior of `vectorize()` in `pooltool/objects/ball/datatypes.py`
- **Suggestion:**  
  - Either wrap the call to `vectorize()` in a try/except block that catches the expected `ValueError` and handles it appropriately,  
  - Or modify the `vectorize()` method to return `None` when the history is empty if that aligns better with the intended control flow.

Please update the code to resolve this inconsistency.

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-31T03:01:14Z
author_association: NONE
diff_hunk: @@ -204,6 +219,176 @@
 
         self.shot_animation.set_t(self.stroke_animation.get_duration())
 
+    def setup_parallel_mode(self) -> None:
+        """Setup all systems for parallel visualization with synchronized animations"""
+        if self.is_parallel_mode:
+            return
+
+        # First, clear any existing animations
+        self.reset_animation(reset_pause=True)
+
+        self.is_parallel_mode = True
+        self.parallel_systems = {}
+
+        # Store current system
+        current_index = multisystem.active_index
+
+        # Create SystemRender for each system in multisystem
+        for idx, system in enumerate(multisystem):
+            # Skip if system doesn't have events (unsimulated)
+            if len(system.events) == 0 and idx != len(multisystem) - 1:
+                continue
+
+            # If this is the active system, we already have it
+            if idx == current_index:
+                self.parallel_systems[idx] = self.system
+                continue
+
+            # Create SystemRender for this system
+            if system.simulated and not system.continuized:
+                continuize(system, inplace=True)
+
+            sys_render = SystemRender.from_system(system)
+            self.parallel_systems[idx] = sys_render
+
+        # Build animations for all systems
+        self._build_parallel_animations(current_index)
+
+    def _build_parallel_animations(self, active_index: int) -> None:
path: pooltool/system/render.py
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Handle empty or partially simulated multisystem**  
If `multisystem` is empty or contains only unsimulated systems (except possibly the last one), this loop might skip key setup steps and leave the controller in an inconsistent state.  

Would you like to add safeguards for an empty or incomplete `multisystem`? For example, checking `if len(multisystem) == 0: return` early, or validating at least one system is simulated before proceeding?

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-03-31T03:01:14Z
author_association: NONE

PR comments:
user: login: coderabbitai[bot]
created_at: 2025-03-31T02:57:43Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough
The changes enhance the pooltool application’s handling of parallel visualization mode. Updates include additional state checks and cleanup procedures in scene and shot closing routines, expanded documentation on interactive controls for toggling parallel visualization, and refined error handling in playback sequence generation. The SystemController now supports parallel mode with new attributes and methods to initialize, build, render, update, and exit parallel animations, while shot mode logic is updated with conditional key event handling.

## Changes

| File(s)                                         | Change Summary                                                                                                                                                                                                                     |
|-------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| pooltool/ani/animate.py,<br>pooltool/interact.py  | Added state checks in `close_scene` and expanded docstrings in `show` to detail the toggle (Enter key) for parallel visualization mode (active in `pt.show()` only).                                                       |
| pooltool/ani/modes/shot.py                       | Introduced conditional keymap registration for "enter-up" when in view-only (ShotViewer) mode; refined shot animation task to handle transitions into/out of parallel visualization, including clean exit and animation resetting. |
| pooltool/objects/ball/render.py                  | Updated `get_playback_sequence` to use a float default (`1.0`) for `playback_speed` and modified error handling by replacing an empty history check with a `None` check on vectorized data.                                  |
| pooltool/system/render.py                        | Added comprehensive support for parallel visualization in SystemController: new `PARALLEL` mode in enum, attributes (`parallel_systems`, `is_parallel_mode`), and methods (`setup_parallel_mode`, `_build_parallel_animations`, `_render_parallel_systems`, `exit_parallel_mode`, `update_parallel_active`). |
| pooltool/__init__.py,<br>pooltool/evolution/__init__.py,<br>pooltool/evolution/continuize.py | Added `interpolate_ball_states` function to imports and `__all__` lists, enhancing module accessibility and functionality for ball state interpolation.                                                                 |
| tests/evolution/test_continuize.py               | Introduced tests for `interpolate_ball_states`, covering exact matches, intermediate timestamps, out-of-range errors, and empty history scenarios, ensuring robust functionality and error handling.                             |

## Sequence Diagram(s)

```mermaid
sequenceDiagram
    participant User
    participant Interface
    participant SystemController
    participant Visual

    User->>Interface: Press Enter (toggle parallel mode)
    Interface->>SystemController: Invoke setup_parallel_mode()
    SystemController->>SystemController: Clear animations & set is_parallel_mode = True
    SystemController->>SystemController: Build parallel animations (_build_parallel_animations)
    SystemController->>Visual: Render systems with adjusted opacity
    User->>Interface: Close scene
    Interface->>SystemController: Call exit_parallel_mode()
```

```mermaid
sequenceDiagram
    participant User
    participant ShotViewer
    participant ModeHandler

    User->>ShotViewer: Press "enter-up" key in ShotViewer mode
    ShotViewer->>ModeHandler: Execute shot_animation_task()
    alt Parallel mode is active
        ModeHandler->>ModeHandler: Exit parallel mode & change to active index
    else Not in parallel mode
        ModeHandler->>ModeHandler: Pause & reset current animation
        ModeHandler->>ModeHandler: Enter parallel mode and resume if previously playing
    end
```

## Poem
> I am a bunny, hopping through the code,  
> New parallel paths lighten my humble abode.  
> With toggles and checks, each scene comes alive,  
> In animations and transitions, watch the systems thrive.  
> I celebrate the clean flow with a joyful leap,  
> Coding magic that makes my circuits beep! 🐰✨

<!-- walkthrough_end -->


---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: CodeRabbit UI**
**Review profile: CHILL**
**Plan: Free**

<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 641c030f0af62c30bda84b2a3a649b9d780ed7a3 and 30b2ce6dc9b62ba5a9dfe7408304998b075cd652.

</details>

<details>
<summary>📒 Files selected for processing (9)</summary>

* `pooltool/__init__.py` (2 hunks)
* `pooltool/ani/animate.py` (2 hunks)
* `pooltool/ani/modes/shot.py` (5 hunks)
* `pooltool/evolution/__init__.py` (1 hunks)
* `pooltool/evolution/continuize.py` (2 hunks)
* `pooltool/interact.py` (1 hunks)
* `pooltool/objects/ball/render.py` (1 hunks)
* `pooltool/system/render.py` (7 hunks)
* `tests/evolution/test_continuize.py` (2 hunks)

</details>

</details>
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=ekiefl/pooltool&utm_content=202):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: NONE
user: login: codecov[bot]
created_at: 2025-03-31T03:00:30Z
body: ## [Codecov](https://app.codecov.io/gh/ekiefl/pooltool/pull/202?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl) Report
Attention: Patch coverage is `26.74419%` with `126 lines` in your changes missing coverage. Please review.
> Project coverage is 44.46%. Comparing base [(`0e89b7f`)](https://app.codecov.io/gh/ekiefl/pooltool/commit/0e89b7f33fe1f559523d1bea00f9d45912dbb169?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl) to head [(`30b2ce6`)](https://app.codecov.io/gh/ekiefl/pooltool/commit/30b2ce6dc9b62ba5a9dfe7408304998b075cd652?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl).
> Report is 21 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/ekiefl/pooltool/pull/202?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl) | Patch % | Lines |
|---|---|---|
| [pooltool/system/render.py](https://app.codecov.io/gh/ekiefl/pooltool/pull/202?src=pr&el=tree&filepath=pooltool%2Fsystem%2Frender.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl#diff-cG9vbHRvb2wvc3lzdGVtL3JlbmRlci5weQ==) | 8.16% | [90 Missing :warning: ](https://app.codecov.io/gh/ekiefl/pooltool/pull/202?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl) |
| [pooltool/ani/modes/shot.py](https://app.codecov.io/gh/ekiefl/pooltool/pull/202?src=pr&el=tree&filepath=pooltool%2Fani%2Fmodes%2Fshot.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl#diff-cG9vbHRvb2wvYW5pL21vZGVzL3Nob3QucHk=) | 0.00% | [29 Missing :warning: ](https://app.codecov.io/gh/ekiefl/pooltool/pull/202?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl) |
| [pooltool/evolution/continuize.py](https://app.codecov.io/gh/ekiefl/pooltool/pull/202?src=pr&el=tree&filepath=pooltool%2Fevolution%2Fcontinuize.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl#diff-cG9vbHRvb2wvZXZvbHV0aW9uL2NvbnRpbnVpemUucHk=) | 87.50% | [5 Missing :warning: ](https://app.codecov.io/gh/ekiefl/pooltool/pull/202?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl) |
| [pooltool/ani/animate.py](https://app.codecov.io/gh/ekiefl/pooltool/pull/202?src=pr&el=tree&filepath=pooltool%2Fani%2Fanimate.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl#diff-cG9vbHRvb2wvYW5pL2FuaW1hdGUucHk=) | 0.00% | [2 Missing :warning: ](https://app.codecov.io/gh/ekiefl/pooltool/pull/202?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #202      +/-   ##
==========================================
+ Coverage   44.22%   44.46%   +0.24%     
==========================================
  Files         127      132       +5     
  Lines        8554     9128     +574     
==========================================
+ Hits         3783     4059     +276     
- Misses       4771     5069     +298     
```

| [Flag](https://app.codecov.io/gh/ekiefl/pooltool/pull/202/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl) | Coverage Δ | |
|---|---|---|
| [service](https://app.codecov.io/gh/ekiefl/pooltool/pull/202/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl) | `44.46% <26.74%> (+0.24%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/ekiefl/pooltool/pull/202?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Evan+Kiefl).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE