diff --git a/docs/reference/event-data/spec.yaml b/docs/reference/event-data/spec.yaml index 6c150b2ec..e634cd19e 100644 --- a/docs/reference/event-data/spec.yaml +++ b/docs/reference/event-data/spec.yaml @@ -23,45 +23,73 @@ event_types: statsbomb: status: parsed implementation: value of 'timestamp' field + sportec: + status: parsed + implementation: value of 'EventTime' field coordinates: providers: statsbomb: status: parsed implementation: value of 'location' field + sportec: + status: parsed + implementation: value of 'X-Position' and 'Y-Position' fields team: providers: statsbomb: status: parsed implementation: value of 'team' field + sportec: + status: parsed + implementation: value of 'Team' field player: providers: statsbomb: status: parsed implementation: value of 'player' field + sportec: + status: parsed + implementation: value of 'Player' field ball_owning_team: providers: statsbomb: status: parsed implementation: value of 'possession_team' field + sportec: + status: not supported ball_state: providers: statsbomb: - status: parsed + status: inferred + implementation: BallState.DEAD for synthetic ball out events; otherwise BallState.ALIVE + sportec: + status: inferred implementation: BallState.DEAD for synthetic ball out events; otherwise BallState.ALIVE raw_event: providers: statsbomb: status: parsed + sportec: + status: parsed + implementation: | + the hierarchical XML structure is flattend into a dict + with the keys 'SetPieceType', 'EventType' and 'SubEventType' + to categorize the event hierarchy, and a nested dictionary + 'extra' containing attributes of all child tags related_event_ids: providers: statsbomb: status: parsed implementation: value of the 'related_events' field + sportec: + status: not supported freeze_frame: providers: statsbomb: status: parsed implementation: tracking data frame created from the 'freeze_frame' field for shots or from a 360 data file for other events + sportec: + status: not supported kloppy.domain.PassEvent: providers: statsbomb: @@ -78,7 +106,7 @@ event_types: implementation: primary event type any of 'pass', 'goal_kick' or 'throw_in', or 'corner', 'free_kick' that is not 'shot' as secondary event type sportec: status: parsed - implementation: event type 'Pass' or 'Cross' + implementation: event type 'Play' metrica_json: status: parsed implementation: event type 1/'PASS' @@ -92,8 +120,7 @@ event_types: status: parsed implementation: sum of 'timestamp' and 'duration' fields sportec: - status: not implemented - implementation: null + status: not supported metrica_json: status: parsed implementation: time difference between event's end time and start of period @@ -102,9 +129,9 @@ event_types: statsbomb: status: parsed implementation: value of 'pass.end_location' field - sportec: + sportec: status: parsed - implementation: X/Y-Source-Position of subsequent event + implementation: X/Y(-Source)-Position of subsequent action metrica_json: status: parsed implementation: value of 'event.end' field @@ -135,7 +162,7 @@ event_types: implementation: outcome is not defined sportec: status: parsed - implementation: successfullyCompleted or successful from [Play / Evaluation] + implementation: Evaluation is 'successfullyCompleted' or 'successful' metrica_json: status: parsed implementation: outcome 1/'COMPLETE' @@ -146,7 +173,7 @@ event_types: implementation: outcome 9/'Incomplete' sportec: status: parsed - implementation: if not successfullyCompleted or successful (unsuccessful) [Play / Evaluation] + implementation: Evaluation is 'unsuccessful' metrica_json: status: parsed implementation: outcome 7/'INCOMPLETE' @@ -157,7 +184,7 @@ event_types: implementation: outcome 75/'Out' or 74/'Injury Clearance' sportec: status: inferred - implementation: When action is SET_PIECE, and previous action was pass, set pass outcome to unsuccessful + implementation: Evaluation is 'unsuccessful' and next action is a set piece of type ThrowIn, Corner or GoalKick metrica_json: status: parsed implementation: outcome 6/'OUT' @@ -167,8 +194,8 @@ event_types: status: parsed implementation: outcome 76/'Pass Offside' sportec: - status: not supported - implementation: null + status: inferred + implementation: Evaluation is 'unsuccessful' and next event is Offside metrica_json: status: parsed implementation: outcome 16/'OFFSIDE' @@ -188,7 +215,7 @@ event_types: implementation: primary event type any of 'shot', 'own_goal' or 'penalty' or 'free_kick' or 'corner' with secondary event type 'shot' sportec: status: parsed - implementation: event type 'ShotWide' or 'SavedShot' or 'BlockedShot' or 'ShotWoodWork' or 'OtherShot' or 'SuccessfulShot' or 'OwnGoal' + implementation: event type 'ShotAtGoal' or 'OwnGoal' metrica_json: status: parsed implementation: event type 2/'SHOT' @@ -223,7 +250,7 @@ event_types: implementation: outcome 97/'Goal' sportec: status: parsed - implementation: outcome SuccessfulShot + implementation: subevent type 'SuccessfulShot' metrica_json: status: parsed implementation: outcome 30/'GOAL' @@ -234,7 +261,7 @@ event_types: implementation: outcome 98/'off T' or 101/'Wayward' sportec: status: parsed - implementation: outcome ShotWide + implementation: subevent type 'ShotWide' metrica_json: status: parsed implementation: outcome 29/'OFF TARGET' @@ -245,7 +272,7 @@ event_types: implementation: outcome 99/'Post' sportec: status: parsed - implementation: outcome ShotWoodWork + implementation: subevent type 'ShotWoodWork' metrica_json: status: parsed implementation: outcome 27/'POST' @@ -256,7 +283,7 @@ event_types: implementation: outcome 96/'Blocked' sportec: status: parsed - implementation: outcome BlockedShot + implementation: subevent type 'BlockedShot' metrica_json: status: parsed implementation: outcome 25/'BLOCKED' @@ -267,7 +294,7 @@ event_types: implementation: outcome 100/'Saved' or 115/'Saved Off T' or 116/'Saved To Post' sportec: status: parsed - implementation: outcome SavedShot + implementation: subevent type 'SavedShot' metrica_json: status: parsed implementation: outcome 26/'SAVED' @@ -278,7 +305,7 @@ event_types: implementation: event type 20/'Own goal against' sportec: status: parsed - implementation: outcome OwnGoal + implementation: event type 'OwnGoal' metrica_json: status: not implemented implementation: null @@ -293,11 +320,11 @@ event_types: wyscout_v2: status: not implemented wyscout_v3: - status: primary event type 'duel' and secondary event type contains 'dribble' - implementation: null + status: parsed + implementation: primary event type 'duel' and secondary event type contains 'dribble' sportec: - status: not supported - implementation: null + status: parsed + implementation: event type 'TacklingGame' with attribute 'DribbleEvaluation' metrica_json: status: parsed implementation: event type 45/'DRIBBLE' @@ -306,6 +333,8 @@ event_types: providers: statsbomb: status: parsed + sportec: + status: parsed metrica_json: status: parsed values: @@ -314,6 +343,9 @@ event_types: statsbomb: status: parsed implementation: outcome 8/'Complete' + sportec: + status: parsed + implementation: attribute 'DribbleEvaluation' is 'successful' and attribute 'WinnerResult' is 'dribbledAround' or 'fouled' metrica_json: status: parsed implementation: outcome 48/'WON' @@ -322,6 +354,9 @@ event_types: statsbomb: status: parsed implementation: outcome 9/'Incomplete' and dribble has no related event of type 4/'Duel' with outcome 14/'Lost Out' or 17/'Success Out' + sportec: + status: parsed + implementation: attribute 'DribbleEvaluation' is 'unsuccessful' and attribute 'WinnerResult' is 'ballClaimed' metrica_json: status: parsed implementation: outcome 49/'LOST' @@ -340,10 +375,8 @@ event_types: implementation: event type 43/'Carry' statsperform: status: not supported - implementation: null sportec: - status: not implemented - implementation: null + status: not supported metrica_json: status: parsed implementation: event type 10/'CARRY' @@ -403,8 +436,8 @@ event_types: status: parsed implementation: primary event type 'clearance' sportec: - status: not implemented - implementation: null + status: parsed + implementation: event type 'OtherBallAction' with attribute 'DefensiveClearance' metrica_json: status: not implemented implementation: null @@ -426,8 +459,8 @@ event_types: status: parsed implementation: primary event type 'interception' sportec: - status: not implemented - implementation: null + status: parsed + implementation: event type 'BallClaiming' with attribute 'Type' is 'InterceptedBall' metrica_json: status: not implemented implementation: null @@ -439,12 +472,14 @@ event_types: providers: statsbomb: status: parsed + sportec: + status: not supported values: SUCCESS: providers: statsbomb: status: parsed - implementation: event type 10/'Interception' with outcome 4/'Won', 15/'Success' or 16/'Success in play', or event type 30/'Pass' with type 64/'One touch interception' + implementation: event type 10/'Interception' with outcome 4/'Won', 15/'Success' or 16/'Success in play', or event type 30/'Pass' with type 64/'One touch interception' LOST: providers: statsbomb: @@ -470,8 +505,8 @@ event_types: status: parsed implementation: primary event type 'duel' and secondary event type not 'dribble' sportec: - status: not supported - implementation: null + status: parsed + implementation: two duel events are created for each event type 'TacklingGame' without attribute 'DribbleEvaluation'; one for the winner of the duel and one for the loser metrica_json: status: not supported implementation: null @@ -489,15 +524,24 @@ event_types: statsbomb: status: parsed implementation: event type 4/'Duel' with outcome 4/'Won', 15/'Success', 16/'Success in play' or 17/'Success out', or synthetic event from event with field 'aerial_won', or event type 33/'Fifty-fifty' with outcome 4/'Won' or 3/'Success to team' + sportec: + status: parsed + implementation: attribute 'WinnerResult' is 'ballClaimed', 'ballControlRetained' or 'fouled'; using the id of the 'Winner' LOST: providers: statsbomb: status: parsed implementation: event type 4/'Duel' with outcome 13/'Lost in play' or 14/'Lost out', or type 11/'Aerial lost', or event type 33/'Fifty-fifty' with outcome 1/'Lost' or 2/'Success to opponent', or event type 2/'Ball recovery' with field 'ball_recovery.recovery_failure' + sportec: + status: parsed + implementation: attribute 'WinnerResult' is 'ballClaimed' or 'ballControlRetained'; using the id of the 'Loser' attribute NEUTRAL: providers: statsbomb: status: not supported + sportec: + status: parsed + implementation: attribute 'WinnerResult' is 'ballContactSucceeded'; both for the 'Winner' and 'Loser' kloppy.domain.SubstitutionEvent: providers: statsbomb: @@ -514,7 +558,7 @@ event_types: implementation: null sportec: status: parsed - implementation: event type Substitution + implementation: event type 'Substitution' metrica_json: status: not supported implementation: null @@ -527,6 +571,9 @@ event_types: statsbomb: status: parsed value: value of 'substitution.replacement' field + sportec: + status: parsed + value: value of 'PlayerIn' attribute kloppy.domain.CardEvent: providers: statsbomb: @@ -555,25 +602,27 @@ event_types: providers: statsbomb: status: parsed + sportec: + status: parsed values: FIRST_YELLOW: providers: statsbomb: status: parsed sportec: - status: not implemented + status: parsed SECOND_YELLOW: providers: statsbomb: status: parsed sportec: - status: not implemented + status: parsed RED: providers: statsbomb: status: parsed sportec: - status: not implemented + status: parsed kloppy.domain.PlayerOnEvent: providers: statsbomb: @@ -586,10 +635,6 @@ event_types: status: not implemented wyscout_v3: status: not implemented - sportec: - status: unknown - metrica_json: - status: unknown kloppy.domain.PlayerOffEvent: providers: statsbomb: @@ -601,10 +646,6 @@ event_types: status: not implemented wyscout_v3: status: not implemented - sportec: - status: unknown - metrica_json: - status: unknown kloppy.domain.RecoveryEvent: providers: statsbomb: @@ -619,7 +660,8 @@ event_types: wyscout_v3: status: not implemented sportec: - status: unknown + status: parsed + implementation: event type 'BallClaiming' and attribute 'Type' is 'BallClaimed' metrica_json: status: parsed implementation: event type 3/'RECOVERY' @@ -639,8 +681,6 @@ event_types: wyscout_v3: status: not implemented implementation: subevent 72/'Touch' with tag 1302 - sportec: - status: unknown metrica_json: status: not supported implementation: null @@ -658,8 +698,8 @@ event_types: wyscout_v3: status: not implemented sportec: - status: unknown - implementation: null + status: inferred + implementation: synthetic events are added before each corner, goal kick or throw-in metrica_json: status: inferred implementation: checks if the event ended out of the field and adds a synthetic out event @@ -700,9 +740,6 @@ event_types: wyscout_v3: status: parsed implementation: primary event type 'shot_against' and 'save' in secondary event type - sportec: - status: unknown - implementation: null metrica_json: status: not supported implementation: null @@ -775,8 +812,6 @@ qualifiers: status: parsed sportec: status: not supported - metrica_json: - status: unknown HEAD_PASS: providers: statsbomb: @@ -790,7 +825,7 @@ qualifiers: statsbomb: status: parsed sportec: - status: not supported + status: parsed metrica_json: status: not supported LAUNCH: @@ -822,7 +857,7 @@ qualifiers: statsbomb: status: parsed sportec: - status: not supported + status: parsed metrica_json: status: not supported THROUGH_BALL: @@ -830,7 +865,7 @@ qualifiers: statsbomb: status: parsed sportec: - status: not implemented + status: parsed metrica_json: status: not supported CHIPPED_PASS: @@ -851,6 +886,8 @@ qualifiers: providers: statsbomb: status: parsed + sportec: + status: not implemented ASSIST_2ND: providers: statsbomb: @@ -860,11 +897,7 @@ qualifiers: statsbomb: status: parsed sportec: - status: not implemented - BACK_PASS: - providers: - sportec: - status: not implemented + status: parsed kloppy.domain.BodyPartQualifier: providers: statsbomb: @@ -881,8 +914,6 @@ qualifiers: sportec: status: parsed implementation: leftLeg - metrica_json: - status: unknown LEFT_FOOT: providers: statsbomb: @@ -890,8 +921,6 @@ qualifiers: sportec: status: parsed implementation: rightLeg - metrica_json: - status: unknown HEAD: providers: statsbomb: @@ -904,106 +933,68 @@ qualifiers: providers: statsbomb: status: parsed - sportec: - status: unknown - metrica_json: - status: unknown HEAD_OTHER: providers: statsbomb: status: parsed - sportec: - status: unknown - metrica_json: - status: unknown BOTH_HANDS: providers: statsbomb: status: parsed - sportec: - status: unknown - metrica_json: - status: unknown CHEST: providers: statsbomb: status: parsed - sportec: - status: unknown - metrica_json: - status: unknown LEFT_HAND: providers: statsbomb: status: parsed - sportec: - status: unknown - metrica_json: - status: unknown RIGHT_HAND: providers: statsbomb: status: parsed - sportec: - status: unknown - metrica_json: - status: unknown DROP_KICK: providers: statsbomb: status: parsed - sportec: - status: unknown - metrica_json: - status: unknown KEEPER_ARM: providers: statsbomb: status: parsed - sportec: - status: unknown - metrica_json: - status: unknown NO_TOUCH: providers: statsbomb: status: parsed - sportec: - status: unknown - metrica_json: - status: unknown kloppy.domain.CardQualifier: providers: statsbomb: status: parsed sportec: - status: not supported + status: parsed metrica_json: - status: not implemented + status: not implemented values: FIRST_YELLOW: providers: statsbomb: status: parsed sportec: - status: not supported + status: parsed metrica_json: - status: inferred + status: parsed implementation: subtype 40/'YELLOW' SECOND_YELLOW: providers: statsbomb: status: parsed sportec: - status: not supported - metrica_json: - status: unknown + status: parsed RED: providers: statsbomb: status: parsed sportec: - status: not supported + status: parsed metrica_json: status: parsed kloppy.domain.CounterAttackQualifier: @@ -1020,16 +1011,14 @@ qualifiers: statsbomb: status: parsed sportec: - status: unknown - metrica_json: - status: unknown + status: parsed values: AERIAL: providers: statsbomb: status: parsed sportec: - status: unknown + status: parsed metrica_json: status: not implemented GROUND: @@ -1037,76 +1026,50 @@ qualifiers: statsbomb: status: parsed sportec: - status: unknown + status: parsed metrica_json: status: not implemented LOOSE_BALL: providers: statsbomb: status: parsed - sportec: - status: unknown - metrica_json: - status: unknown SLIDING_TACKLE: providers: statsbomb: status: parsed - sportec: - status: unknown - metrica_json: - status: unknown kloppy.domain.GoalkeeperQualifier: providers: statsbomb: status: parsed - sportec: - status: parsed - metrica_json: - status: unknown values: SAVE: providers: statsbomb: status: parsed - sportec: - status: not implemented CLAIM: providers: statsbomb: status: parsed - sportec: - status: unknown PUNCH: providers: statsbomb: status: parsed - sportec: - status: unknown PICK_UP: providers: statsbomb: status: parsed - sportec: - status: unknown SMOTHER: providers: statsbomb: status: parsed - sportec: - status: unknown REFLEX: providers: statsbomb: status: parsed - sportec: - status: unknown SAVE_ATTEMPT: providers: statsbomb: status: parsed - sportec: - status: unknown kloppy.domain.SetPieceQualifier: providers: statsbomb: diff --git a/docs/user-guide/loading-data/sportec.ipynb b/docs/user-guide/loading-data/sportec.ipynb index 4b162ea2e..37b24ea62 100644 --- a/docs/user-guide/loading-data/sportec.ipynb +++ b/docs/user-guide/loading-data/sportec.ipynb @@ -44,8 +44,6 @@ " \n", " event_id\n", " event_type\n", - " result\n", - " success\n", " period_id\n", " timestamp\n", " end_timestamp\n", @@ -59,6 +57,8 @@ " end_coordinates_y\n", " receiver_player_id\n", " set_piece_type\n", + " result\n", + " success\n", " body_part_type\n", " \n", " \n", @@ -67,10 +67,8 @@ " 0\n", " 17364900000006\n", " PASS\n", - " COMPLETE\n", - " True\n", " 1\n", - " 0.000\n", + " 0 days 00:00:00\n", " None\n", " alive\n", " None\n", @@ -82,16 +80,16 @@ " 38.71\n", " DFL-OBJ-0000ZS\n", " KICK_OFF\n", + " COMPLETE\n", + " True\n", " None\n", " \n", " \n", " 1\n", " 17364900000007\n", " PASS\n", - " COMPLETE\n", - " True\n", " 1\n", - " 3.123\n", + " 0 days 00:00:03.123000\n", " None\n", " alive\n", " None\n", @@ -103,16 +101,16 @@ " NaN\n", " DFL-OBJ-002G3I\n", " None\n", + " COMPLETE\n", + " True\n", " None\n", " \n", " \n", " 2\n", " 17364900000014\n", " PASS\n", - " COMPLETE\n", - " True\n", " 1\n", - " 31.797\n", + " 0 days 00:00:31.797000\n", " None\n", " alive\n", " None\n", @@ -120,20 +118,20 @@ " DFL-OBJ-00017V\n", " 35.57\n", " 68.00\n", - " 21.24\n", - " 28.58\n", + " NaN\n", + " NaN\n", " DFL-OBJ-0027B9\n", " THROW_IN\n", + " COMPLETE\n", + " True\n", " None\n", " \n", " \n", " 3\n", " 17364900000031\n", " SHOT\n", - " BLOCKED\n", - " False\n", " 1\n", - " 79.480\n", + " 0 days 00:01:19.480000\n", " None\n", " alive\n", " None\n", @@ -145,16 +143,16 @@ " NaN\n", " None\n", " None\n", + " BLOCKED\n", + " False\n", " RIGHT_FOOT\n", " \n", " \n", " 4\n", " 17364900000036\n", " PASS\n", - " INCOMPLETE\n", - " False\n", " 1\n", - " 95.173\n", + " 0 days 00:01:35.173000\n", " None\n", " alive\n", " None\n", @@ -166,6 +164,8 @@ " NaN\n", " None\n", " None\n", + " INCOMPLETE\n", + " False\n", " None\n", " \n", " \n", @@ -173,33 +173,33 @@ "" ], "text/plain": [ - " event_id event_type result success period_id timestamp \\\n", - "0 17364900000006 PASS COMPLETE True 1 0.000 \n", - "1 17364900000007 PASS COMPLETE True 1 3.123 \n", - "2 17364900000014 PASS COMPLETE True 1 31.797 \n", - "3 17364900000031 SHOT BLOCKED False 1 79.480 \n", - "4 17364900000036 PASS INCOMPLETE False 1 95.173 \n", + " event_id event_type period_id timestamp end_timestamp \\\n", + "0 17364900000006 PASS 1 0 days 00:00:00 None \n", + "1 17364900000007 PASS 1 0 days 00:00:03.123000 None \n", + "2 17364900000014 PASS 1 0 days 00:00:31.797000 None \n", + "3 17364900000031 SHOT 1 0 days 00:01:19.480000 None \n", + "4 17364900000036 PASS 1 0 days 00:01:35.173000 None \n", "\n", - " end_timestamp ball_state ball_owning_team team_id player_id \\\n", - "0 None alive None DFL-CLU-000004 DFL-OBJ-0000SP \n", - "1 None alive None DFL-CLU-000004 DFL-OBJ-0000ZS \n", - "2 None alive None DFL-CLU-00000A DFL-OBJ-00017V \n", - "3 None alive None DFL-CLU-000004 DFL-OBJ-002706 \n", - "4 None alive None DFL-CLU-000004 DFL-OBJ-002G3I \n", + " ball_state ball_owning_team team_id player_id coordinates_x \\\n", + "0 alive None DFL-CLU-000004 DFL-OBJ-0000SP 56.41 \n", + "1 alive None DFL-CLU-000004 DFL-OBJ-0000ZS 73.94 \n", + "2 alive None DFL-CLU-00000A DFL-OBJ-00017V 35.57 \n", + "3 alive None DFL-CLU-000004 DFL-OBJ-002706 21.24 \n", + "4 alive None DFL-CLU-000004 DFL-OBJ-002G3I 8.72 \n", "\n", - " coordinates_x coordinates_y end_coordinates_x end_coordinates_y \\\n", - "0 56.41 68.00 77.75 38.71 \n", - "1 73.94 37.21 NaN NaN \n", - "2 35.57 68.00 21.24 28.58 \n", - "3 21.24 28.58 NaN NaN \n", - "4 8.72 4.21 NaN NaN \n", + " coordinates_y end_coordinates_x end_coordinates_y receiver_player_id \\\n", + "0 68.00 77.75 38.71 DFL-OBJ-0000ZS \n", + "1 37.21 NaN NaN DFL-OBJ-002G3I \n", + "2 68.00 NaN NaN DFL-OBJ-0027B9 \n", + "3 28.58 NaN NaN None \n", + "4 4.21 NaN NaN None \n", "\n", - " receiver_player_id set_piece_type body_part_type \n", - "0 DFL-OBJ-0000ZS KICK_OFF None \n", - "1 DFL-OBJ-002G3I None None \n", - "2 DFL-OBJ-0027B9 THROW_IN None \n", - "3 None None RIGHT_FOOT \n", - "4 None None None " + " set_piece_type result success body_part_type \n", + "0 KICK_OFF COMPLETE True None \n", + "1 None COMPLETE True None \n", + "2 THROW_IN COMPLETE True None \n", + "3 None BLOCKED False RIGHT_FOOT \n", + "4 None INCOMPLETE False None " ] }, "execution_count": 1, @@ -211,8 +211,8 @@ "from kloppy import sportec\n", "\n", "dataset = sportec.load_event(\n", - " event_data=\"../../kloppy/tests/files/sportec_events.xml\",\n", - " meta_data=\"../../kloppy/tests/files/sportec_meta.xml\",\n", + " event_data=\"../../../kloppy/tests/files/sportec_events.xml\",\n", + " meta_data=\"../../../kloppy/tests/files/sportec_meta.xml\",\n", " # Optional arguments\n", " coordinates=\"sportec\",\n", " event_types=[\"pass\", \"shot\"],\n", @@ -231,7 +231,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "id": "958f17ee", "metadata": {}, "outputs": [ @@ -266,24 +266,16 @@ " ball_z\n", " ball_speed\n", " DFL-OBJ-002G3I_x\n", - " ...\n", + " DFL-OBJ-002G3I_y\n", " DFL-OBJ-002G3I_d\n", " DFL-OBJ-002G3I_s\n", - " DFL-OBJ-002G5S_x\n", - " DFL-OBJ-002G5S_y\n", - " DFL-OBJ-002G5S_d\n", - " DFL-OBJ-002G5S_s\n", - " DFL-OBJ-002FVJ_x\n", - " DFL-OBJ-002FVJ_y\n", - " DFL-OBJ-002FVJ_d\n", - " DFL-OBJ-002FVJ_s\n", " \n", " \n", " \n", " \n", " 0\n", " 1\n", - " 0.00\n", + " 0 days 00:00:00\n", " 10000\n", " dead\n", " DFL-CLU-000004\n", @@ -292,22 +284,14 @@ " 0.06\n", " 0.00\n", " 0.35\n", - " ...\n", + " -25.26\n", " None\n", " 0.00\n", - " NaN\n", - " NaN\n", - " None\n", - " NaN\n", - " NaN\n", - " NaN\n", - " None\n", - " NaN\n", " \n", " \n", " 1\n", " 1\n", - " 0.04\n", + " 0 days 00:00:00.040000\n", " 10001\n", " alive\n", " DFL-CLU-00000A\n", @@ -316,22 +300,14 @@ " 0.08\n", " 65.59\n", " 0.34\n", - " ...\n", + " -25.28\n", " None\n", " 1.74\n", - " NaN\n", - " NaN\n", - " None\n", - " NaN\n", - " NaN\n", - " NaN\n", - " None\n", - " NaN\n", " \n", " \n", " 2\n", " 1\n", - " 0.08\n", + " 0 days 00:00:00.080000\n", " 10002\n", " alive\n", " DFL-CLU-000004\n", @@ -340,22 +316,14 @@ " 0.09\n", " 65.16\n", " 0.32\n", - " ...\n", + " -25.29\n", " None\n", " 1.76\n", - " NaN\n", - " NaN\n", - " None\n", - " NaN\n", - " NaN\n", - " NaN\n", - " None\n", - " NaN\n", " \n", " \n", " 3\n", " 1\n", - " 0.12\n", + " 0 days 00:00:00.120000\n", " 10003\n", " alive\n", " DFL-CLU-000004\n", @@ -364,22 +332,14 @@ " 0.09\n", " 74.34\n", " 0.31\n", - " ...\n", + " -25.30\n", " None\n", " 1.78\n", - " NaN\n", - " NaN\n", - " None\n", - " NaN\n", - " NaN\n", - " NaN\n", - " None\n", - " NaN\n", " \n", " \n", " 4\n", " 1\n", - " 0.16\n", + " 0 days 00:00:00.160000\n", " 10004\n", " alive\n", " DFL-CLU-000004\n", @@ -388,63 +348,38 @@ " 0.08\n", " 73.58\n", " 0.29\n", - " ...\n", + " -25.31\n", " None\n", " 1.80\n", - " NaN\n", - " NaN\n", - " None\n", - " NaN\n", - " NaN\n", - " NaN\n", - " None\n", - " NaN\n", " \n", " \n", "\n", - "

