Source code for packing

# -*- coding: utf-8 -*-
import pandas as pd
import numpy as np

from scipy.spatial import distance

import math
from sklearn import preprocessing


class calculate_packing:

    def __init__(self):
        self.packing_rate = 0

    def get_pass_direction(self, sender, receiver, goal, defend_side):
        """
        Get Pass Direction

        Returns:
            Forward/Back/Side
        """
        if defend_side == 'left':
            goal_sender = [0, sender[1]]
            goal_receiver = [0, receiver[1]]
        elif defend_side == 'right':
            goal_sender = [1, sender[1]]
            goal_receiver = [1, receiver[1]]

        # Distance of 3 sides sender-receiver-goal triangle
        d_sg = np.round(np.linalg.norm(sender-goal_sender), 5)
        d_rg = np.round(np.linalg.norm(
            receiver-goal_receiver), 5)

        if (d_rg < d_sg) and (np.abs(d_rg-d_sg) > 0.03):
            return 'Forward'
        elif (d_rg > d_sg) and (np.abs(d_rg-d_sg) > 0.03):
            return 'Back'
        else:
            return 'Side'

    def method_1(self, box_a, box_b, box_c, box_d, df_method1, col_label_x, col_label_y, rect_thresh=0.010):
        """
        Method 1 :
        Draw a rectangle box between sender and receiver to see if any player
        is inside the bounding box. A rect_thresh of 0.01 is used to consider players on the
        edge of the box.

        Parameters
        ----------
        box_a : ndarray
            A ndarray of ['sender_x', 'sender_y']
        box_b : ndarray
            A ndarray of ['sender_x', 'receiver_y']
        box_c : ndarray
            A ndarray of ['receiver_x', 'receiver_y']
        box_d : ndarray
            A ndarray of ['receiver_x', 'sender_y']
        df_method1 : DataFrame
            A copy of defending_team_xy dataframe
        col_label_x : String
            The column label for defending team's X coordinate in `defending_team_xy`
        col_label_y : String
            The column label for defending team's Y coordinate in `defending_team_xy`
        rect_thresh : Float, default 0.015
            A threshold to check if any player is outside/on the edge of the box within
            the threshold distance

        Returns
        ----------
        df_method1 : DataFrame
            A copy of original DataFrame with 1/0 for Method 1 and the following new columns :
            `triangle_area` : Float, `rect_length` : Float, `rect_width` : Float, `method_1` : Binary
        """
        def area_triangle(s1, s2, s3):

            s = (s1 + s2 + s3) / 2.0
            area = (s*(s-s1)*(s-s2)*(s-s3)) ** 0.5

            if area == np.nan:
                return 0
            else:
                return np.round(area, 5)

        def checkBoundary(df):
            method_1 = 0
            point_def = df[[col_label_x, col_label_y]].values.tolist()

            p_a = np.round(np.linalg.norm(point_def-box_a), 5)
            p_b = np.round(np.linalg.norm(point_def-box_b), 5)
            p_c = np.round(np.linalg.norm(point_def-box_c), 5)
            p_d = np.round(np.linalg.norm(point_def-box_d), 5)

            area_rect = np.round(ab*bc, 5)
            area_ab = area_triangle(p_a, p_b, ab)
            area_bc = area_triangle(p_b, p_c, bc)
            area_cd = area_triangle(p_c, p_d, cd)
            area_da = area_triangle(p_d, p_a, da)

            # Check if player xy lies inside the bounding box
            # rect_thresh = 0.010 is for normalized data

            if ((area_ab + area_bc + area_cd + area_da) - area_rect) <= rect_thresh:
                method_1 = 1
            else:
                method_1 = 0

            return pd.to_numeric(pd.Series({'triangle_area': (area_ab + area_bc + area_cd + area_da),
                                            'rect_length': ab, 'rect_width': bc,
                                            'area_diff': ((area_ab + area_bc + area_cd + area_da) - area_rect),
                                            'method_1': method_1}),
                                 downcast='integer')

        # rectangle edges
        ab = np.round(np.linalg.norm(box_a-box_b), 5)
        bc = np.round(np.linalg.norm(box_b-box_c), 5)
        cd = np.round(np.linalg.norm(box_c-box_d), 5)
        da = np.round(np.linalg.norm(box_d-box_a), 5)

        df_method1[['triangle_area', 'rect_length', 'rect_width', 'area_diff', 'method_1']
                   ] = df_method1.apply(checkBoundary, axis=1)

        return df_method1

    def method_2(self, sender_xy, receiver_xy, df_method2, col_label_x, col_label_y, method2_radius=0.12):
        """
        Method 2 :
        Check if player is within a certain distance to line of pass, so that
        the pass can potentially be intersected (assuming the speed of pass is not a factor).

        For a given defender, assume the defender xy to be center of circle. Find the perpendicular
        distance from player xy to the line of pass. If the distance is <= method2_radius, then method_2
        returns as 1, else 0.

        Parameters
        ----------
        sender_xy : ndarray
            A ndarray of ['sender_x', 'sender_y']
        receiver_xy : ndarray
            A ndarray of ['receiver_x', 'receiver_y']
        df_method2 : DataFrame
            A copy of defending_team_xy dataframe, updated from `Method 1`
        radius : Float, default 0.150
            search radius for find if player can potentially intersect the pass
            by being within a given distance

        Returns
        ----------
        df_method2 : DataFrame
            A copy of original DataFrame with 1/0 for Method 2 and the following new columns :
            `method2_dist` : Distance of player to line of pass,
            `method_2` : Binary, (1/0)
        """

        def check_intersection(df):
            """
            If rectangle from method_1 is big enough ((rect_length > 0.01) or (rect_width > 0.01)),
            take a diagonal (non player) side of the rectangle and find the perpendicular distance
            between it and the line of pass. If a defending player is within that distance to the
            line of pass, then method_2 = 1.

            If rectangle is small, then use method2_radius to check if a defending player
            is within that distance to the line of pass.
            """
            method_2 = 0

            # Defender point
            center = df[[col_label_x, col_label_y]].values
            dist_dl = np.round(np.abs(np.cross(receiver_xy-sender_xy, sender_xy-center)) /
                               np.linalg.norm(receiver_xy-sender_xy), 5)

            # Box diagonal
            box_diagonal = np.array([sender_xy[0], receiver_xy[1]])
            dist_box_line = np.round(np.abs(np.cross(receiver_xy-sender_xy, sender_xy-box_diagonal)) /
                                     np.linalg.norm(receiver_xy-sender_xy), 5)

            rect_length = df['rect_length']
            rect_width = df['rect_width']

            if (rect_length <= 0.07) or (rect_width <= 0.07):
                if (dist_dl <= method2_radius):
                    method_2 = 1
                else:
                    method_2 = 0
            elif dist_dl <= dist_box_line:
                method_2 = 1
            else:
                method_2 = 0

            return pd.to_numeric(pd.Series({'method2_dist': dist_dl,
                                            'method_2': method_2}),
                                 downcast='integer')

        df_method2[['method2_dist', 'method_2']] = df_method2.apply(
            check_intersection, axis=1)

        return df_method2

    def method_3(self, sender_xy, receiver_xy, df_method3, col_label_x, col_label_y):
        """
        Method 3 :
        Check defender angle with respect to sender & receiver.
        One of the draw back of `method_2` is that defender can be close to line to pass
        but still be beyond the sender or receiver (one of angle b/w defender & sender/receiver > 90).
        This method checks this condition.

        Parameters
        ----------
        sender_xy : ndarray
            A ndarray of ['sender_x', 'sender_y']
        receiver_xy : ndarray
            A ndarray of ['receiver_x', 'receiver_y']
        df_method3 : DataFrame
            A copy of defending_team_xy dataframe, updated from `Method 2`

        Returns
        ----------
        df_method3 : DataFrame
            A copy of original DataFrame with 1/0 for Method 3 and the following new columns :
            `method3_angle_s` : Angle between defender & sender,
            `method3_angle_r` : Angle between defender & receiver,
            `method_3` : Binary, (1/0)
        """
        def check_angles(df):
            method_3 = 0
            center = df[[col_label_x, col_label_y]].values

            # Distance between sender, receiver & defender combination
            d_sr = np.linalg.norm(sender_xy-receiver_xy)
            d_sd = np.linalg.norm(sender_xy-center)
            d_rd = np.linalg.norm(receiver_xy-center)

            angle_s = np.round(math.degrees(
                math.acos((d_sr**2 + d_sd**2 - d_rd**2)/(2.0 * d_sr * d_sd))))
            angle_r = np.round(math.degrees(
                math.acos((d_sr**2 + d_rd**2 - d_sd**2)/(2.0 * d_sr * d_rd))))

            if (angle_s <= 105) & (angle_r <= 105):
                method_3 = 1
            else:
                method_3 = 0

            return pd.to_numeric(pd.Series({'method3_angle_s': angle_s,
                                            'method3_angle_r': angle_r,
                                            'method_3': method_3}),
                                 downcast='integer')

        df_method3[['method3_angle_s', 'method3_angle_r', 'method_3']
                   ] = df_method3.apply(check_angles, axis=1)

        return df_method3

    def update_method_1(self, df_update):
        """
        Method 1 Update :
        For special cases where bounding box from `Method 1` is almost a line i.e: either width/length <= 0.07 units
        (both sender and receiver are in similar X or Y coordinate).
        In this case, update the value of method_1 value to 1 if both method_2 and method_3 are 1.

        Parameters
        ----------
        df_update : DataFrame
            The copy of DataFrame after Methods 1,2 & 3.

        Returns
        ----------
        df_update : DataFrame
            Final Dataframe with updated 1/0 for Method 1
        """
        rect_length = df_update['rect_length'].unique()[0]
        rect_width = df_update['rect_width'].unique()[0]

        if (rect_length <= 0.07) or (rect_width <= 0.07):
            df_update.loc[:, 'method_1_update'] = np.where(((df_update['method_1'] == 0) &
                                                            (df_update['method_2'] == 1) &
                                                            (df_update['method_3'] == 1)), 1, df_update['method_1'])
        else:
            df_update.loc[:, 'method_1_update'] = df_update['method_1']

        return df_update

    def get_pass_pressure(self, sender_xy, receiver_xy, defending_team_xy, col_label_x, col_label_y):
        """
        For defender who are not in the packing rate, if they are close (<=0.05 units) to the
        sender/receiver, they're considered to have an influence on the pass by increasing the
        pressure of the pass.

        Parameters
        ----------
        sender_xy : ndarray
            Sender XY coordinates as numpy array
        receiver_xy : ndarray
            Receiver XY coordinates as numpy array
        defending_team_xy : DataFrame
            DataFrame with the defending team coordinates
        col_label_x : String
            The column label for defending team's X coordinate in `defending_team_xy`
        col_label_y : String
            The column label for defending team's Y coordinate in `defending_team_xy`

        Returns
        ----------
        total_pressure : Int
            Total count of defenders applying pressure on the sender & receiver, but not involved in
            packing rate.
        """
        defend_xy = defending_team_xy[defending_team_xy['packing_rate'] == 0][[
            col_label_x, col_label_y]].values
        sender_def_cdist = distance.cdist(sender_xy, defend_xy)
        receiver_def_cdist = distance.cdist(receiver_xy, defend_xy)

        sender_ids = np.array(
            np.where(sender_def_cdist[0] <= 0.05)).tolist()[0]
        receiver_ids = np.array(
            np.where(receiver_def_cdist[0] <= 0.05)).tolist()[0]

        pass_pressure_players = list(
            set(sender_ids).symmetric_difference(set(receiver_ids)))
        total_pressure = len(pass_pressure_players)

        return total_pressure


