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)