5 rows × 21 columns

\n", "" ], "text/plain": [ - " period_id timestamp frame_id ball_state ball_owning_team_id ball_x \\\n", - "0 1 0.00 10000 dead DFL-CLU-000004 2.69 \n", - "1 1 0.04 10001 alive DFL-CLU-00000A 3.41 \n", - "2 1 0.08 10002 alive DFL-CLU-000004 4.22 \n", - "3 1 0.12 10003 alive DFL-CLU-000004 5.02 \n", - "4 1 0.16 10004 alive DFL-CLU-000004 5.79 \n", - "\n", - " ball_y ball_z ball_speed DFL-OBJ-002G3I_x ... DFL-OBJ-002G3I_d \\\n", - "0 0.26 0.06 0.00 0.35 ... None \n", - "1 0.26 0.08 65.59 0.34 ... None \n", - "2 0.33 0.09 65.16 0.32 ... None \n", - "3 0.38 0.09 74.34 0.31 ... None \n", - "4 0.44 0.08 73.58 0.29 ... None \n", + " period_id timestamp frame_id ball_state ball_owning_team_id \\\n", + "0 1 0 days 00:00:00 10000 dead DFL-CLU-000004 \n", + "1 1 0 days 00:00:00.040000 10001 alive DFL-CLU-00000A \n", + "2 1 0 days 00:00:00.080000 10002 alive DFL-CLU-000004 \n", + "3 1 0 days 00:00:00.120000 10003 alive DFL-CLU-000004 \n", + "4 1 0 days 00:00:00.160000 10004 alive DFL-CLU-000004 \n", "\n", - " DFL-OBJ-002G3I_s DFL-OBJ-002G5S_x DFL-OBJ-002G5S_y DFL-OBJ-002G5S_d \\\n", - "0 0.00 NaN NaN None \n", - "1 1.74 NaN NaN None \n", - "2 1.76 NaN NaN None \n", - "3 1.78 NaN NaN None \n", - "4 1.80 NaN NaN None \n", + " ball_x ball_y ball_z ball_speed DFL-OBJ-002G3I_x DFL-OBJ-002G3I_y \\\n", + "0 2.69 0.26 0.06 0.00 0.35 -25.26 \n", + "1 3.41 0.26 0.08 65.59 0.34 -25.28 \n", + "2 4.22 0.33 0.09 65.16 0.32 -25.29 \n", + "3 5.02 0.38 0.09 74.34 0.31 -25.30 \n", + "4 5.79 0.44 0.08 73.58 0.29 -25.31 \n", "\n", - " DFL-OBJ-002G5S_s DFL-OBJ-002FVJ_x DFL-OBJ-002FVJ_y DFL-OBJ-002FVJ_d \\\n", - "0 NaN NaN NaN None \n", - "1 NaN NaN NaN None \n", - "2 NaN NaN NaN None \n", - "3 NaN NaN NaN None \n", - "4 NaN NaN NaN None \n", - "\n", - " DFL-OBJ-002FVJ_s \n", - "0 NaN \n", - "1 NaN \n", - "2 NaN \n", - "3 NaN \n", - "4 NaN \n", - "\n", - "[5 rows x 21 columns]" + " DFL-OBJ-002G3I_d DFL-OBJ-002G3I_s \n", + "0 None 0.00 \n", + "1 None 1.74 \n", + "2 None 1.76 \n", + "3 None 1.78 \n", + "4 None 1.80 " ] }, - "execution_count": 3, + "execution_count": 2, "metadata": {}, "output_type": "execute_result" } @@ -453,8 +388,8 @@ "from kloppy import sportec\n", "\n", "dataset = sportec.load_tracking(\n", - " raw_data=\"../../kloppy/tests/files/sportec_positional.xml\",\n", - " meta_data=\"../../kloppy/tests/files/sportec_meta.xml\",\n", + " raw_data=\"../../../kloppy/tests/files/sportec_positional.xml\",\n", + " meta_data=\"../../../kloppy/tests/files/sportec_meta.xml\",\n", " # Optional arguments\n", " sample_rate=1,\n", " limit=10,\n", @@ -488,7 +423,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "8d434c80", "metadata": {}, "outputs": [], @@ -536,9 +471,9 @@ ], "metadata": { "kernelspec": { - "display_name": ".venv", + "display_name": "kloppya", "language": "python", - "name": "python3" + "name": "kloppy-dev" }, "language_info": { "codemirror_mode": { @@ -550,7 +485,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.11" + "version": "3.13.3" } }, "nbformat": 4, diff --git a/kloppy/infra/serializers/event/sportec/__init__.py b/kloppy/infra/serializers/event/sportec/__init__.py index ef42d6eba..4d84cbb68 100644 --- a/kloppy/infra/serializers/event/sportec/__init__.py +++ b/kloppy/infra/serializers/event/sportec/__init__.py @@ -1,6 +1,8 @@ from .deserializer import SportecEventDataDeserializer, SportecEventDataInputs +from .metadata import load_metadata __all__ = [ "SportecEventDataDeserializer", "SportecEventDataInputs", + "load_metadata", ] diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py index 229803070..028b81576 100644 --- a/kloppy/infra/serializers/event/sportec/deserializer.py +++ b/kloppy/infra/serializers/event/sportec/deserializer.py @@ -1,453 +1,31 @@ -from collections import OrderedDict -from datetime import datetime, timedelta +from copy import copy import logging from typing import IO, NamedTuple from lxml import objectify from kloppy.domain import ( - BallState, - BodyPart, - BodyPartQualifier, - CardType, DatasetFlag, EventDataset, EventType, - Ground, Metadata, - Official, - OfficialType, Orientation, PassResult, Period, - Player, Point, - PositionType, Provider, - Qualifier, - Score, - SetPieceQualifier, - SetPieceType, - ShotResult, - Team, ) from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.deserializer import EventDataDeserializer from kloppy.utils import performance_logging -position_types_mapping: dict[str, PositionType] = { - "TW": PositionType.Goalkeeper, - "IVR": PositionType.RightCenterBack, - "IVL": PositionType.LeftCenterBack, - "STR": PositionType.Striker, - "STL": PositionType.LeftForward, - "STZ": PositionType.Striker, - "ZO": PositionType.CenterAttackingMidfield, - "LV": PositionType.LeftBack, - "RV": PositionType.RightBack, - "DMR": PositionType.RightDefensiveMidfield, - "DRM": PositionType.RightDefensiveMidfield, - "DML": PositionType.LeftDefensiveMidfield, - "DLM": PositionType.LeftDefensiveMidfield, - "ORM": PositionType.RightMidfield, - "OLM": PositionType.LeftMidfield, - "RA": PositionType.RightWing, - "LA": PositionType.LeftWing, -} - -referee_types_mapping: dict[str, OfficialType] = { - "referee": OfficialType.MainReferee, - "firstAssistant": OfficialType.AssistantReferee, - "videoReferee": OfficialType.VideoAssistantReferee, - "videoRefereeAssistant": OfficialType.AssistantVideoAssistantReferee, - "secondAssistant": OfficialType.AssistantReferee, - "fourthOfficial": OfficialType.FourthOfficial, -} +from . import specification as SPORTEC +from .helpers import parse_datetime +from .metadata import SportecMetadata, load_metadata logger = logging.getLogger(__name__) -def _team_from_xml_elm(team_elm) -> Team: - team = Team( - team_id=team_elm.attrib["TeamId"], - name=team_elm.attrib["TeamName"], - ground=( - Ground.HOME if team_elm.attrib["Role"] == "home" else Ground.AWAY - ), - ) - team.players = [ - Player( - player_id=player_elm.attrib["PersonId"], - team=team, - jersey_no=int(player_elm.attrib["ShirtNumber"]), - name=player_elm.attrib["Shortname"], - first_name=player_elm.attrib["FirstName"], - last_name=player_elm.attrib["LastName"], - starting_position=position_types_mapping.get( - player_elm.attrib.get("PlayingPosition"), PositionType.Unknown - ), - starting=player_elm.attrib["Starting"] == "true", - ) - for player_elm in team_elm.Players.iterchildren("Player") - ] - return team - - -SPORTEC_FPS = 25 - -"""Sportec uses fixed starting frame ids for each half""" -SPORTEC_FIRST_HALF_STARTING_FRAME_ID = 10_000 -SPORTEC_SECOND_HALF_STARTING_FRAME_ID = 100_000 -SPORTEC_FIRST_EXTRA_HALF_STARTING_FRAME_ID = 200_000 -SPORTEC_SECOND_EXTRA_HALF_STARTING_FRAME_ID = 250_000 - - -class SportecMetadata(NamedTuple): - score: Score - teams: list[Team] - periods: list[Period] - x_max: float - y_max: float - fps: int - officials: list[Official] - - -def sportec_metadata_from_xml_elm(match_root) -> SportecMetadata: - """ - Load metadata from Sportec XML element. This part is shared between event- and tracking data. - In the future this might move to a common.sportec package that provides functionality for both - deserializers. - """ - x_max = float(match_root.MatchInformation.Environment.attrib["PitchX"]) - y_max = float(match_root.MatchInformation.Environment.attrib["PitchY"]) - - team_path = objectify.ObjectPath("PutDataRequest.MatchInformation.Teams") - team_elms = list(team_path.find(match_root).iterchildren("Team")) - - home_team = away_team = None - for team_elm in team_elms: - head_coach = [ - trainer.attrib["Shortname"] - for trainer in team_elm.TrainerStaff.iterchildren("Trainer") - if trainer.attrib["Role"] == "headcoach" - ] - if team_elm.attrib["Role"] == "home": - home_team = _team_from_xml_elm(team_elm) - home_coach = head_coach[0] if len(head_coach) else None - elif team_elm.attrib["Role"] == "guest": - away_team = _team_from_xml_elm(team_elm) - away_coach = head_coach[0] if len(head_coach) else None - else: - raise DeserializationError( - f"Unknown side: {team_elm.attrib['Role']}" - ) - - if not home_team: - raise DeserializationError("Home team is missing from metadata") - if not away_team: - raise DeserializationError("Away team is missing from metadata") - - ( - home_score, - away_score, - ) = match_root.MatchInformation.General.attrib["Result"].split(":") - score = Score(home=int(home_score), away=int(away_score)) - - home_team.coach = home_coach - away_team.coach = away_coach - teams = [home_team, away_team] - - if len(home_team.players) == 0 or len(away_team.players) == 0: - raise DeserializationError("LineUp incomplete") - - # The periods can be rebuild from event data. Therefore, the periods attribute - # from the metadata can be ignored. It is required for tracking data. - other_game_information = ( - match_root.MatchInformation.OtherGameInformation.attrib - ) - periods = [ - Period( - id=1, - start_timestamp=timedelta( - seconds=SPORTEC_FIRST_HALF_STARTING_FRAME_ID / SPORTEC_FPS - ), - end_timestamp=timedelta( - seconds=SPORTEC_FIRST_HALF_STARTING_FRAME_ID / SPORTEC_FPS - + float(other_game_information["TotalTimeFirstHalf"]) / 1000 - ), - ), - Period( - id=2, - start_timestamp=timedelta( - seconds=SPORTEC_SECOND_HALF_STARTING_FRAME_ID / SPORTEC_FPS - ), - end_timestamp=timedelta( - seconds=SPORTEC_SECOND_HALF_STARTING_FRAME_ID / SPORTEC_FPS - + float(other_game_information["TotalTimeSecondHalf"]) / 1000 - ), - ), - ] - - if "TotalTimeFirstHalfExtra" in other_game_information: - # Add two periods for extra time. - periods.extend( - [ - Period( - id=3, - start_timestamp=timedelta( - seconds=SPORTEC_FIRST_EXTRA_HALF_STARTING_FRAME_ID - / SPORTEC_FPS - ), - end_timestamp=timedelta( - seconds=SPORTEC_FIRST_EXTRA_HALF_STARTING_FRAME_ID - / SPORTEC_FPS - + float( - other_game_information["TotalTimeFirstHalfExtra"] - ) - / 1000 - ), - ), - Period( - id=4, - start_timestamp=timedelta( - seconds=SPORTEC_SECOND_EXTRA_HALF_STARTING_FRAME_ID - / SPORTEC_FPS - ), - end_timestamp=timedelta( - seconds=SPORTEC_SECOND_EXTRA_HALF_STARTING_FRAME_ID - / SPORTEC_FPS - + float( - other_game_information["TotalTimeSecondHalfExtra"] - ) - / 1000 - ), - ), - ] - ) - - if hasattr(match_root, "MatchInformation") and hasattr( - match_root.MatchInformation, "Referees" - ): - officials = [] - referee_path = objectify.ObjectPath( - "PutDataRequest.MatchInformation.Referees" - ) - referee_elms = referee_path.find(match_root).iterchildren(tag="Referee") - - for referee in referee_elms: - ref_attrib = referee.attrib - officials.append( - Official( - official_id=ref_attrib["PersonId"], - name=ref_attrib["Shortname"], - first_name=ref_attrib["FirstName"], - last_name=ref_attrib["LastName"], - role=referee_types_mapping.get( - ref_attrib["Role"], OfficialType.Unknown - ), - ) - ) - else: - officials = [] - - return SportecMetadata( - score=score, - teams=teams, - periods=periods, - x_max=x_max, - y_max=y_max, - fps=SPORTEC_FPS, - officials=officials, - ) - - -def _event_chain_from_xml_elm(event_elm): - chain = OrderedDict() - current_elm = event_elm - while True: - chain[current_elm.tag] = dict(current_elm.attrib) - if not current_elm.countchildren(): - break - current_elm = current_elm.getchildren()[0] - return chain - - -SPORTEC_EVENT_NAME_KICKOFF = "KickOff" -SPORTEC_EVENT_NAME_FINAL_WHISTLE = "FinalWhistle" - -SPORTEC_EVENT_NAME_SHOT_WIDE = "ShotWide" -SPORTEC_EVENT_NAME_SHOT_SAVED = "SavedShot" -SPORTEC_EVENT_NAME_SHOT_BLOCKED = "BlockedShot" -SPORTEC_EVENT_NAME_SHOT_WOODWORK = "ShotWoodWork" -SPORTEC_EVENT_NAME_SHOT_OTHER = "OtherShot" -SPORTEC_EVENT_NAME_SHOT_GOAL = "SuccessfulShot" -SPORTEC_EVENT_NAME_OWN_GOAL = "OwnGoal" -SPORTEC_SHOT_EVENT_NAMES = ( - SPORTEC_EVENT_NAME_SHOT_WIDE, - SPORTEC_EVENT_NAME_SHOT_SAVED, - SPORTEC_EVENT_NAME_SHOT_BLOCKED, - SPORTEC_EVENT_NAME_SHOT_WOODWORK, - SPORTEC_EVENT_NAME_SHOT_OTHER, - SPORTEC_EVENT_NAME_SHOT_GOAL, - SPORTEC_EVENT_NAME_OWN_GOAL, -) - -SPORTEC_EVENT_NAME_PASS = "Pass" -SPORTEC_EVENT_NAME_CROSS = "Cross" -SPORTEC_EVENT_NAME_THROW_IN = "ThrowIn" -SPORTEC_EVENT_NAME_GOAL_KICK = "GoalKick" -SPORTEC_EVENT_NAME_PENALTY = "Penalty" -SPORTEC_EVENT_NAME_CORNER_KICK = "CornerKick" -SPORTEC_EVENT_NAME_FREE_KICK = "FreeKick" -SPORTEC_PASS_EVENT_NAMES = (SPORTEC_EVENT_NAME_PASS, SPORTEC_EVENT_NAME_CROSS) - -SPORTEC_EVENT_NAME_BALL_CLAIMING = "BallClaiming" -SPORTEC_EVENT_NAME_SUBSTITUTION = "Substitution" -SPORTEC_EVENT_NAME_CAUTION = "Caution" -SPORTEC_EVENT_NAME_FOUL = "Foul" - -SPORTEC_EVENT_TYPE_OF_SHOT = "TypeOfShot" -SPORTEC_EVENT_BODY_PART_HEAD = "head" -SPORTEC_EVENT_BODY_PART_LEFT_FOOT = "leftLeg" -SPORTEC_EVENT_BODY_PART_RIGHT_FOOT = "rightLeg" - - -def _parse_datetime(dt_str: str) -> datetime: - return datetime.fromisoformat(dt_str) - - -def _get_event_qualifiers(event_chain: dict) -> list[Qualifier]: - qualifiers = [] - - qualifiers.extend(_get_event_setpiece_qualifiers(event_chain)) - qualifiers.extend(_get_event_bodypart_qualifiers(event_chain)) - - return qualifiers - - -def _get_event_setpiece_qualifiers(event_chain): - qualifiers = [] - - if SPORTEC_EVENT_NAME_THROW_IN in event_chain: - qualifiers.append(SetPieceQualifier(value=SetPieceType.THROW_IN)) - elif SPORTEC_EVENT_NAME_GOAL_KICK in event_chain: - qualifiers.append(SetPieceQualifier(value=SetPieceType.GOAL_KICK)) - elif SPORTEC_EVENT_NAME_PENALTY in event_chain: - qualifiers.append(SetPieceQualifier(value=SetPieceType.PENALTY)) - elif SPORTEC_EVENT_NAME_CORNER_KICK in event_chain: - qualifiers.append(SetPieceQualifier(value=SetPieceType.CORNER_KICK)) - elif SPORTEC_EVENT_NAME_KICKOFF in event_chain: - qualifiers.append(SetPieceQualifier(value=SetPieceType.KICK_OFF)) - elif SPORTEC_EVENT_NAME_FREE_KICK in event_chain: - qualifiers.append(SetPieceQualifier(value=SetPieceType.FREE_KICK)) - - return qualifiers - - -def _get_event_bodypart_qualifiers(event_chain): - qualifiers = [] - - if SPORTEC_EVENT_BODY_PART_HEAD in [ - item.get(SPORTEC_EVENT_TYPE_OF_SHOT) for item in event_chain.values() - ]: - qualifiers.append(BodyPartQualifier(value=BodyPart.HEAD)) - elif SPORTEC_EVENT_BODY_PART_LEFT_FOOT in [ - item.get(SPORTEC_EVENT_TYPE_OF_SHOT) for item in event_chain.values() - ]: - qualifiers.append(BodyPartQualifier(value=BodyPart.LEFT_FOOT)) - elif SPORTEC_EVENT_BODY_PART_RIGHT_FOOT in [ - item.get(SPORTEC_EVENT_TYPE_OF_SHOT) for item in event_chain.values() - ]: - qualifiers.append(BodyPartQualifier(value=BodyPart.RIGHT_FOOT)) - - return qualifiers - - -def _parse_shot(event_name: str, event_chain: OrderedDict) -> dict: - if event_name == SPORTEC_EVENT_NAME_SHOT_WIDE: - result = ShotResult.OFF_TARGET - elif event_name == SPORTEC_EVENT_NAME_SHOT_SAVED: - result = ShotResult.SAVED - elif event_name == SPORTEC_EVENT_NAME_SHOT_BLOCKED: - result = ShotResult.BLOCKED - elif event_name == SPORTEC_EVENT_NAME_SHOT_WOODWORK: - result = ShotResult.POST - elif event_name == SPORTEC_EVENT_NAME_SHOT_GOAL: - result = ShotResult.GOAL - elif event_name == SPORTEC_EVENT_NAME_OWN_GOAL: - result = ShotResult.OWN_GOAL - elif event_name == SPORTEC_EVENT_NAME_SHOT_OTHER: - result = None - else: - raise ValueError(f"Unknown shot type {event_name}") - - return dict(result=result, qualifiers=_get_event_qualifiers(event_chain)) - - -def _parse_pass(event_chain: OrderedDict, team: Team) -> dict: - if event_chain["Play"]["Evaluation"] in ( - "successfullyCompleted", - "successful", - ): - result = PassResult.COMPLETE - if "Recipient" in event_chain["Play"]: - receiver_player = team.get_player_by_id( - event_chain["Play"]["Recipient"] - ) - else: - # this attribute can be missing according to docs - receiver_player = None - else: - result = PassResult.INCOMPLETE - receiver_player = None - - return dict( - result=result, - receiver_player=receiver_player, - qualifiers=_get_event_qualifiers(event_chain), - ) - - -def _parse_substitution(event_attributes: dict, team: Team) -> dict: - return dict( - player=team.get_player_by_id(event_attributes["PlayerOut"]), - replacement_player=team.get_player_by_id(event_attributes["PlayerIn"]), - ) - - -def _parse_caution(event_attributes: dict) -> dict: - if event_attributes["CardColor"] == "yellow": - card_type = CardType.FIRST_YELLOW - elif event_attributes["CardColor"] == "yellowRed": - card_type = CardType.SECOND_YELLOW - elif event_attributes["CardColor"] == "red": - card_type = CardType.RED - else: - raise ValueError(f"Unknown card color: {event_attributes['CardColor']}") - - return dict(card_type=card_type) - - -def _parse_foul(event_attributes: dict, teams: list[Team]) -> dict: - team = ( - teams[0] - if event_attributes["TeamFouler"] == teams[0].team_id - else teams[1] - ) - player = team.get_player_by_id(event_attributes["Fouler"]) - - return dict(team=team, player=player) - - -def _parse_coordinates(event_attributes: dict) -> Point: - if "X-Position" not in event_attributes: - return None - return Point( - x=float(event_attributes["X-Position"]), - y=float(event_attributes["Y-Position"]), - ) - - class SportecEventDataInputs(NamedTuple): meta_data: IO[bytes] event_data: IO[bytes] @@ -461,248 +39,265 @@ def provider(self) -> Provider: return Provider.SPORTEC def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset: + # Load data from XML files with performance_logging("load data", logger=logger): match_root = objectify.fromstring(inputs.meta_data.read()) event_root = objectify.fromstring(inputs.event_data.read()) - with performance_logging("parse data", logger=logger): - date = datetime.fromisoformat( - match_root.MatchInformation.General.attrib["KickoffTime"] + # Flatten XML structure + raw_events = [] + for event_elm in parse_sportec_xml(event_root): + raw_events.append(SPORTEC.event_decoder(event_elm)) + # Sort events + raw_events.sort( + key=lambda x: parse_datetime(x.raw_event["EventTime"]) ) - game_week = match_root.MatchInformation.General.attrib["MatchDay"] - game_id = match_root.MatchInformation.General.attrib["MatchId"] - - sportec_metadata = sportec_metadata_from_xml_elm(match_root) - teams = home_team, away_team = sportec_metadata.teams - transformer = self.get_transformer( - pitch_length=sportec_metadata.x_max, - pitch_width=sportec_metadata.y_max, + + # Parse metadata + with performance_logging("parse metadata", logger=logger): + meta: SportecMetadata = load_metadata(match_root) + + # Initialize coordinate system transformer + transformer = self.get_transformer( + pitch_length=meta.x_max, pitch_width=meta.y_max + ) + + # Create periods + # We extract periods from the events, as the start/end times are + # more accurate there than in the metadata. + with performance_logging("parse periods", logger=logger): + periods, orientation = self._parse_periods_and_orientation( + raw_events, meta.teams ) - periods = [] - period_id = 0 + # Create events + with performance_logging("parse events", logger=logger): events = [] + for i, raw_event in enumerate(raw_events): + new_events = raw_event.set_refs( + periods, + meta.teams, + prev_events=(raw_events[max(0, i - 5) : i]), + next_events=( + raw_events[i + 1 : min(i + 6, len(raw_events) - 1)] + ), + ).deserialize(self.event_factory) + for event in new_events: + if self.should_include_event(event): + # Transform event to the coordinate system + event = transformer.transform_event(event) + events.append(event) - for event_elm in event_root.iterchildren("Event"): - event_chain = _event_chain_from_xml_elm(event_elm) - timestamp = _parse_datetime(event_chain["Event"]["EventTime"]) - - if ( - SPORTEC_EVENT_NAME_KICKOFF in event_chain - and "GameSection" in event_chain[SPORTEC_EVENT_NAME_KICKOFF] - ): - period_id += 1 - period = Period( - id=period_id, - start_timestamp=timestamp, - end_timestamp=None, - ) - if period_id == 1: - team_left = event_chain[SPORTEC_EVENT_NAME_KICKOFF][ - "TeamLeft" - ] - orientation = ( - Orientation.HOME_AWAY - if team_left == home_team.team_id - else Orientation.AWAY_HOME - ) + # Post-process events + self._update_pass_receiver_coordinates(events, transformer) - periods.append(period) - elif SPORTEC_EVENT_NAME_FINAL_WHISTLE in event_chain: - period.end_timestamp = timestamp - continue - elif period_id == 0: - # Skip any events that happened before the first kick off - continue - - team = None - player = None - flatten_attributes = dict() - # reverse because top levels are more important - for event_attributes in reversed(event_chain.values()): - flatten_attributes.update(event_attributes) - - if "Team" in flatten_attributes: - team = ( - home_team - if flatten_attributes["Team"] == home_team.team_id - else away_team - ) - if "Player" in flatten_attributes: - if not team: - raise ValueError("Player set while team is not set") - player = team.get_player_by_id(flatten_attributes["Player"]) - - generic_event_kwargs = dict( - # from DataRecord - period=period, - timestamp=timestamp - period.start_timestamp, - ball_owning_team=None, - ball_state=BallState.ALIVE, - # from Event - event_id=event_chain["Event"]["EventId"], - coordinates=_parse_coordinates(event_chain["Event"]), - raw_event=flatten_attributes, - team=team, - player=player, + metadata = Metadata( + date=meta.date, + game_week=int(meta.game_week), + game_id=meta.game_id, + officials=meta.officials, + teams=meta.teams, + periods=periods, + pitch_dimensions=transformer.get_to_coordinate_system().pitch_dimensions, + frame_rate=None, + orientation=orientation, + flags=DatasetFlag.BALL_STATE, + score=meta.score, + provider=Provider.SPORTEC, + coordinate_system=transformer.get_to_coordinate_system(), + ) + return EventDataset(metadata=metadata, records=events) + + def _parse_periods_and_orientation(self, raw_events, teams): + # Collect kick-off and final whistle events + half_start_events = {} + half_end_events = {} + for event in raw_events: + set_piece_type = event.raw_event["SetPieceType"] + event_type = event.raw_event["EventType"] + # Kick-off + if ( + set_piece_type is not None + and SPORTEC.SET_PIECE_TYPE(set_piece_type) + == SPORTEC.SET_PIECE_TYPE.KICK_OFF + ): + set_piece_attr = event.raw_event["extra"][set_piece_type] + if "GameSection" in set_piece_attr: + period = SPORTEC.PERIOD(set_piece_attr["GameSection"]) + half_start_events[period] = { + "EventTime": event.raw_event["EventTime"], + "TeamLeft": set_piece_attr.get("TeamLeft"), + } + else: + # This is a kick-off after a goal was scored + pass + # Final whistle + elif ( + event_type is not None + and SPORTEC.EVENT_TYPE(event_type) + == SPORTEC.EVENT_TYPE.FINAL_WHISTLE + ): + event_attr = event.raw_event["extra"][event_type] + period = SPORTEC.PERIOD(event_attr["GameSection"]) + half_end_events[period] = { + "EventTime": event.raw_event["EventTime"], + } + + # Create periods + periods = [] + for period_id, period_name in enumerate(SPORTEC.PERIOD, start=1): + start_event = half_start_events.get(period_name, None) + end_event = half_end_events.get(period_name, None) + if (start_event is None) ^ (end_event is None): + raise DeserializationError( + f"Failed to determine start and end time of period {period_id}." ) + if (start_event is None) and (end_event is None): + continue + start_timestamp = parse_datetime(start_event["EventTime"]) + end_timestamp = parse_datetime(end_event["EventTime"]) + period = Period( + id=period_id, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + ) + periods.append(period) + + # Determine orientation from first half kick-off + team_left = half_start_events[SPORTEC.PERIOD.FIRST_HALF]["TeamLeft"] + orientation = ( + Orientation.HOME_AWAY + if team_left == teams[0].team_id + else Orientation.AWAY_HOME + ) + return periods, orientation + + def _update_pass_receiver_coordinates(self, events, transformer): + pass_events = [ + (i, e) + for i, e in enumerate(events[:-1]) + if e.event_type == EventType.PASS + and e.result == PassResult.COMPLETE + ] - event_name, event_attributes = event_chain.popitem() - if event_name in SPORTEC_SHOT_EVENT_NAMES: - shot_event_kwargs = _parse_shot( - event_name=event_name, event_chain=event_chain - ) - event = self.event_factory.build_shot( - **shot_event_kwargs, - **generic_event_kwargs, - ) - elif event_name in SPORTEC_PASS_EVENT_NAMES: - pass_event_kwargs = _parse_pass( - event_chain=event_chain, team=team - ) - event = self.event_factory.build_pass( - **pass_event_kwargs, - **generic_event_kwargs, - receive_timestamp=None, - receiver_coordinates=None, - ) - elif event_name == SPORTEC_EVENT_NAME_BALL_CLAIMING: - event = self.event_factory.build_recovery( - result=None, - qualifiers=None, - **generic_event_kwargs, - ) - elif event_name == SPORTEC_EVENT_NAME_SUBSTITUTION: - substitution_event_kwargs = _parse_substitution( - event_attributes=event_attributes, team=team - ) - generic_event_kwargs["player"] = substitution_event_kwargs[ - "player" - ] - del substitution_event_kwargs["player"] - event = self.event_factory.build_substitution( - result=None, - qualifiers=None, - **substitution_event_kwargs, - **generic_event_kwargs, - ) - elif event_name == SPORTEC_EVENT_NAME_CAUTION: - card_kwargs = _parse_caution(event_attributes) - event = self.event_factory.build_card( - result=None, - qualifiers=None, - **card_kwargs, - **generic_event_kwargs, - ) - elif event_name == SPORTEC_EVENT_NAME_FOUL: - foul_kwargs = _parse_foul(event_attributes, teams=teams) - generic_event_kwargs.update(foul_kwargs) - event = self.event_factory.build_foul_committed( - result=None, - qualifiers=None, - **generic_event_kwargs, - ) - else: - event = self.event_factory.build_generic( - result=None, - qualifiers=None, - event_name=event_name, - **generic_event_kwargs, - ) + for i, pass_event in pass_events: + candidates = events[i + 1 : min(i + 5, len(events))] + receiver = next( + ( + e + for e in candidates + if (e.player == pass_event.receiver_player) + and (e.coordinates is not None) + ), + None, + ) + if receiver: + coords = copy(receiver.coordinates) if ( - event.event_type == EventType.PASS - and event.get_qualifier_value(SetPieceQualifier) - in ( - SetPieceType.THROW_IN, - SetPieceType.GOAL_KICK, - SetPieceType.CORNER_KICK, - ) + "X-Source-Position" in receiver.raw_event + and "Y-Source-Position" in receiver.raw_event ): - # 1. update previous pass - if events[-1].event_type == EventType.PASS: - events[-1].result = PassResult.OUT - - # 2. add synthetic out event - decision_timestamp = _parse_datetime( - event_chain[list(event_chain.keys())[1]][ - "DecisionTimestamp" - ] - ) - out_event = self.event_factory.build_ball_out( - period=period, - timestamp=decision_timestamp - period.start_timestamp, - ball_owning_team=None, - ball_state=BallState.DEAD, - # from Event - event_id=event_chain["Event"]["EventId"] + "-ball-out", - team=events[-1].team, - player=events[-1].player, - coordinates=None, - raw_event={}, - result=None, - qualifiers=None, + raw = receiver.raw_event + temp = receiver.replace( + coordinates=Point( + x=float(raw["X-Source-Position"]), + y=float(raw["Y-Source-Position"]), + ) ) - events.append(transformer.transform_event(out_event)) + coords = transformer.transform_event(temp).coordinates - events.append(transformer.transform_event(event)) + pass_event.receiver_coordinates = coords - for i, event in enumerate(events[:-1]): - if ( - event.event_type == EventType.PASS - and event.result == PassResult.COMPLETE - ): - # Sportec uses X/Y-Source-Position to define the start coordinates of - # an event and X/Y-Position to define the end of an event. There can/will - # be quite a distance between the start and the end of an event. - # When we want to set the receiver_coordinates we need to use - # the start of the event. - # How to solve this: - # 1. Create a copy of an event - # 2. Set the coordinates based on X/Y-Source-Position - # 3. Pass through the transformer - # 4. Update the receiver coordinates - if "X-Source-Position" in events[i + 1].raw_event: - updated_event = transformer.transform_event( - events[i + 1].replace( - coordinates=Point( - x=float( - events[i + 1].raw_event["X-Source-Position"] - ), - y=float( - events[i + 1].raw_event["Y-Source-Position"] - ), - ) - ) - ) - event.receiver_coordinates = updated_event.coordinates - else: - event.receiver_coordinates = events[i + 1].coordinates - events = list( - filter( - self.should_include_event, - events, - ) - ) +def parse_sportec_xml(root: objectify.ObjectifiedElement) -> list[dict]: + """Parses Sportec XML content into a structured list of event dictionaries. - metadata = Metadata( - teams=teams, - periods=periods, - pitch_dimensions=transformer.get_to_coordinate_system().pitch_dimensions, - score=sportec_metadata.score, - frame_rate=None, - orientation=orientation, - flags=~(DatasetFlag.BALL_STATE | DatasetFlag.BALL_OWNING_TEAM), - provider=Provider.SPORTEC, - coordinate_system=transformer.get_to_coordinate_system(), - date=date, - game_week=game_week, - game_id=game_id, - officials=sportec_metadata.officials, - ) + This function iterates through 'Event' elements in the provided XML. For each event, + it extracts top-level attributes and recursively traverses child elements to + categorize the event hierarchy and collect nested attributes into a specific format. + + The resulting dictionary for each event follows this structure: + - Root keys: Attributes from the tag (e.g., 'EventId', 'EventTime', 'X-Position'). + - 'SetPieceType': The specific set piece context (e.g., 'KickOff', 'CornerKick'), if present. + - 'EventType': The primary action type (e.g., 'Play', 'TacklingGame'). + - 'SubEventType': The specific action detail (e.g., 'Pass', 'Cross'), if present. + - 'extra': A nested dictionary containing attributes of all child tags, keyed by + the tag name (e.g., {'Play': {...}, 'Pass': {...}}). - return EventDataset( - metadata=metadata, - records=events, + Args: + root (objectify.ObjectifiedElement): The root element of the Sportec XML data. + + Returns: + list[dict]: A list of dictionaries, where each dictionary represents a single + parsed event. + """ + # Define tags that indicate a set piece context + SET_PIECE_TAGS = {sp.value for sp in SPORTEC.SET_PIECE_TYPE} + + # Helper to convert "true"/"false" strings to actual booleans + def convert_value(val): + if val.lower() == "true": + return True + elif val.lower() == "false": + return False + return val + + events_list = [] + + # Iterate over each element + for event in root.Event: + # 1. Initialize the root dict with attributes + event_data = {k: convert_value(v) for k, v in event.attrib.items()} + + # Initialize hierarchy placeholders + event_data.update( + { + "SetPieceType": None, + "EventType": None, + "SubEventType": None, + "extra": {}, + } ) + + # Track the sequence of tags found to determine hierarchy later + hierarchy_tags = [] + + # 2. Recursive function to traverse children + def traverse(element): + # iterchildren() iterates over direct children in document order + for child in element.iterchildren(): + tag = child.tag + hierarchy_tags.append(tag) + + # Store attributes in 'extra' keyed by the tag name + converted_attrs = { + k: convert_value(v) for k, v in child.attrib.items() + } + event_data["extra"][child.tag] = converted_attrs + + # Go deeper + traverse(child) + + traverse(event) + + # 3. Map the tags to the Type fields + if hierarchy_tags: + first_tag = hierarchy_tags[0] + + if first_tag in SET_PIECE_TAGS: + # Structure: [SetPiece] -> [Event] -> [SubEvent] + event_data["SetPieceType"] = first_tag + if len(hierarchy_tags) > 1: + event_data["EventType"] = hierarchy_tags[1] + if len(hierarchy_tags) > 2: + event_data["SubEventType"] = hierarchy_tags[2] + else: + # Structure: [Event] -> [SubEvent] + event_data["EventType"] = first_tag + if len(hierarchy_tags) > 1: + event_data["SubEventType"] = hierarchy_tags[1] + + events_list.append(event_data) + + return events_list diff --git a/kloppy/infra/serializers/event/sportec/helpers.py b/kloppy/infra/serializers/event/sportec/helpers.py new file mode 100644 index 000000000..fe000cac8 --- /dev/null +++ b/kloppy/infra/serializers/event/sportec/helpers.py @@ -0,0 +1,34 @@ +from datetime import datetime + +from kloppy.domain import ( + Period, + Team, +) +from kloppy.exceptions import DeserializationError + + +def get_team_by_id(team_id: int, teams: list[Team]) -> Team: + """Get a team by its id.""" + if str(team_id) == teams[0].team_id: + return teams[0] + elif str(team_id) == teams[1].team_id: + return teams[1] + else: + raise DeserializationError(f"Unknown team_id {team_id}") + + +def get_period_by_timestamp( + timestamp: datetime, periods: list[Period] +) -> Period: + """Get a period by its id.""" + for period in periods: + if period.start_timestamp <= timestamp <= period.end_timestamp: + return period + raise DeserializationError( + f"Could not find period for timestamp {timestamp}" + ) + + +def parse_datetime(timestamp: str) -> datetime: + """Parse a ISO format datetime string into a datetime object.""" + return datetime.fromisoformat(timestamp) diff --git a/kloppy/infra/serializers/event/sportec/metadata.py b/kloppy/infra/serializers/event/sportec/metadata.py new file mode 100644 index 000000000..db063a7a4 --- /dev/null +++ b/kloppy/infra/serializers/event/sportec/metadata.py @@ -0,0 +1,217 @@ +from datetime import datetime, timedelta +from typing import NamedTuple, Optional + +from lxml import objectify + +from kloppy.domain import ( + FormationType, + Ground, + Official, + OfficialType, + Period, + Player, + PositionType, + Score, + Team, +) +from kloppy.exceptions import DeserializationError + +# --- Constants --- + +SPORTEC_FPS = 25 +SPORTEC_START_FRAMES = { + 1: 10_000, + 2: 100_000, + 3: 200_000, + 4: 250_000, +} + +# --- Mappings --- + +POSITION_TYPES_MAPPING: dict[str, PositionType] = { + "TW": PositionType.Goalkeeper, + "IVR": PositionType.RightCenterBack, + "IVL": PositionType.LeftCenterBack, + "STR": PositionType.Striker, + "STL": PositionType.LeftForward, + "STZ": PositionType.Striker, + "ZO": PositionType.CenterAttackingMidfield, + "LV": PositionType.LeftBack, + "RV": PositionType.RightBack, + "DMR": PositionType.RightDefensiveMidfield, + "DRM": PositionType.RightDefensiveMidfield, + "DML": PositionType.LeftDefensiveMidfield, + "DLM": PositionType.LeftDefensiveMidfield, + "ORM": PositionType.RightMidfield, + "OLM": PositionType.LeftMidfield, + "RA": PositionType.RightWing, + "LA": PositionType.LeftWing, +} + +REFEREE_TYPES_MAPPING: dict[str, OfficialType] = { + "referee": OfficialType.MainReferee, + "firstAssistant": OfficialType.AssistantReferee, + "videoReferee": OfficialType.VideoAssistantReferee, + "videoRefereeAssistant": OfficialType.AssistantVideoAssistantReferee, + "secondAssistant": OfficialType.AssistantReferee, + "fourthOfficial": OfficialType.FourthOfficial, +} + + +# --- Models --- + + +class SportecMetadata(NamedTuple): + game_id: str + date: datetime + score: Score + teams: list[Team] + periods: list[Period] + x_max: float + y_max: float + fps: int + officials: list[Official] + game_week: Optional[int] = None + + +# --- Parsing Functions --- + + +def _extract_team_and_players(team_elm) -> Team: + """Extracts a Team object and its Players from the XML element.""" + # Parse Coach Name + head_coach = next( + ( + trainer.attrib.get("Shortname") + or f"{trainer.attrib.get('FirstName')} {trainer.attrib.get('LastName')}" + for trainer in team_elm.TrainerStaff.iterchildren("Trainer") + if trainer.attrib["Role"] == "headcoach" + ), + None, + ) + + # Parse Formation + formation_str = team_elm.attrib.get("LineUp", "").split()[0] + + team = Team( + team_id=team_elm.attrib["TeamId"], + name=team_elm.attrib["TeamName"], + ground=Ground.HOME + if team_elm.attrib["Role"] == "home" + else Ground.AWAY, + coach=head_coach, + starting_formation=( + FormationType(formation_str) + if formation_str + else FormationType.UNKNOWN + ), + ) + + team.players = [ + Player( + player_id=p.attrib["PersonId"], + team=team, + jersey_no=int(p.attrib["ShirtNumber"]), + name=( + p.attrib.get("Shortname") + or f"{p.attrib.get('FirstName')} {p.attrib.get('LastName')}" + ), + first_name=p.attrib["FirstName"], + last_name=p.attrib["LastName"], + starting_position=POSITION_TYPES_MAPPING.get( + p.attrib.get("PlayingPosition"), PositionType.Unknown + ) + if p.attrib["Starting"] == "true" + else None, + starting=p.attrib["Starting"] == "true", + ) + for p in team_elm.Players.iterchildren("Player") + ] + return team + + +def load_metadata(match_root: objectify.ObjectifiedElement) -> SportecMetadata: + """Parses the MatchInformation XML root into SportecMetadata.""" + + # 1. Pitch Dimensions + x_max = float(match_root.MatchInformation.Environment.attrib["PitchX"]) + y_max = float(match_root.MatchInformation.Environment.attrib["PitchY"]) + + # 2. Teams & Players + team_path = objectify.ObjectPath("PutDataRequest.MatchInformation.Teams") + team_elms = list(team_path.find(match_root).iterchildren("Team")) + + teams = [] + for role in ["home", "guest"]: + team_node = next( + (t for t in team_elms if t.attrib["Role"] == role), None + ) + if team_node is None: + raise DeserializationError(f"Missing {role} team in metadata") + teams.append(_extract_team_and_players(team_node)) + + # 3. Score + h_score, a_score = match_root.MatchInformation.General.attrib[ + "Result" + ].split(":") + + # 4. Periods + other_info = match_root.MatchInformation.OtherGameInformation.attrib + + def create_period(pid: int, duration_key: str): + if duration_key not in other_info: + return None + start_sec = SPORTEC_START_FRAMES[pid] / SPORTEC_FPS + duration_sec = float(other_info[duration_key]) / 1000 + return Period( + id=pid, + start_timestamp=timedelta(seconds=start_sec), + end_timestamp=timedelta(seconds=start_sec + duration_sec), + ) + + periods = [ + create_period(1, "TotalTimeFirstHalf"), + create_period(2, "TotalTimeSecondHalf"), + create_period(3, "TotalTimeFirstHalfExtra"), + create_period(4, "TotalTimeSecondHalfExtra"), + ] + periods = [p for p in periods if p is not None] + + # 5. Officials + officials = [] + if hasattr(match_root.MatchInformation, "Referees"): + ref_path = objectify.ObjectPath( + "PutDataRequest.MatchInformation.Referees" + ) + for ref in ref_path.find(match_root).iterchildren("Referee"): + officials.append( + Official( + official_id=ref.attrib["PersonId"], + name=ref.attrib["Shortname"], + first_name=ref.attrib["FirstName"], + last_name=ref.attrib["LastName"], + role=REFEREE_TYPES_MAPPING.get( + ref.attrib["Role"], OfficialType.Unknown + ), + ) + ) + + # 6. Match date + game_id = match_root.MatchInformation.General.attrib["MatchId"] + date = datetime.fromisoformat( + match_root.MatchInformation.General.attrib["KickoffTime"] + ) + game_week = match_root.MatchInformation.General.attrib.get("MatchDay", None) + + return SportecMetadata( + game_id=game_id, + date=date, + game_week=game_week, + score=Score(home=int(h_score), away=int(a_score)), + teams=teams, + periods=periods, + x_max=x_max, + y_max=y_max, + fps=SPORTEC_FPS, + officials=officials, + ) diff --git a/kloppy/infra/serializers/event/sportec/specification.py b/kloppy/infra/serializers/event/sportec/specification.py new file mode 100644 index 000000000..98fcea523 --- /dev/null +++ b/kloppy/infra/serializers/event/sportec/specification.py @@ -0,0 +1,971 @@ +from enum import Enum, EnumMeta +from typing import Optional, Union + +from kloppy.domain import ( + BallState, + BodyPart, + BodyPartQualifier, + CardQualifier, + CardType, + DuelQualifier, + DuelResult, + DuelType, + Event, + EventFactory, + ExpectedGoals, + PassQualifier, + PassResult, + PassType, + Point, + Qualifier, + SetPieceQualifier, + SetPieceType, + ShotResult, + TakeOnResult, + Team, +) +from kloppy.exceptions import DeserializationError + +from .helpers import get_period_by_timestamp, get_team_by_id, parse_datetime + + +class TypesEnumMeta(EnumMeta): + def __call__(cls, value, *args, **kw): + if isinstance(value, dict): + if value["id"] not in cls._value2member_map_: + raise DeserializationError( + "Unknown Sportec {}: {}/{}".format( + ( + cls.__qualname__.replace("_", " ") + .replace(".", " ") + .title() + ), + value["id"], + value["name"], + ) + ) + value = cls(value["id"]) + elif value not in cls._value2member_map_: + raise DeserializationError( + "Unknown Sportec {}: {}".format( + ( + cls.__qualname__.replace("_", " ") + .replace(".", " ") + .title() + ), + value, + ) + ) + return super().__call__(value, *args, **kw) + + +class PERIOD(Enum, metaclass=TypesEnumMeta): + """The list of period names used in Sportec data.""" + + FIRST_HALF = "firstHalf" + SECOND_HALF = "secondHalf" + # TODO: handle extra time periods + + @property + def id(self) -> int: + period_to_id = { + PERIOD.FIRST_HALF: 1, + PERIOD.SECOND_HALF: 2, + } + return period_to_id[self] + + +class SET_PIECE_TYPE(Enum, metaclass=TypesEnumMeta): + KICK_OFF = "KickOff" + FREE_KICK = "FreeKick" + CORNER_KICK = "CornerKick" + THROW_IN = "ThrowIn" + GOAL_KICK = "GoalKick" + PENALTY = "Penalty" + + +class CARD_TYPE(Enum, metaclass=TypesEnumMeta): + FIRST_YELLOW = "yellow" + SECOND_YELLOW = "yellowRed" + RED = "red" + + +class EVENT_TYPE(Enum, metaclass=TypesEnumMeta): + """The list of event types that compose all of Sportec data.""" + + FINAL_WHISTLE = "FinalWhistle" + SHOT = "ShotAtGoal" + PLAY = "Play" + BALL_CLAIMING = "BallClaiming" + SUBSTITUTION = "Substitution" + CAUTION = "Caution" + FOUL = "Foul" + TACKLING_GAME = "TacklingGame" + OTHER = "OtherBallAction" + DELETE = "Delete" + FAIR_PLAY = "FairPlay" + OFFSIDE = "Offside" + SPECTACULAR_PLAY = "SpectacularPlay" + PENALTY_NOT_AWARDED = "PenaltyNotAwarded" + RUN = "Run" + POSSESSION_LOSS_BEFORE_GOAL = "PossessionLossBeforeGoal" + NUTMEG = "Nutmeg" + REFEREE_BALL = "RefereeBall" + BALL_DEFLECTION = "BallDeflection" + CHANCE_WITHOUT_SHOT = "ChanceWithoutShot" + GOAL_DISALLOWED = "GoalDisallowed" + OWN_GOAL = "OwnGoal" + VIDEO_ASSISTANT_ACTION = "VideoAssistantAction" + + +class EVENT: + """Base class for Sportec events. + + This class is used to deserialize Sportec events into kloppy events. + This default implementation is used for all events that do not have a + specific implementation. They are deserialized into a generic event. + + Args: + raw_event: The raw JSON event + """ + + def __init__(self, raw_event: dict): + self.raw_event = raw_event + + def set_refs(self, periods, teams, prev_events=None, next_events=None): + event_type = self.raw_event["EventType"] + event_attr = self.raw_event["extra"][event_type] + self.period = get_period_by_timestamp( + parse_datetime(self.raw_event["EventTime"]), periods + ) + self.team = ( + get_team_by_id(event_attr["Team"], teams) + if "Team" in event_attr + else None + ) + self.player = ( + self.team.get_player_by_id(event_attr["Player"]) + if "Player" in event_attr and self.team + else None + ) + self._teams = teams + self.prev_events = prev_events + self.next_events = next_events + return self + + def deserialize(self, event_factory: EventFactory) -> list[Event]: + """Deserialize the event. + + Args: + event_factory: The event factory to use to build the event. + + Returns: + A list of kloppy events. + """ + generic_event_kwargs = self._parse_generic_kwargs() + + base_events = self._create_events(event_factory, **generic_event_kwargs) + return base_events + + def _parse_generic_kwargs(self) -> dict: + return { + "period": self.period, + "timestamp": ( + parse_datetime(self.raw_event["EventTime"]) + - self.period.start_timestamp + ), + "ball_owning_team": None, + "ball_state": BallState.ALIVE, + "event_id": self.raw_event["EventId"], + "team": self.team, + "player": self.player, + "coordinates": ( + Point( + x=float(self.raw_event["X-Position"]), + y=float(self.raw_event["Y-Position"]), + ) + if "X-Position" in self.raw_event + and "Y-Position" in self.raw_event + else None + ), + "related_event_ids": [], + "raw_event": self.raw_event, + "statistics": [], + } + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + name = "GenericEvent" + if self.raw_event.get("EventType") is not None: + name = self.raw_event["EventType"] + if self.raw_event.get("SubEventType") is not None: + name = self.raw_event["SubEventType"] + + generic_event = event_factory.build_generic( + result=None, + qualifiers=None, + event_name=name, + **generic_event_kwargs, + ) + return [generic_event] + + +class SHOT(EVENT): + """Sportec ShotAtGoal event.""" + + class TYPE(Enum, metaclass=TypesEnumMeta): + SHOT_WIDE = "ShotWide" + SHOT_SAVED = "SavedShot" + SHOT_BLOCKED = "BlockedShot" + SHOT_WOODWORK = "ShotWoodWork" + SHOT_OTHER = "OtherShot" + SHOT_GOAL = "SuccessfulShot" + + class BODYPART(Enum, metaclass=TypesEnumMeta): + LEFT_LEG = "leftLeg" + RIGHT_LEG = "rightLeg" + HEAD = "head" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + shot_dict = self.raw_event["extra"]["ShotAtGoal"] + outcome_id = SHOT.TYPE(self.raw_event["SubEventType"]) + + outcome_mapping = { + SHOT.TYPE.SHOT_WIDE: ShotResult.OFF_TARGET, + SHOT.TYPE.SHOT_SAVED: ShotResult.SAVED, + SHOT.TYPE.SHOT_BLOCKED: ShotResult.BLOCKED, + SHOT.TYPE.SHOT_WOODWORK: ShotResult.POST, + SHOT.TYPE.SHOT_OTHER: None, + SHOT.TYPE.SHOT_GOAL: ShotResult.GOAL, + } + + result = outcome_mapping.get(outcome_id) + + qualifiers = _get_set_piece_qualifiers( + self.raw_event + ) + _get_body_part_qualifiers(EVENT_TYPE.SHOT, shot_dict) + + for statistic_cls, prop_name in { + ExpectedGoals: "xG", + }.items(): + value = shot_dict.get(prop_name, None) + if value is not None: + generic_event_kwargs["statistics"].append( + statistic_cls(value=float(value)) + ) + + shot_event = event_factory.build_shot( + result=result, + qualifiers=qualifiers, + result_coordinates=None, + **generic_event_kwargs, + ) + + return [shot_event] + + +class PLAY(EVENT): + """Sportec Play event.""" + + class TYPE(Enum, metaclass=TypesEnumMeta): + PASS = "Pass" + CROSS = "Cross" + + class OUTCOME(Enum, metaclass=TypesEnumMeta): + SUCCESSFUL_COMPLETE = "successfullyCompleted" + SUCCESSFUL = "successful" + UNSUCCSESSFUL = "unsuccessful" + + class HEIGHT(Enum, metaclass=TypesEnumMeta): + GROUND = "flat" + HIGH = "high" + + class DIRECTION(Enum, metaclass=TypesEnumMeta): + DIAGONAL_BALL = "diagonalBall" + THROUGH_BALL = "throughBall" + SQUARE_BALL = "squarePass" + BACK_PASS = "backPass" + + class DISTANCE(Enum, metaclass=TypesEnumMeta): + LONG = "long" + MEDIUM = "medium" + SHORT = "short" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + pass_dict = self.raw_event["extra"]["Play"] + + # Parse result + result = None + if "Evaluation" in pass_dict: + outcome_name = pass_dict["Evaluation"] + outcome_mapping = { + PLAY.OUTCOME.SUCCESSFUL_COMPLETE: PassResult.COMPLETE, + PLAY.OUTCOME.SUCCESSFUL: PassResult.COMPLETE, # TODO: infer more specific outcome? + PLAY.OUTCOME.UNSUCCSESSFUL: PassResult.INCOMPLETE, + } + result = outcome_mapping.get(PLAY.OUTCOME(outcome_name)) + else: + result = None + + if result == PassResult.INCOMPLETE and _before_ball_out_restart(self): + result = PassResult.OUT + if result == PassResult.INCOMPLETE and _before_offside_event(self): + result = PassResult.OFFSIDE + + # Parse recipient + if "Recipient" in pass_dict: + team = generic_event_kwargs["team"] + receiver_player = team.get_player_by_id(pass_dict["Recipient"]) + else: + receiver_player = None + + # Parse qualifiers + add_qualifier = lambda v: qualifiers.append(PassQualifier(value=v)) + + subevent_type = self.raw_event["SubEventType"] + subevent_attr = self.raw_event["extra"].get(subevent_type, {}) + + qualifiers: list[Qualifier] = _get_set_piece_qualifiers(self.raw_event) + if ( + subevent_type is not None + and PLAY.TYPE(subevent_type) == PLAY.TYPE.CROSS + ): + add_qualifier(PassType.CROSS) + if ( + "Direction" in subevent_attr + and PLAY.DIRECTION(subevent_attr["Direction"]) + == PLAY.DIRECTION.THROUGH_BALL + ): + add_qualifier(PassType.THROUGH_BALL) + if ( + "Direction" in subevent_attr + and PLAY.DIRECTION(subevent_attr["Direction"]) + == PLAY.DIRECTION.DIAGONAL_BALL + ): + add_qualifier(PassType.SWITCH_OF_PLAY) + if ( + "Height" in pass_dict + and PLAY.HEIGHT(pass_dict["Height"]) == PLAY.HEIGHT.HIGH + ): + add_qualifier(PassType.HIGH_PASS) + if ( + "Distance" in pass_dict + and PLAY.DISTANCE(pass_dict["Distance"]) == PLAY.DISTANCE.LONG + ): + add_qualifier(PassType.LONG_BALL) + + pass_event = event_factory.build_pass( + result=result, + receive_timestamp=None, + receiver_coordinates=None, + receiver_player=receiver_player, + qualifiers=qualifiers, + **generic_event_kwargs, + ) + + is_restart = any( + qualifier.value + in { + SetPieceType.THROW_IN, + SetPieceType.GOAL_KICK, + SetPieceType.CORNER_KICK, + } + for qualifier in qualifiers + if isinstance(qualifier, SetPieceQualifier) + ) + if is_restart: + set_piece_type = self.raw_event["SetPieceType"] + set_piece_attr = self.raw_event["extra"][set_piece_type] + + if "DecisionTimestamp" in set_piece_attr: + decision_ts = parse_datetime( + set_piece_attr["DecisionTimestamp"] + ) + out_event = event_factory.build_ball_out( + period=self.period, + timestamp=(decision_ts - self.period.start_timestamp), + ball_owning_team=None, + ball_state=BallState.DEAD, + event_id=f"{self.raw_event['EventId']}-out", + team=None, + player=None, + coordinates=None, + raw_event={}, + result=None, + qualifiers=None, + ) + return [out_event, pass_event] + + return [pass_event] + + +class BALL_CLAIMING(EVENT): + """Sportec BallClaiming event.""" + + class TYPE(Enum, metaclass=TypesEnumMeta): + BALL_CLAIMED = "BallClaimed" + BALL_HELD = "BallHeld" + INTERCEPTED_BALL = "InterceptedBall" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + recovery_dict = self.raw_event["extra"]["BallClaiming"] + recovery_type = BALL_CLAIMING.TYPE(recovery_dict["Type"]) + if recovery_type == BALL_CLAIMING.TYPE.BALL_CLAIMED: + recovery_event = event_factory.build_recovery( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + elif recovery_type == BALL_CLAIMING.TYPE.INTERCEPTED_BALL: + recovery_event = event_factory.build_interception( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + elif recovery_type == BALL_CLAIMING.TYPE.BALL_HELD: + # TODO: What is a BallClaiming>BallHeld event? + recovery_event = event_factory.build_generic( + event_name="BallClaiming:BallHeld", + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + else: + raise DeserializationError( + f"Unknown recovery type: {recovery_type}" + ) + return [recovery_event] + + +class CAUTION(EVENT): + """Sportec Caution event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + card_type = _get_card_type(self.raw_event["extra"]["Caution"]) + if card_type: + card_event = event_factory.build_card( + result=None, + qualifiers=None, + card_type=card_type, + **generic_event_kwargs, + ) + return [card_event] + + generic_event = event_factory.build_generic( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + return [generic_event] + + +class FOUL(EVENT): + """Sportec Foul event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + foul_dict = self.raw_event["extra"]["Foul"] + # Parse team and player + team = ( + get_team_by_id(foul_dict["TeamFouler"], self._teams) + if "TeamFouler" in foul_dict + else None + ) + player = ( + team.get_player_by_id(foul_dict["Fouler"]) + if "Fouler" in foul_dict and team + else None + ) + generic_event_kwargs = { + **generic_event_kwargs, + "player": player, + "team": team, + "ball_state": BallState.DEAD, + } + + # Parse card qualifier + qualifiers = None + card_event = _before_card_event(self) + if card_event: + card_type = _get_card_type(card_event.raw_event["extra"]["Caution"]) + if card_type: + qualifiers = ( + [CardQualifier(value=card_type)] if card_type else [] + ) + + foul_committed_event = event_factory.build_foul_committed( + result=None, + qualifiers=qualifiers, + **generic_event_kwargs, + ) + return [foul_committed_event] + + +class OTHER_BALL_ACTION(EVENT): + """Sportec OtherBallAction event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + event_attr = self.raw_event["extra"]["OtherBallAction"] + if event_attr.get("DefensiveClearance", False): + return [ + event_factory.build_clearance( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + return [ + event_factory.build_generic( + event_name="OtherBallAction", + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + + +class SUBSTITUTION(EVENT): + """Sportec Substitution event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + sub_dict = self.raw_event["extra"]["Substitution"] + generic_event_kwargs["player"] = self.team.get_player_by_id( + sub_dict["PlayerOut"] + ) + + substitution_event = event_factory.build_substitution( + replacement_player=self.team.get_player_by_id(sub_dict["PlayerIn"]), + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + return [substitution_event] + + +class TACKLING_GAME(EVENT): + """Sportec TacklingGame event.""" + + class WINNER_RESULT(Enum, metaclass=TypesEnumMeta): + BALL_CONTROL_RETAINED = "ballControlRetained" + BALL_CONTACT_SUCCEEDED = "ballcontactSucceeded" + DRIBBLED_AROUND = "dribbledAround" + FOULED = "fouled" + LAYOFF = "layoff" + BALL_CLAIMED = "ballClaimed" + + class ROLE(Enum, metaclass=TypesEnumMeta): + # Recorded when DUEL.TYPE is DUEL_TACKLE + WON = "withBallControl" + LOST_IN_PLAY = "withoutBallControl" + + class DRIBBLING_TYPE(Enum, metaclass=TypesEnumMeta): + AT_THE_FOOT = "atTheFoot" + OVERRUN = "overrun" + + class DRIBBLING_EVALUATION(Enum, metaclass=TypesEnumMeta): + SUCCESSFUL = "successful" + UNSUCCESSFUL = "unsuccessful" + + class DRIBBLING_SIDE(Enum, metaclass=TypesEnumMeta): + LEFT = "left" + RIGHT = "right" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + duel_dict = self.raw_event["extra"]["TacklingGame"] + + def q_tackle(source: str) -> list[Qualifier]: + dt = DuelType.AERIAL if source == "air" else DuelType.GROUND + return [ + DuelQualifier(value=dt), + DuelQualifier(value=DuelType.TACKLE), + ] + + def q_duel(source: str) -> list[Qualifier]: + dt = DuelType.AERIAL if source == "air" else DuelType.GROUND + return [DuelQualifier(value=dt)] + + if ( + duel_dict.get("DribbleEvaluation") + == TACKLING_GAME.DRIBBLING_EVALUATION.SUCCESSFUL.value + and duel_dict.get("WinnerResult") + == TACKLING_GAME.WINNER_RESULT.DRIBBLED_AROUND.value + ): + return [ + event_factory.build_take_on( + result=TakeOnResult.COMPLETE, + qualifiers=[], + **_parse_winner( + generic_event_kwargs, duel_dict, self._teams + ), + ), + event_factory.build_duel( + result=DuelResult.LOST, + qualifiers=q_duel(duel_dict.get("Type", "ground")), + **_parse_loser( + generic_event_kwargs, duel_dict, self._teams + ), + ), + ] + + elif ( + duel_dict.get("DribbleEvaluation") + == TACKLING_GAME.DRIBBLING_EVALUATION.UNSUCCESSFUL.value + and duel_dict.get("WinnerResult") + == TACKLING_GAME.WINNER_RESULT.BALL_CLAIMED.value + ): + return [ + event_factory.build_take_on( + result=TakeOnResult.INCOMPLETE, + qualifiers=[], + **_parse_loser( + generic_event_kwargs, duel_dict, self._teams + ), + ), + event_factory.build_duel( + result=DuelResult.WON, + qualifiers=q_duel(duel_dict.get("Type", "ground")), + **_parse_winner( + generic_event_kwargs, duel_dict, self._teams + ), + ), + ] + + elif ( + duel_dict.get("DribbleEvaluation") + == TACKLING_GAME.DRIBBLING_EVALUATION.SUCCESSFUL.value + and duel_dict.get("WinnerResult") + == TACKLING_GAME.WINNER_RESULT.FOULED.value + ): + return [ + event_factory.build_take_on( + result=TakeOnResult.COMPLETE, + qualifiers=[], + **_parse_winner( + generic_event_kwargs, duel_dict, self._teams + ), + ), + # the foul is also provided as a separate event + ] + + elif ( + duel_dict.get("DribbleEvaluation") + == TACKLING_GAME.DRIBBLING_EVALUATION.UNSUCCESSFUL.value + and duel_dict.get("WinnerResult") + == TACKLING_GAME.WINNER_RESULT.FOULED.value + ): + return [ + event_factory.build_take_on( + result=TakeOnResult.COMPLETE, # FIXME: Maybe we do not have a good result type for this one + qualifiers=[], + **_parse_loser( + generic_event_kwargs, duel_dict, self._teams + ), + ), + # the foul is also provided as a separate event + ] + + elif ( + duel_dict.get("WinnerResult") + == TACKLING_GAME.WINNER_RESULT.BALL_CLAIMED.value + ): + return [ + event_factory.build_duel( + result=DuelResult.WON, + qualifiers=q_duel(duel_dict.get("Type", "ground")), + **_parse_winner( + generic_event_kwargs, duel_dict, self._teams + ), + ), + event_factory.build_duel( + result=DuelResult.LOST, + qualifiers=q_duel(duel_dict.get("Type", "ground")), + **_parse_loser( + generic_event_kwargs, duel_dict, self._teams + ), + ), + ] + + elif ( + duel_dict.get("WinnerResult") + == TACKLING_GAME.WINNER_RESULT.BALL_CONTROL_RETAINED.value + ): + return [ + event_factory.build_duel( + result=DuelResult.WON, + qualifiers=q_duel(duel_dict.get("Type", "ground")), + **_parse_winner( + generic_event_kwargs, duel_dict, self._teams + ), + ), + event_factory.build_duel( + result=DuelResult.LOST, + qualifiers=q_duel(duel_dict.get("Type", "ground")), + **_parse_loser( + generic_event_kwargs, duel_dict, self._teams + ), + ), + ] + + elif ( + duel_dict.get("WinnerResult") + == TACKLING_GAME.WINNER_RESULT.BALL_CONTACT_SUCCEEDED.value + ): + return [ + event_factory.build_duel( + result=DuelResult.NEUTRAL, + qualifiers=q_duel(duel_dict.get("Type", "ground")), + **_parse_winner( + generic_event_kwargs, duel_dict, self._teams + ), + ), + event_factory.build_duel( + result=DuelResult.NEUTRAL, + qualifiers=q_duel(duel_dict.get("Type", "ground")), + **_parse_loser( + generic_event_kwargs, duel_dict, self._teams + ), + ), + ] + + elif ( + duel_dict.get("WinnerResult") + == TACKLING_GAME.WINNER_RESULT.LAYOFF.value + ): + return [ + event_factory.build_generic( + event_name="TacklingGame:Layoff", + result=None, + qualifiers=[], + **_parse_winner( + generic_event_kwargs, duel_dict, self._teams + ), + ), + event_factory.build_generic( + event_name="TacklingGame:Layoff", + result=None, + qualifiers=[], + **_parse_loser( + generic_event_kwargs, duel_dict, self._teams + ), + ), + ] + + elif ( + duel_dict.get("WinnerResult") + == TACKLING_GAME.WINNER_RESULT.FOULED.value + ): + return [ + event_factory.build_duel( + result=DuelResult.WON, + qualifiers=q_duel(duel_dict.get("Type", "ground")), + **_parse_winner( + generic_event_kwargs, duel_dict, self._teams + ), + ), + # the foul is also provided as a separate event + ] + + return [ + event_factory.build_generic( + event_name="TacklingGame:Unknown", + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + + +class DELETE(EVENT): + """Sportec Delete event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [] + + +class OWN_GOAL(EVENT): + """Sportec OwnGoal event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + shot_event = event_factory.build_shot( + result=ShotResult.OWN_GOAL, + qualifiers=None, + **generic_event_kwargs, + ) + return [shot_event] + + +def _get_body_part_qualifiers( + event_type: EVENT_TYPE, + event_dict: dict, +) -> list[BodyPartQualifier]: + sportec_to_kloppy_body_part_mapping = { + SHOT.BODYPART.LEFT_LEG: BodyPart.LEFT_FOOT, + SHOT.BODYPART.RIGHT_LEG: BodyPart.RIGHT_FOOT, + SHOT.BODYPART.HEAD: BodyPart.HEAD, + } + + body_part_name = None + if event_type == EVENT_TYPE.SHOT: + if "TypeOfShot" in event_dict: + body_part_name = SHOT.BODYPART(event_dict["TypeOfShot"]) + else: + raise RuntimeError( + f"Sportec does not annotate body parts for events of type {event_type}" + ) + if body_part_name in sportec_to_kloppy_body_part_mapping: + body_part = sportec_to_kloppy_body_part_mapping[body_part_name] + return [BodyPartQualifier(value=body_part)] + + return [] + + +def _get_card_type(event_dict: dict) -> Optional[CardType]: + sportec_to_kloppy_card_mappings = { + CARD_TYPE.FIRST_YELLOW: CardType.FIRST_YELLOW, + CARD_TYPE.SECOND_YELLOW: CardType.SECOND_YELLOW, + CARD_TYPE.RED: CardType.RED, + } + if "CardColor" in event_dict: + card_name = CARD_TYPE(event_dict["CardColor"]) + return sportec_to_kloppy_card_mappings[card_name] + return None + + +def _get_set_piece_qualifiers(event_dict: dict) -> list[SetPieceQualifier]: + mapping = { + SET_PIECE_TYPE.THROW_IN: SetPieceType.THROW_IN, + SET_PIECE_TYPE.GOAL_KICK: SetPieceType.GOAL_KICK, + SET_PIECE_TYPE.PENALTY: SetPieceType.PENALTY, + SET_PIECE_TYPE.CORNER_KICK: SetPieceType.CORNER_KICK, + SET_PIECE_TYPE.KICK_OFF: SetPieceType.KICK_OFF, + SET_PIECE_TYPE.FREE_KICK: SetPieceType.FREE_KICK, + } + if event_dict.get("SetPieceType") is not None: + type_name = SET_PIECE_TYPE(event_dict["SetPieceType"]) + if type_name in mapping: + set_piece_type = mapping[type_name] + return [SetPieceQualifier(value=set_piece_type)] + return [] + + +def _parse_duel_actor( + base_kwargs: dict, attributes: dict, teams: list[Team], role_key: str +) -> dict: + team_id = attributes.get(f"{role_key}Team") + team = get_team_by_id(team_id, teams) + player = team.get_player_by_id(attributes.get(role_key)) + base_kwargs["event_id"] = base_kwargs["event_id"] + f"-{role_key}" + base_kwargs["team"] = team + base_kwargs["player"] = player + return base_kwargs + + +def _parse_winner(base_kwargs, attrs, teams): + return _parse_duel_actor(dict(base_kwargs), attrs, teams, "Winner") + + +def _parse_loser(base_kwargs, attrs, teams): + return _parse_duel_actor(dict(base_kwargs), attrs, teams, "Loser") + + +def _before_ball_out_restart(event: Event) -> Optional[Event]: + """Check if the event is before another event that brings the ball back into play.""" + for e in event.next_events or []: + event_type = e.raw_event["EventType"] + set_piece_type = e.raw_event["SetPieceType"] + if EVENT_TYPE(event_type) in { + # skip these event types + EVENT_TYPE.DELETE, + EVENT_TYPE.PENALTY_NOT_AWARDED, + EVENT_TYPE.RUN, + EVENT_TYPE.POSSESSION_LOSS_BEFORE_GOAL, + EVENT_TYPE.VIDEO_ASSISTANT_ACTION, + }: + continue + elif set_piece_type is not None and SET_PIECE_TYPE(set_piece_type) in { + SET_PIECE_TYPE.CORNER_KICK, + SET_PIECE_TYPE.THROW_IN, + SET_PIECE_TYPE.GOAL_KICK, + }: + return e + else: + return None + return None + + +def _before_card_event(event: Event) -> Optional[Event]: + """Check if the event is before a card event.""" + for e in event.next_events or []: + event_type = EVENT_TYPE(e.raw_event["EventType"]) + if event_type in { + # skip these event types + EVENT_TYPE.DELETE, + EVENT_TYPE.PENALTY_NOT_AWARDED, + EVENT_TYPE.RUN, + EVENT_TYPE.POSSESSION_LOSS_BEFORE_GOAL, + EVENT_TYPE.VIDEO_ASSISTANT_ACTION, + }: + continue + elif event_type == EVENT_TYPE.CAUTION: + return e + else: + return None + return None + + +def _before_offside_event(event: Event) -> Optional[Event]: + """Check if the event is before an offside event.""" + for e in event.next_events or []: + event_type = EVENT_TYPE(e.raw_event["EventType"]) + if event_type in { + # skip these event types + EVENT_TYPE.DELETE, + EVENT_TYPE.PENALTY_NOT_AWARDED, + EVENT_TYPE.RUN, + EVENT_TYPE.POSSESSION_LOSS_BEFORE_GOAL, + EVENT_TYPE.VIDEO_ASSISTANT_ACTION, + }: + continue + elif event_type == EVENT_TYPE.OFFSIDE: + return e + else: + return None + return None + + +def event_decoder(raw_event: dict) -> Union[EVENT, dict]: + type_to_event = { + EVENT_TYPE.SHOT: SHOT, + EVENT_TYPE.PLAY: PLAY, + EVENT_TYPE.BALL_CLAIMING: BALL_CLAIMING, + EVENT_TYPE.CAUTION: CAUTION, + EVENT_TYPE.FOUL: FOUL, + EVENT_TYPE.OTHER: OTHER_BALL_ACTION, + EVENT_TYPE.SUBSTITUTION: SUBSTITUTION, + EVENT_TYPE.TACKLING_GAME: TACKLING_GAME, + EVENT_TYPE.DELETE: DELETE, + EVENT_TYPE.OWN_GOAL: OWN_GOAL, + } + event_type = EVENT_TYPE(raw_event["EventType"]) + event_creator = type_to_event.get(event_type, EVENT) + return event_creator(raw_event) diff --git a/kloppy/infra/serializers/tracking/sportec/deserializer.py b/kloppy/infra/serializers/tracking/sportec/deserializer.py index 0fd793445..b0d9effd8 100644 --- a/kloppy/infra/serializers/tracking/sportec/deserializer.py +++ b/kloppy/infra/serializers/tracking/sportec/deserializer.py @@ -1,5 +1,5 @@ from collections import defaultdict -from datetime import datetime, timedelta +from datetime import timedelta import logging from typing import IO, Callable, NamedTuple, Optional, Union @@ -20,7 +20,7 @@ ) from kloppy.domain.services.frame_factory import create_frame from kloppy.infra.serializers.event.sportec.deserializer import ( - sportec_metadata_from_xml_elm, + load_metadata, ) from kloppy.utils import performance_logging @@ -315,12 +315,7 @@ def __init__( def deserialize(self, inputs: SportecTrackingDataInputs) -> TrackingDataset: with performance_logging("parse metadata", logger=logger): match_root = objectify.fromstring(inputs.meta_data.read()) - sportec_metadata = sportec_metadata_from_xml_elm(match_root) - date = datetime.fromisoformat( - match_root.MatchInformation.General.attrib["KickoffTime"] - ) - game_week = match_root.MatchInformation.General.attrib["MatchDay"] - game_id = match_root.MatchInformation.General.attrib["MatchId"] + sportec_metadata = load_metadata(match_root) periods = sportec_metadata.periods teams = home_team, away_team = sportec_metadata.teams player_map = { @@ -455,9 +450,9 @@ def deserialize(self, inputs: SportecTrackingDataInputs) -> TrackingDataset: provider=Provider.SPORTEC, flags=DatasetFlag.BALL_OWNING_TEAM | DatasetFlag.BALL_STATE, coordinate_system=transformer.get_to_coordinate_system(), - date=date, - game_week=game_week, - game_id=game_id, + date=sportec_metadata.date, + game_week=sportec_metadata.game_week, + game_id=sportec_metadata.game_id, officials=sportec_metadata.officials, ) diff --git a/kloppy/tests/files/sportec_events_J03WPY.xml b/kloppy/tests/files/sportec_events_J03WPY.xml new file mode 100644 index 000000000..c8065cd14 --- /dev/null +++ b/kloppy/tests/files/sportec_events_J03WPY.xml @@ -0,0 +1,6535 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kloppy/tests/files/sportec_meta_J03WPY.xml b/kloppy/tests/files/sportec_meta_J03WPY.xml new file mode 100644 index 000000000..46f1ffe15 --- /dev/null +++ b/kloppy/tests/files/sportec_meta_J03WPY.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py index d19b77152..b6e7cc5c7 100644 --- a/kloppy/tests/test_sportec.py +++ b/kloppy/tests/test_sportec.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone from pathlib import Path +from typing import cast import pytest @@ -8,24 +9,649 @@ BallState, BodyPart, BodyPartQualifier, + CardQualifier, + CardType, + DatasetFlag, DatasetType, + Dimension, + DuelQualifier, + DuelResult, + DuelType, EventDataset, + FormationType, + MetricPitchDimensions, Official, OfficialType, Orientation, + Origin, + PassQualifier, + PassResult, + PassType, Point, Point3D, PositionType, Provider, + Score, SetPieceQualifier, SetPieceType, ShotResult, + SportecEventDataCoordinateSystem, + SubstitutionEvent, + TakeOnResult, + Time, TrackingDataset, + VerticalOrientation, ) +from kloppy.domain.models.event import EventType + + +@pytest.fixture(scope="module") +def event_data(base_dir) -> str: + return base_dir / "files" / "sportec_events_J03WPY.xml" + + +@pytest.fixture(scope="module") +def meta_data(base_dir) -> str: + return base_dir / "files" / "sportec_meta_J03WPY.xml" + + +@pytest.fixture(scope="module") +def dataset(event_data: Path, meta_data: Path): + return sportec.load_event( + event_data=event_data, meta_data=meta_data, coordinates="sportec" + ) + + +class TestSportecMetadata: + """Tests related to deserializing metadata""" + + def test_provider(self, dataset): + """It should set the Sportec provider""" + assert dataset.metadata.provider == Provider.SPORTEC + + def test_date(self, dataset): + """It should set the correct match date""" + assert dataset.metadata.date == datetime.fromisoformat( + "2022-10-15T11:01:28.300+00:00" + ) + + def test_game_id(self, dataset): + """It should set the correct game id""" + assert dataset.metadata.game_id == "DFL-MAT-J03WPY" + + def test_game_week(self, dataset): + """It should set the correct game week""" + assert dataset.metadata.game_week == 12 + + def test_orientation(self, dataset): + """It should set the action-executing-team orientation""" + assert dataset.metadata.orientation == Orientation.AWAY_HOME + + def test_frame_rate(self, dataset): + """It should set the frame rate to None""" + assert dataset.metadata.frame_rate is None + + def test_teams(self, dataset): + """It should create the teams and player objects""" + # There should be two teams with the correct names and starting formations + assert dataset.metadata.teams[0].name == "Fortuna Düsseldorf" + assert dataset.metadata.teams[0].coach == "Daniel Thioune" + assert dataset.metadata.teams[0].starting_formation == FormationType( + "4-2-3-1" + ) + assert dataset.metadata.teams[1].name == "1. FC Nürnberg" + assert dataset.metadata.teams[1].coach == "M. Weinzierl" + assert dataset.metadata.teams[1].starting_formation == FormationType( + "4-1-3-2" + ) + # The teams should have the correct players + player = dataset.metadata.teams[0].get_player_by_id("DFL-OBJ-0000NZ") + assert player.player_id == "DFL-OBJ-0000NZ" + assert player.jersey_no == 25 + assert player.full_name == "Matthias Zimmermann" + + def test_player_position(self, dataset): + """It should set the correct player position from the events""" + # Starting players get their position from the STARTING_XI event + player = dataset.metadata.teams[0].get_player_by_id("DFL-OBJ-0000NZ") + + assert player.starting_position == PositionType.RightBack + assert player.starting + + # Substituted players have a position + sub_player = dataset.metadata.teams[0].get_player_by_id( + "DFL-OBJ-00008K" + ) + assert sub_player.starting_position is None + assert sub_player.positions.last() is not None + assert not sub_player.starting + + # Get player by position and time + periods = dataset.metadata.periods + period_1 = periods[0] + period_2 = periods[1] + + home_starting_gk = dataset.metadata.teams[0].get_player_by_position( + PositionType.Goalkeeper, + time=Time(period=period_1, timestamp=timedelta(seconds=0)), + ) + assert home_starting_gk.player_id == "DFL-OBJ-0028FW" # Kastenmeier + + home_starting_cam = dataset.metadata.teams[0].get_player_by_position( + PositionType.CenterAttackingMidfield, + time=Time(period=period_1, timestamp=timedelta(seconds=0)), + ) + assert home_starting_cam.player_id == "DFL-OBJ-002G5J" # Appelkamp + + home_ending_cam = dataset.metadata.teams[0].get_player_by_position( + PositionType.CenterAttackingMidfield, + time=Time(period=period_2, timestamp=timedelta(seconds=45 * 60)), + ) + assert home_ending_cam.player_id == "DFL-OBJ-00008K" # Hennings + + away_starting_gk = dataset.metadata.teams[1].get_player_by_position( + PositionType.Goalkeeper, + time=Time(period=period_1, timestamp=timedelta(seconds=92)), + ) + assert away_starting_gk.player_id == "DFL-OBJ-0001HW" # Mathenia + + def test_periods(self, dataset): + """It should create the periods""" + assert len(dataset.metadata.periods) == 2 + assert dataset.metadata.periods[0].id == 1 + assert dataset.metadata.periods[ + 0 + ].start_timestamp == datetime.fromisoformat( + "2022-10-15T13:01:28.310+02:00" + ) + assert dataset.metadata.periods[ + 0 + ].end_timestamp == datetime.fromisoformat( + "2022-10-15T13:47:31.000+02:00" + ) + assert dataset.metadata.periods[1].id == 2 + assert dataset.metadata.periods[ + 1 + ].start_timestamp == datetime.fromisoformat( + "2022-10-15T14:03:29.010+02:00" + ) + assert dataset.metadata.periods[ + 1 + ].end_timestamp == datetime.fromisoformat( + "2022-10-15T14:54:41.000+02:00" + ) + + def test_pitch_dimensions(self, dataset): + """It should set the correct pitch dimensions""" + assert dataset.metadata.pitch_dimensions == MetricPitchDimensions( + x_dim=Dimension(0, 105), + y_dim=Dimension(0, 68), + standardized=False, + pitch_length=105, + pitch_width=68, + ) + + def test_coordinate_system(self, dataset): + """It should set the correct coordinate system""" + coordinate_system = dataset.metadata.coordinate_system + assert isinstance(coordinate_system, SportecEventDataCoordinateSystem) + assert coordinate_system.origin == Origin.BOTTOM_LEFT + assert ( + coordinate_system.vertical_orientation + == VerticalOrientation.BOTTOM_TO_TOP + ) + assert coordinate_system.normalized is False + + def test_score(self, dataset): + """It should set the correct score""" + assert dataset.metadata.score == Score(0, 1) + + def test_officials(self, dataset): + """It should set the correct officials""" + referees = {role: list() for role in OfficialType} + for referee in dataset.metadata.officials: + referees[referee.role].append(referee) + # main referee + assert referees[OfficialType.MainReferee][0].name == "W. Haslberger" + assert referees[OfficialType.MainReferee][0].first_name == "Wolfgang" + assert referees[OfficialType.MainReferee][0].last_name == "Haslberger" + # assistants + assert referees[OfficialType.AssistantReferee][0].name == "D. Riehl" + assert referees[OfficialType.AssistantReferee][1].name == "L. Erbst" + assert referees[OfficialType.FourthOfficial][0].name == "N. Fuchs" + assert ( + referees[OfficialType.VideoAssistantReferee][0].name + == "D. Schlager" + ) + + def test_flags(self, dataset): + """It should set the correct flags""" + assert dataset.metadata.flags == DatasetFlag.BALL_STATE class TestSportecEventData: - """""" + """Generic tests related to deserializing events""" + + def test_generic_attributes(self, dataset: EventDataset): + """Test generic event attributes""" + event = dataset.get_event_by_id("18237400000011") + assert event.event_id == "18237400000011" + assert event.team.name == "1. FC Nürnberg" + assert event.ball_owning_team is None + assert event.player.name == "James Lawrence" + assert event.coordinates == Point(11.72, 50.25) + # raw_event must be flattened dict + assert isinstance(dataset.events[0].raw_event, dict) + assert event.raw_event["EventId"] == "18237400000011" + assert event.related_event_ids == [] + assert event.period.id == 1 + assert event.timestamp == ( + datetime.fromisoformat( + "2022-10-15T13:01:43.407+02:00" + ) # event timestamp + - datetime.fromisoformat( + "2022-10-15T13:01:28.310+02:00" + ) # period start + ) + assert event.ball_state == BallState.ALIVE + + def test_timestamp(self, dataset: EventDataset): + """It should set the correct timestamp, reset to zero after each period""" + kickoff_p1 = dataset.get_event_by_id("18237400000006") + assert kickoff_p1.timestamp == timedelta(seconds=0) + kickoff_p2 = dataset.get_event_by_id("18237400000772") + assert kickoff_p2.timestamp == timedelta(seconds=0) + + def test_ball_out_of_play(self, dataset: EventDataset): + """It should add a synthetic ball out event before each throw-in/corner/goal kick""" + ball_out_events = dataset.find_all("ball_out") + assert len(ball_out_events) == ( + 41 # throw-ins + + 11 # corners + + 18 # goal kicks + ) + + ball_out_event = dataset.get_event_by_id("18237400000023-out") + # Timestamp is set from "DecisionTimestamp" + assert ball_out_event.timestamp == ( + datetime.fromisoformat( + "2022-10-15T13:02:10.879+02:00" + ) # event timestamp + - datetime.fromisoformat( + "2022-10-15T13:01:28.310+02:00" + ) # period start + ) + + def test_correct_normalized_deserialization( + self, event_data: Path, meta_data: Path + ): + """Test if the normalized deserialization is correct""" + dataset = sportec.load_event( + event_data=event_data, meta_data=meta_data, coordinates="kloppy" + ) + + # The events should have standardized coordinates + kickoff = dataset.get_event_by_id("18237400000006") + assert kickoff.coordinates.x == pytest.approx(0.5, abs=1e-2) + assert kickoff.coordinates.y == pytest.approx(0.5, abs=1e-2) + + def test_supported_events(self, dataset: EventDataset): + """It should parse all supported event types""" + # Test the kloppy event types that are being parsed + event_types_set = set(event.event_type for event in dataset.events) + + assert EventType.GENERIC in event_types_set + assert EventType.SHOT in event_types_set + assert EventType.PASS in event_types_set + assert EventType.RECOVERY in event_types_set + assert EventType.SUBSTITUTION in event_types_set + assert EventType.CARD in event_types_set + assert EventType.FOUL_COMMITTED in event_types_set + assert EventType.CLEARANCE in event_types_set + assert EventType.INTERCEPTION in event_types_set + assert EventType.DUEL in event_types_set + assert EventType.TAKE_ON in event_types_set + + def test_unsupported_events(self, dataset: EventDataset): + generic_events = dataset.find_all("generic") + generic_event_types = {e.event_name for e in generic_events} + for event in generic_events: + if event.event_name == "generic": + print(event.raw_event) + assert generic_event_types == { + "OtherBallAction", # Are these carries and clearances? + "TacklingGame:Layoff", # What are layoffs? + "FairPlay", + "PossessionLossBeforeGoal", + "BallClaiming:BallHeld", # Probably a goalkeeper event + "Nutmeg", + "PenaltyNotAwarded", + "Run", + "SpectacularPlay", # Should be mapped to a pass? + "Offside", # Add as qualifier + "BallDeflection", + "RefereeBall", + "FinalWhistle", + } + + +class TestSportecShotEvent: + """Tests related to deserializing Shot events""" + + def test_deserialize_all(self, dataset: EventDataset): + """It should deserialize all shot events""" + events = dataset.find_all("shot") + assert len(events) == 26 # events + + def test_open_play(self, dataset: EventDataset): + """Verify specific attributes of simple open play shot""" + shot = dataset.get_event_by_id("18237400000125") + # A shot event should have a result + assert shot.result == ShotResult.SAVED + # Not implemented or not supported? + assert shot.result_coordinates is None + # A shot event should have a body part + assert shot.get_qualifier_value(BodyPartQualifier) == BodyPart.LEFT_FOOT + # An open play shot should not have a set piece qualifier + assert shot.get_qualifier_value(SetPieceQualifier) is None + # A shot event should have a xG value + assert ( + next( + statistic + for statistic in shot.statistics + if statistic.name == "xG" + ).value + == 0.5062 + ) + + # def test_free_kick(self, dataset: EventDataset): + # """It should add set piece qualifiers to free kick shots""" + # shot = dataset.get_event_by_id("???") + # assert ( + # shot.get_qualifier_value(SetPieceQualifier) + # == SetPieceType.FREE_KICK + # ) + + +class TestSportecPlayEvent: + """Tests related to deserializing Pass and Cross events""" + + def test_deserialize_all(self, dataset: EventDataset): + """It should deserialize all pass events""" + events = dataset.find_all("pass") + assert len(events) == 858 + 33 # pass 858 + cross 33 + + def test_open_play_pass(self, dataset: EventDataset): + """Verify specific attributes of simple open play pass""" + pass_event = dataset.get_event_by_id("18237400000007") + # A pass should have a result + assert pass_event.result == PassResult.COMPLETE + # A pass should have end coordinates + assert pass_event.receiver_coordinates == Point(35.27, 5.89) + # Sportec does not provide the end timestamp + assert pass_event.receive_timestamp is None + # A pass should have a receiver + assert pass_event.receiver_player.name == "Dawid Kownacki" + # Sportec only sets the bodypart for shots + assert pass_event.get_qualifier_value(BodyPartQualifier) is None + # A pass can have set piece qualifiers + assert pass_event.get_qualifier_value(SetPieceQualifier) is None + + def test_pass_result(self, dataset: EventDataset): + """It should set the correct pass result""" + # Evaluation="successfullyCompleted" + completed_pass = dataset.get_event_by_id("18237400000007") + assert completed_pass.result == PassResult.COMPLETE + # Evaluation="unsuccessful" + failed_pass = dataset.get_event_by_id("18237400000013") + assert failed_pass.result == PassResult.INCOMPLETE + # Evaluation="unsuccessful" + next event is throw-in + failed_pass_out = dataset.get_event_by_id("18237400000076") + assert failed_pass_out.result == PassResult.OUT + # Evaluation="unsuccessful" + next event is offside + failed_pass_offside = dataset.get_event_by_id("18237400000693") + assert failed_pass_offside.result == PassResult.OFFSIDE + + def test_receiver_coordinates(self, dataset: EventDataset): + """Completed pass should have receiver coordinates""" + pass_events = dataset.find_all("pass.complete") + for pass_event in pass_events: + if "Recipient" in pass_event.raw_event: + if pass_event.receiver_coordinates is None: + print(pass_event.event_id) + assert pass_event.receiver_coordinates is not None + + def test_pass_qualifiers(self, dataset: EventDataset): + """It should add pass qualifiers""" + pass_event = dataset.get_event_by_id("18237400000007") + assert set(pass_event.get_qualifier_values(PassQualifier)) == { + PassType.SWITCH_OF_PLAY, + PassType.HIGH_PASS, + PassType.LONG_BALL, + } + + def test_set_piece(self, dataset: EventDataset): + """It should add set piece qualifiers to free kick passes""" + pass_event = dataset.get_event_by_id("18237400000006") + assert ( + pass_event.get_qualifier_value(SetPieceQualifier) + == SetPieceType.KICK_OFF + ) + + +class TestSportecBallClaimingEvent: + """Tests related to deserializing BallClaiming events""" + + def test_deserialize_ball_claimed(self, dataset: EventDataset): + """It should deserialize all Type='BallClaimed' events as recoveries""" + events = dataset.find_all("recovery") + assert len(events) == 7 + + def test_deserialize_intercepted_ball(self, dataset: EventDataset): + """It should deserialize all Type='InterceptedBall' events as interceptions""" + events = dataset.find_all("interception") + assert len(events) == 4 + + interception = dataset.get_event_by_id("18237403501368") + assert interception.result is None # TODO: infer result + assert interception.get_qualifier_value(BodyPartQualifier) is None + + +class TestSportecCautionEvent: + """Tests related to deserializing Caution events""" + + def test_deserialize_all(self, dataset: EventDataset): + """It should create a card event for each Caution event.""" + events = dataset.find_all("card") + assert len(events) == 9 + + for event in events: + assert event.card_type == CardType.FIRST_YELLOW + + def test_attributes(self, dataset: EventDataset): + """Verify specific attributes of cards""" + card = dataset.get_event_by_id("18237400001225") + # A card should have a card type + assert card.card_type == CardType.FIRST_YELLOW + # Card qualifiers should not be added + assert card.get_qualifier_value(CardQualifier) is None + + +class TestSportecFoulEvent: + """Tests related to deserializing Foul events""" + + def test_deserialize_all(self, dataset: EventDataset): + """It should deserialize all foul events""" + events = dataset.find_all("foul_committed") + assert len(events) == 18 + + def test_player(self, dataset: EventDataset): + """It should get the player who committed the foul""" + foul = dataset.get_event_by_id("18237400000878") + assert foul.player.player_id == "DFL-OBJ-002G68" + assert foul.team.team_id == "DFL-CLU-000005" + + def test_ball_state(self, dataset: EventDataset): + """It should set the ball state to dead for fouls""" + foul = dataset.get_event_by_id("18237400000894") + assert foul.ball_state == BallState.DEAD + + def test_card(self, dataset: EventDataset): + """It should add a card qualifier if a card was given""" + foul_with_card = dataset.get_event_by_id("18237400000894") + assert ( + foul_with_card.get_qualifier_value(CardQualifier) + == CardType.FIRST_YELLOW + ) + + foul_without_card = dataset.get_event_by_id("18237400001114") + assert foul_without_card.get_qualifier_value(CardQualifier) is None + + +class TestSportecDefensiveClearanceEvent: + """Tests related to deserializing OtherBallAction>DefensiveClearance events""" + + def test_deserialize_all(self, dataset: EventDataset): + """It should deserialize all clearance events""" + events = dataset.find_all("clearance") + assert len(events) == 15 + + def test_attributes(self, dataset: EventDataset): + """Verify specific attributes of clearances""" + clearance = dataset.get_event_by_id("18237400000679") + # A clearance has no result + assert clearance.result is None + # A clearance has no bodypart + assert clearance.get_qualifier_value(BodyPartQualifier) is None + + +class TestSportecSubstitutionEvent: + """Tests related to deserializing Substitution events""" + + def test_deserialize_all(self, dataset: EventDataset): + """It should deserialize all substitution events""" + events = dataset.find_all("substitution") + assert len(events) == 9 + + # Verify that the player and replacement player are set correctly + subs = [ + ("DFL-OBJ-002G5J", "DFL-OBJ-00008K"), + ("DFL-OBJ-0026RH", "DFL-OBJ-J01CP5"), + ("DFL-OBJ-J01H9X", "DFL-OBJ-J01NQ8"), + ("DFL-OBJ-002595", "DFL-OBJ-0026IA"), + ("DFL-OBJ-J0178P", "DFL-OBJ-002G78"), + ("DFL-OBJ-002FYC", "DFL-OBJ-00286U"), + ("DFL-OBJ-0000F8", "DFL-OBJ-002GM1"), + ("DFL-OBJ-0000EJ", "DFL-OBJ-J01K2L"), + ("DFL-OBJ-0001LJ", "DFL-OBJ-0001BX"), + ] + for event_idx, (player_id, replacement_player_id) in enumerate(subs): + event = cast(SubstitutionEvent, events[event_idx]) + assert event.player == event.team.get_player_by_id(player_id) + assert event.replacement_player == event.team.get_player_by_id( + replacement_player_id + ) + + +class TestSportecTacklingGameEvent: + def test_deserialize_takeon(self, dataset: EventDataset): + """It should deserialize all TacklingGame events with a DribbleEvaluation attribute as take-ons.""" + events = dataset.find_all("take_on") + assert len(events) == 17 + + # A dribble should have a result and a duel associated with it + completed_dribble = dataset.get_event_by_id("18237400000845-Winner") + assert completed_dribble.result == TakeOnResult.COMPLETE + lost_duel = dataset.get_event_by_id("18237400000845-Loser") + assert lost_duel.event_type == EventType.DUEL + assert lost_duel.get_qualifier_value(DuelQualifier) == DuelType.GROUND + + failed_dribble = dataset.get_event_by_id("18237400001220-Loser") + assert failed_dribble.result == TakeOnResult.INCOMPLETE + won_duel = dataset.get_event_by_id("18237400001220-Winner") + assert won_duel.event_type == EventType.DUEL + assert won_duel.get_qualifier_value(DuelQualifier) == DuelType.GROUND + + # A dribble can have as result OUT + # dribble = dataset.get_event_by_id("???") + # assert dribble.result == TakeOnResult.OUT + + def test_deserialize_duel(self, dataset: EventDataset): + # ("WinnerResult", "ballClaimed") --> player with ball control looses duel + duel_won = dataset.get_event_by_id("18237400000092-Winner") + duel_won.player.player_id == "DFL-OBJ-0000EJ" + assert duel_won.event_type == EventType.DUEL + assert duel_won.get_qualifier_value(DuelQualifier) == DuelType.GROUND + assert duel_won.result == DuelResult.WON + duel_lost = dataset.get_event_by_id("18237400000092-Loser") + duel_lost.player.player_id == "DFL-OBJ-002FXT" + assert duel_lost.event_type == EventType.DUEL + assert duel_lost.get_qualifier_value(DuelQualifier) == DuelType.GROUND + assert duel_lost.result == DuelResult.LOST + # ("WinnerResult", "ballControlRetained") --> player with ball control wins duel + duel_won = dataset.get_event_by_id("18237400000870-Winner") + duel_won.player.player_id == "DFL-OBJ-002FYC" + assert duel_won.event_type == EventType.DUEL + assert duel_won.get_qualifier_value(DuelQualifier) == DuelType.GROUND + assert duel_won.result == DuelResult.WON + duel_lost = dataset.get_event_by_id("18237400000870-Loser") + duel_lost.player.player_id == "DFL-OBJ-0000F8" + assert duel_lost.event_type == EventType.DUEL + assert duel_lost.get_qualifier_value(DuelQualifier) == DuelType.GROUND + assert duel_lost.result == DuelResult.LOST + # ("WinnerResult", "ballcontactSucceeded") --> defender can touch the ball without recovering + duel_won = dataset.get_event_by_id("18237400000874-Winner") + duel_won.player.player_id == "DFL-OBJ-002FYC" + assert duel_won.event_type == EventType.DUEL + assert duel_won.get_qualifier_value(DuelQualifier) == DuelType.GROUND + assert duel_won.result == DuelResult.NEUTRAL + duel_lost = dataset.get_event_by_id("18237400000874-Loser") + duel_lost.player.player_id == "DFL-OBJ-002GMO" + assert duel_lost.event_type == EventType.DUEL + assert duel_lost.get_qualifier_value(DuelQualifier) == DuelType.GROUND + assert duel_lost.result == DuelResult.NEUTRAL + # ("WinnerResult", "layoff") + # TODO: not sure what this is + duel_won = dataset.get_event_by_id("18237400000945-Winner") + duel_won.player.player_id == "DFL-OBJ-0026RH" + assert duel_won.event_type == EventType.GENERIC + duel_lost = dataset.get_event_by_id("18237400000945-Loser") + duel_lost.player.player_id == "DFL-OBJ-0028T3" + assert duel_lost.event_type == EventType.GENERIC + # ("WinnerResult", "fouled") + duel_won = dataset.get_event_by_id("18237400000877-Winner") + duel_won.player.player_id == "DFL-OBJ-002FXT" + assert duel_won.event_type == EventType.DUEL + assert duel_won.get_qualifier_value(DuelQualifier) == DuelType.GROUND + assert duel_won.result == DuelResult.WON + duel_lost = dataset.get_event_by_id("18237400000877-Loser") + assert duel_lost is None + foul = dataset.get_event_by_id("18237400000878") + foul.player.player_id == "DFL-OBJ-002G68" + assert foul.event_type == EventType.FOUL_COMMITTED + + def test_deserialize_air_duel(self, dataset: EventDataset): + duel_won = dataset.get_event_by_id("18237400000925-Winner") + duel_won.player.player_id == "DFL-OBJ-0000EJ" + assert duel_won.event_type == EventType.DUEL + assert duel_won.get_qualifier_value(DuelQualifier) == DuelType.AERIAL + assert duel_won.result == DuelResult.NEUTRAL + duel_lost = dataset.get_event_by_id("18237400000925-Loser") + duel_lost.player.player_id == "DFL-OBJ-002FXT" + assert duel_lost.event_type == EventType.DUEL + assert duel_lost.get_qualifier_value(DuelQualifier) == DuelType.AERIAL + assert duel_lost.result == DuelResult.NEUTRAL + + +class TestSportecDeleteEvent: + def test_deserialize_delete_event(self, dataset: EventDataset): + """Delete events are thrown away""" + delete_event = dataset.get_event_by_id("18237400000016") + assert delete_event is None + + +class TestSportecLegacyEventData: + """Tests on some old private Sportec event data.""" @pytest.fixture def event_data(self, base_dir) -> str: @@ -49,8 +675,8 @@ def test_correct_event_data_deserialization(self, dataset: EventDataset): # raw_event must be flattened dict assert isinstance(dataset.events[0].raw_event, dict) - assert len(dataset.events) == 29 - assert dataset.events[28].result == ShotResult.OWN_GOAL + assert len(dataset.events) == 33 + assert dataset.events[31].result == ShotResult.OWN_GOAL assert dataset.metadata.orientation == Orientation.HOME_AWAY assert dataset.metadata.periods[0].id == 1 @@ -71,7 +697,7 @@ def test_correct_event_data_deserialization(self, dataset: EventDataset): # Check the timestamps assert dataset.events[0].timestamp == timedelta(seconds=0) assert dataset.events[1].timestamp == timedelta(seconds=3.123) - assert dataset.events[25].timestamp == timedelta(seconds=0) + assert dataset.events[28].timestamp == timedelta(seconds=0) player = dataset.metadata.teams[0].players[0] assert player.player_id == "DFL-OBJ-00001D" @@ -81,19 +707,19 @@ def test_correct_event_data_deserialization(self, dataset: EventDataset): # Check the qualifiers assert ( - dataset.events[25].get_qualifier_value(SetPieceQualifier) + dataset.events[28].get_qualifier_value(SetPieceQualifier) == SetPieceType.KICK_OFF ) assert ( - dataset.events[16].get_qualifier_value(BodyPartQualifier) + dataset.events[18].get_qualifier_value(BodyPartQualifier) == BodyPart.RIGHT_FOOT ) assert ( - dataset.events[24].get_qualifier_value(BodyPartQualifier) + dataset.events[26].get_qualifier_value(BodyPartQualifier) == BodyPart.LEFT_FOOT ) assert ( - dataset.events[26].get_qualifier_value(BodyPartQualifier) + dataset.events[29].get_qualifier_value(BodyPartQualifier) == BodyPart.HEAD ) @@ -311,3 +937,13 @@ def test_referees(self, raw_data_referee: Path, meta_data: Path): == "main_referee_42" ) assert Official(official_id="42").full_name == "official_42" + + +# @pytest.mark.parametrize( +# "match_id", +# ["J03WPY", "J03WN1", "J03WMX", "J03WOH", "J03WQQ", "J03WOY", "J03WR9"], +# ) +# def test_load_open_data(match_id): +# """Test if it can load all public event data""" +# dataset = sportec.load_open_event_data(match_id) +# assert isinstance(dataset, EventDataset)