Note
This is under construction (work in progress)!
Modular Physics¶
One of pooltool’s founding ambitions is completely customizable physics.
Loosely speaking, the physics in pooltool can be simplified into two categories:
Evolution - refers to the physics that governs ball trajectories over time in the absence of interupting collisions.
Resolution - refers to the physics that resolves the outcome of a collision, e.g. a ball-ball collision or ball-cushion collision.
Evolution¶
Unfortunately, evolution physics are not yet modular. That means the ball trajectories are governed by the equations presented in this blog and no other equations can be easily substituted in without overwriting these equations in the source code.
The equations can still be modified by changing parameters governing the trajectories, like ball mass, radius, and coefficients of friction, but no matter how you change these parameters, it’s still the same underlying model.
Resolution¶
Pooltool supports modular physics for resolving events. To clarify, events refer to either collisions or transitions. The way in which an event is resolved depends on the strategy you choose to employ.
Below details how you can either (a) plug in models that already exist in the codebase and (b) write your own custom models.
Modify¶
This section guides you on how to switch between existing models within the codebase.
The handling of events is determined by the resolver configuration file, which is located at ~/.config/pooltool/physics/resolver.yaml
. Here’s an example of what that looks like:
ball_ball: frictionless_elastic
ball_ball_params: {}
ball_circular_cushion: han_2005
ball_circular_cushion_params: {}
ball_linear_cushion: han_2005
ball_linear_cushion_params: {}
ball_pocket: canonical
ball_pocket_params: {}
stick_ball: instantaneous_point
stick_ball_params:
throttle_english: true
transition: canonical
transition_params: {}
The resolver configuration file is automatically generated during the initial execution. If you don’t have one yet, execute pooltool with the command run_pooltool
, start a new game, and take a shot--one will be generated.
Models are identified by their names. You can view the names of all available models by executing the following command:
from pooltool.physics.resolve.ball_ball import BallBallModel
from pooltool.physics.resolve.ball_cushion import BallCCushionModel, BallLCushionModel
from pooltool.physics.resolve.ball_pocket import BallPocketModel
from pooltool.physics.resolve.stick_ball import StickBallModel
from pooltool.physics.resolve.transition import BallTransitionModel
print("ball-ball models:")
for model in BallBallModel: print(f"\t{model}")
print("ball-linear cushion models:")
for model in BallLCushionModel: print(f"\t{model}")
print("ball-circular cushion models:")
for model in BallCCushionModel: print(f"\t{model}")
print("stick-ball models:")
for model in StickBallModel: print(f"\t{model}")
print("ball-pocket models:")
for model in BallPocketModel: print(f"\t{model}")
print("ball-transition models:")
for model in BallTransitionModel: print(f"\t{model}")
Each model lives in its own directory. For example, the frictionless_elastic
ball-ball collision model is found in pooltool/physics/resolve/ball_ball/frictionless_elastic/__init__.py
with the class name FrictionlessElastic
. FrictionlessElastic
doesn’t take any parameters, so in the resolver config the parameter field is empty:
ball_ball: frictionless_elastic
ball_ball_params: {}
But some models do take parameters: The stick-ball collision model instantaneous_point
has a parameter throttle_english
, which you can see is an initialization parameter of the class InstantaneousPoint
housed in pooltool/physics/resolve/stick_ball/instantaneous_point/__init__.py
:
@attrs.define
class InstantaneousPoint(CoreStickBallCollision):
throttle_english: bool
def solve(self, cue: Cue, ball: Ball) -> Tuple[Cue, Ball]:
(...)
To set this parameter to False
, modify stick_ball_params
:
stick_ball: instantaneous_point
stick_ball_params:
throttle_english: false
Currently, the easiest way to see which parameters are available for a model is to dig through the source code, specifically in pooltool/physics/resolve/*
.
What happens at runtime?¶
This may be a useful section for you if you want to learn more about how this all works.
Resolver
¶
The cornerstone of event resolution is pooltool.physics.resolve.resolver.Resolver
. An instance of Resolver
is either passed to or generated by the shot evolution algorithm, and it is this instance that exclusively determines how events are resolved.
This is the structure of Resolver
:
class Resolver:
ball_ball: BallBallCollisionStrategy
ball_linear_cushion: BallLCushionCollisionStrategy
ball_circular_cushion: BallCCushionCollisionStrategy
ball_pocket: BallPocketStrategy
stick_ball: StickBallCollisionStrategy
transition: BallTransitionStrategy
Each attribute is a physics strategy (or model) for each event class: ball-ball collisions, ball-linear cushion collisions, ball-circular cushion collisions, ball-pocket “collisions”, and ball motion transitions.
While it’s possible to create a Resolver
object by instantiating physics strategies for each event class, this programmatic interface is complex to replicate. A more user-friendly approach would be to serialize the desired physics strategies into a configuration file. This is the function of ResolverConfig
. As a dataclass, it supports seamless serialization and deserialization, allowing all information needed to create a Resolver
object to be conveniently stored in a YAML file.
The structure of ResolverConfig
is as follows:
class ResolverConfig:
ball_ball: BallBallModel
ball_ball_params: ModelArgs
ball_linear_cushion: BallLCushionModel
ball_linear_cushion_params: ModelArgs
ball_circular_cushion: BallCCushionModel
ball_circular_cushion_params: ModelArgs
ball_pocket: BallPocketModel
ball_pocket_params: ModelArgs
stick_ball: StickBallModel
stick_ball_params: ModelArgs
transition: BallTransitionModel
transition_params: ModelArgs
def save(self, path: Pathish) -> Path:
# Code to serialize the object to YAML and save it to a file...
@classmethod
def load(cls, path: Pathish) -> ResolverConfig:
# Code to load a YAML file and deserialize it into a ResolverConfig object...
You might observe that this closely resembles the YAML configuration file. That’s because the resolver.yaml
file is simply a serialization of this class. When you modify resolver.yaml
, a ResolverConfig
is created from the resolver.yaml
file using ResolverConfig.load(...)
during runtime. This configuration is then used to build the Resolver
object employed by the shot evolution algorithm, using Resolver.from_config(resolver_config)
.
Creating new physics models¶
To demonstrate how you can integrate your own physics model into pooltool, I’ll be incorporating a mock ball-cushion model suitable for both linear and circular segments. I’ll outline the general steps here, and for each step, I’ll include a link to a commit where you can view the specific files I modified. By following these patterns, the process should be quite straightforward.
Ok let’s get started.
Create a directory¶
First, we need to establish a model within its own dedicated directory. This directory should be named after the model. As I’m using a simple toy example, I’ll name mine unrealistic
. The directory should be located in one of the pooltool/physics/resolve/*
folders, depending on the event class your model manages. Since I’m constructing a ball-cushion model, I’ll create the unrealistic
folder in pooltool/physics/resolve/ball_cushion/
.
Within your model directory, create an __init__.py
file. If your model is simple, all your model logic can be contained within this single file. However, if your model grows complex, feel free to expand it across multiple files, provided they’re kept within your model directory.
Here’s the example code: 7ded13254150cdebb09013fa35e6fe0846d59ea9
Create the template¶
Regardless of how you choose to structure your code, it must eventually lead to a class that:
Contains a method named
solve
.Inherits from the core model.
The call signature of your solve
method and the core model from which you inherit will depend on the event class for which you’re developing a model.
Since I’m developing a ball-cushion model, I’ll refer to pooltool/physics/resolve/ball_cushion/core.py
for this information. Below is the required call signature for my solve
method:
class BallLCushionCollisionStrategy(_BaseLinearStrategy, Protocol):
def solve(
self, ball: Ball, cushion: LinearCushionSegment
) -> Tuple[Ball, LinearCushionSegment]:
...
It takes a ball and linear cushion, and then returns a ball and linear cushion. Simple enough.
And here is the required core model:
class CoreBallLCushionCollision(ABC):
"""Operations used by every ball-linear cushion collision resolver"""
(...)
With these, we can create our template:
"""An unrealistic ball-cushion model"""
from typing import Tuple
from pooltool.objects.ball.datatypes import Ball
from pooltool.objects.table.components import LinearCushionSegment
from pooltool.physics.resolve.ball_cushion.core import CoreBallLCushionCollision
class UnrealisticLinear(CoreBallLCushionCollision):
def solve(
self, ball: Ball, cushion: LinearCushionSegment
) -> Tuple[Ball, LinearCushionSegment]:
return ball, cushion
Here’s the example code: af507032217914629e53954965c982d21fdc8094
As you can see, resolve
currently does nothing, it just returns what is handed to it.
Implement the logic¶
Note
You may prefer registering and activating your model before you start implementing the logic. Even though your model doesn’t do anything at this point, you may prefer registering and activating it now, so that you can make changes, and immediately see how your implementation affects a test case.
This is where you come in, but there are a few points to make. First, I really like type hints, but I remember a time when I didn’t. If that’s you, don’t worry about them--or any other conventions I follow, for that matter. This is your code, just do your thing and don’t get overwhelmed in my conventions.
Second, since you’ll be working with the core pooltool objects Cue
, Ball
, LinearCushionSegment
, CircularCushionSegment
, and Pocket
, it is worth scanning their source code to determine what parameters they have, and therefore what tools you have at your disposal.
Anyways, here’s my preliminary implementation: 17510e7d014c8aa5e60d6556db2e5b0dea36f2f0
Then I added a parameter to the model to add some flavor and complexity. Note that the model parameters should not be things like mass or friction coefficients. Those are properties of the passed objects. If you think a property is missing for an object, we can add it to the object. Model parameters are more meta/behavioral (see the below example).
Please note that the resolver config can only handle strings, booleans, floats, and integers for model parameters due to serialization requirements. If you have more complex model types like functions, try and simplify the passed argument to a string by string-lookup dictionary.
Here’s me adding a model parameter that dictates whether or not the outgoing speed should be dampened with the ball’s restitution coefficient: ec42752f381edf3d576a66a9178a27d6054ff437
Register the model¶
Your model is in the codebase, but no other part of the codebase knows about it yet. Changing that is simple.
Open the __init__.py
file corresponding to your event class:
pooltool/physics/resolve/ball_ball/__init__.py
pooltool/physics/resolve/ball_cushion/__init__.py
pooltool/physics/resolve/ball_pocket/__init__.py
pooltool/physics/resolve/stick_ball/__init__.py
pooltool/physics/resolve/transition/__init__.py
You’ll need to modify two objects.
First, is an Enum that holds the collection of all model names for a given event-class. You can find it by searching for a class that inherits from StrEnum
. Here is the ball linear cushion Enum:
class BallLCushionModel(StrEnum):
HAN_2005 = auto()
Add a new element to this Enum that will correspond to your model, like I’ve done here:
class BallLCushionModel(StrEnum):
HAN_2005 = auto()
UNREALISTIC = auto()
Technically the name here is arbitrary, but it makes good sense to have it match your model name.
Second, you’ll have to modify a dictionary that associates model names to model classes. It’s in the same file. Mine was this:
_ball_lcushion_models: Dict[BallLCushionModel, Type[BallLCushionCollisionStrategy]] = {
BallLCushionModel.HAN_2005: Han2005Linear,
}
And I made it this:
_ball_lcushion_models: Dict[BallLCushionModel, Type[BallLCushionCollisionStrategy]] = {
BallLCushionModel.HAN_2005: Han2005Linear,
BallLCushionModel.UNREALISTIC: UnrealisticLinear,
}
I needed to import my new model, so I put this at the top of the file:
from pooltool.physics.resolve.ball_cushion.unrealistic import UnrealisticLinear
Here are the full changes: 9c4d9ad2dc6bae3848bfc9973f150b864e07db06
Activate the model¶
In order to apply your new model using pooltool, you’ll need to modify the resolver configuration file, which is found at ~/.config/pooltool/physics/resolver.yaml
. This file is automatically generated upon the first run-time. Therefore, if it’s missing, simply execute the command python sandbox/break.py
from the pooltool root directory to generate it.
Next, you need to replace the existing model name with the name of your new model. It’s important to note that the model name should be the lower case version of the Enum attribute. For instance, if my Enum attribute is UNREALISTIC
, I would input unrealistic
for ball_linear_cushion
.
If your model doesn’t require parameters, you should set the _params
key to {}
. However, if your model does have parameters, you should list them, along with their corresponding values. For example, if my model includes a parameter, I would specify its value as follows:
ball_linear_cushion: unrealistic
ball_linear_cushion_params:
restitution: true
That’s it. If everything runs without error, your model is active.
Note, there is no git commit to show you here, because ~/.config/pooltool/physics/resolver.yaml
isn’t part of the project--it’s part of your personal workspace.
Addendum: ball-cushion models¶
This is instructions for taking your ball-linear cushion model and creating a corresponding ball-circular cushion model.
The ball-cushion collision has two event classes: collision of the ball with (1) linear cushion segments and (2) circular cushion segments. I developed circular cushion segments to smoothly join two linear cushion segments, e.g. to create the jaws of a pocket.
You could have separate models for circular and linear cushion segment collisions, or if you’re lazy they could mimic each other. In this git commit, I illustrate how you can use the same model for both collision types, assuming you’ve already implemented the linear cushion segment: f4b91e436976fb857bf7681fcb6458c3ae1e6377
If you want to activate the model, don’t forget to modify the resolver config.