[docs]class packing: """ Find the packing for a given pass Parameters ---------- sender_xy : ndarray Sender XY coordinates as numpy array receiver_xy : ndarray Receiver XY coordinates as numpy array defending_team_xy : DataFrame DataFrame with the defending team coordinates Do not include any passing team XY or other columns as it'll have an impact on plotting function. col_label_x : String The column label for defending team's X coordinate in `defending_team_xy` col_label_y : String The column label for defending team's Y coordinate in `defending_team_xy` defend_side : String The side of the defending team on the football pitch. Left/Right, `not case sensitive` goal_center : Dict Center of goal selected based on defend_side {'left': [0, 0.5], 'right': [1, 0.5]} Returns ---------- packing_df : DataFrame Returns a dataframe with the following new columns along with existing columns that was provided. New Columns : [`triangle_area`, `rect_length`, `rect_width`, `area_diff`, `method_1`, `method2_dist`, `method_2`, `method3_angle_s`, `method3_angle_r`, `method_3`, `method_1_update`, `packing_rate`, `col_label_x`, `col_label_y`] packing_rate : Float Packing rate for that given pass scenario Packing rate will be multiplied by a factor based on the pass type: 1.0 : Forward Pass -1.0 : Back Pass 0.5 : Side pass pass_pressure : Integer Defending players who are closer to sender/receiver but not involved in packing. Indicator to see if players take high risk pass. For eg: packing rate could be lower but pass pressure can be higher if pass sender/receiver are heavily marked. """ def __init__( self, sender_xy: np.array, receiver_xy: np.array, defending_team_xy: pd.DataFrame, col_label_x: str, col_label_y: str, defend_side: str, ): self.sender_xy = np.asarray(sender_xy) self.receiver_xy = np.asarray(receiver_xy) self.defending_team_xy = defending_team_xy.copy() self.col_label_x = col_label_x self.col_label_y = col_label_y self.defend_side = defend_side.lower() self.goal_center = {'left': [0, 0.5], 'right': [1, 0.5]} self.pass_pressure = None def get_packing(self): self.defending_team_xy_copy = self.defending_team_xy.copy() if self.sender_xy.size == 0: raise RuntimeError( "Sender coordinates are empty. A valid array with [x, y] should be provided") if self.receiver_xy.size == 0: raise RuntimeError( "Receiver coordinates are empty. A valid array with [x, y] should be provided") if self.defending_team_xy_copy.size == 0: raise RuntimeError( "Defending team coordinates are empty. A valid dataframe with [x, y] should be provided for at least 1 player") if not isinstance(self.defending_team_xy_copy, pd.DataFrame): raise RuntimeError( "Defending team coordinates should be a dataframe with x and y values.") defend_xy_cols = self.defending_team_xy_copy.columns.tolist() if self.col_label_x not in defend_xy_cols or self.col_label_x not in defend_xy_cols: raise RuntimeError( f"Either {self.col_label_x} or {self.col_label_y} is not a column in defending_team_xy. Please provide valid column names") self.goal_xy = self.goal_center[self.defend_side] if max(self.defending_team_xy_copy[[self.col_label_x]].values) > 1 or \ max(self.defending_team_xy_copy[[self.col_label_y]].values) > 1: concat_location = np.concatenate([ self.defending_team_xy_copy[[ self.col_label_x, self.col_label_y]].values, self.sender_xy.reshape(1, -1), self.receiver_xy.reshape(1, -1) ]) min_max_scaler = preprocessing.MinMaxScaler() defending_team_xy_scaled = min_max_scaler.fit_transform( concat_location) self.defending_team_xy_copy.drop( [self.col_label_x, self.col_label_y], axis=1, inplace=True) self.defending_team_xy_copy[self.col_label_x], self.defending_team_xy_copy[ self.col_label_y] = defending_team_xy_scaled[:-2, 0], defending_team_xy_scaled[:-2, 1] self.sender_xy = defending_team_xy_scaled[-2] self.receiver_xy = defending_team_xy_scaled[-1] box_a = np.asarray(self.sender_xy) # sender box_b = np.asarray( [self.sender_xy[0], self.receiver_xy[1]]) box_c = np.asarray(list(self.receiver_xy)) # receiver box_d = np.asarray( [self.receiver_xy[0], self.sender_xy[1]]) cp = calculate_packing() self.pass_direction = cp.get_pass_direction( self.sender_xy, self.receiver_xy, self.goal_xy, self.defend_side) self.packing_df = cp.method_1( box_a, box_b, box_c, box_d, self.defending_team_xy_copy.copy(), col_label_x=self.col_label_x, col_label_y=self.col_label_y) self.packing_df = cp.method_2( self.sender_xy, self.receiver_xy, self.packing_df, col_label_x=self.col_label_x, col_label_y=self.col_label_y) self.packing_df = cp.method_3( self.sender_xy, self.receiver_xy, self.packing_df, col_label_x=self.col_label_x, col_label_y=self.col_label_y) self.packing_df = cp.update_method_1(self.packing_df) self.packing_df['packing_rate'] = np.where( self.packing_df[["method_1_update", "method_2", "method_3"]].sum(axis=1) == 3, 1, 0) # If back pass, multiple packing by -1 if self.pass_direction == 'Back': self.packing_df.loc[:, 'packing_rate'] = self.packing_df.loc[:, 'packing_rate']*-1.0 elif self.pass_direction == 'Side': self.packing_df.loc[:, 'packing_rate'] = self.packing_df.loc[:, 'packing_rate']*0.5 self.packing_rate = self.packing_df['packing_rate'].sum() self.pass_pressure = cp.get_pass_pressure(self.sender_xy.reshape(1, -1), self.receiver_xy.reshape(1, -1), self.packing_df, self.col_label_x, self.col_label_y,) if max(self.defending_team_xy[[self.col_label_x]].values) > 1 or \ max(self.defending_team_xy[[self.col_label_y]].values) > 1: defending_team_xy_unscaled = min_max_scaler.inverse_transform( self.packing_df[[self.col_label_x, self.col_label_y]].values) self.packing_df.drop( [self.col_label_x, self.col_label_y], axis=1, inplace=True) self.packing_df[self.col_label_x], self.packing_df[ self.col_label_y] = defending_team_xy_unscaled[:, 0], defending_team_xy_unscaled[:, 1] return self.packing_df, self.packing_rate, self.pass_pressure