From c4df3faee060fe56474b08d1086613a740015af8 Mon Sep 17 00:00:00 2001
From: Mark Thompson <41844090+mrkthmpsn@users.noreply.github.com>
Date: Tue, 6 May 2025 19:48:40 +0100
Subject: [PATCH 01/14] Add extra Sportec test files
Taken from the IDSSE dataset, to be used when testing updates to the Sportec deserializer
---
kloppy/tests/files/sportec_events_J03WPY.xml | 6535 ++++++++++++++++++
kloppy/tests/files/sportec_meta_J03WPY.xml | 83 +
2 files changed, 6618 insertions(+)
create mode 100644 kloppy/tests/files/sportec_events_J03WPY.xml
create mode 100644 kloppy/tests/files/sportec_meta_J03WPY.xml
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 0e30d80d1de85c4d25854c1cecad649744c482ba Mon Sep 17 00:00:00 2001
From: Mark Thompson <41844090+mrkthmpsn@users.noreply.github.com>
Date: Tue, 6 May 2025 19:50:09 +0100
Subject: [PATCH 02/14] Add clearance to Sportec deserializer
---
.../serializers/event/sportec/deserializer.py | 10 +++++
kloppy/tests/test_sportec.py | 42 +++++++++++++++++++
2 files changed, 52 insertions(+)
diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py
index b250d91e3..a68740ee8 100644
--- a/kloppy/infra/serializers/event/sportec/deserializer.py
+++ b/kloppy/infra/serializers/event/sportec/deserializer.py
@@ -304,6 +304,7 @@ def _event_chain_from_xml_elm(event_elm):
SPORTEC_EVENT_NAME_SUBSTITUTION = "Substitution"
SPORTEC_EVENT_NAME_CAUTION = "Caution"
SPORTEC_EVENT_NAME_FOUL = "Foul"
+SPORTEC_EVENT_NAME_OTHER = "OtherBallAction"
SPORTEC_EVENT_TYPE_OF_SHOT = "TypeOfShot"
SPORTEC_EVENT_BODY_PART_HEAD = "head"
@@ -605,6 +606,15 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset:
qualifiers=None,
**generic_event_kwargs,
)
+ elif (
+ event_name == SPORTEC_EVENT_NAME_OTHER
+ and event_attributes.get("DefensiveClearance") == "true"
+ ):
+ event = self.event_factory.build_clearance(
+ result=None,
+ qualifiers=None,
+ **generic_event_kwargs,
+ )
else:
event = self.event_factory.build_generic(
result=None,
diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py
index f713ba258..fa162af8d 100644
--- a/kloppy/tests/test_sportec.py
+++ b/kloppy/tests/test_sportec.py
@@ -19,6 +19,7 @@
)
from kloppy import sportec
+from kloppy.domain.models.event import EventType
class TestSportecEventData:
@@ -32,6 +33,14 @@ def event_data(self, base_dir) -> str:
def meta_data(self, base_dir) -> str:
return base_dir / "files/sportec_meta.xml"
+ @pytest.fixture
+ def event_data_new(self, base_dir) -> str:
+ return base_dir / "files/sportec_events_J03WPY.xml"
+
+ @pytest.fixture
+ def meta_data_new(self, base_dir) -> str:
+ return base_dir / "files/sportec_meta_J03WPY.xml"
+
def test_correct_event_data_deserialization(
self, event_data: Path, meta_data: Path
):
@@ -109,6 +118,39 @@ def test_pass_receiver_coordinates(
x=0.7775, y=0.43073529411764705
)
+ def test_correct_event_data_deserialization_new(
+ self, event_data_new: Path, meta_data_new: Path
+ ):
+ """A basic version of the event data deserialization test, for a newer event data file."""
+
+ dataset = sportec.load_event(
+ event_data=event_data_new,
+ meta_data=meta_data_new,
+ coordinates="sportec",
+ )
+
+ assert dataset.metadata.provider == Provider.SPORTEC
+ assert dataset.dataset_type == DatasetType.EVENT
+ assert len(dataset.metadata.periods) == 2
+
+ # raw_event must be flattened dict
+ assert isinstance(dataset.events[0].raw_event, dict)
+
+ # Test the kloppy event types that are being parsed
+ event_types_set = set(event.event_type for event in dataset.events)
+
+ # Kloppy types that were already being deserialized
+ 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.GENERIC in event_types_set
+
+ # Kloppy types added in PR #XXXX
+ assert EventType.CLEARANCE in event_types_set
+
class TestSportecTrackingData:
"""
From 3ec9f9ffd73a0156676dbaabce0d5e95388772fc Mon Sep 17 00:00:00 2001
From: Mark Thompson <41844090+mrkthmpsn@users.noreply.github.com>
Date: Tue, 6 May 2025 19:50:38 +0100
Subject: [PATCH 03/14] Add interception to Sportec deserializer
---
.../serializers/event/sportec/deserializer.py | 17 ++++++++++++-----
kloppy/tests/test_sportec.py | 1 +
2 files changed, 13 insertions(+), 5 deletions(-)
diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py
index a68740ee8..e0b073572 100644
--- a/kloppy/infra/serializers/event/sportec/deserializer.py
+++ b/kloppy/infra/serializers/event/sportec/deserializer.py
@@ -571,11 +571,18 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset:
receiver_coordinates=None,
)
elif event_name == SPORTEC_EVENT_NAME_BALL_CLAIMING:
- event = self.event_factory.build_recovery(
- result=None,
- qualifiers=None,
- **generic_event_kwargs,
- )
+ if event_attributes.get("Type") == "BallClaimed":
+ event = self.event_factory.build_recovery(
+ result=None,
+ qualifiers=None,
+ **generic_event_kwargs,
+ )
+ elif event_attributes.get("Type") == "InterceptedBall":
+ event = self.event_factory.build_interception(
+ 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
diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py
index fa162af8d..c9d53681d 100644
--- a/kloppy/tests/test_sportec.py
+++ b/kloppy/tests/test_sportec.py
@@ -150,6 +150,7 @@ def test_correct_event_data_deserialization_new(
# Kloppy types added in PR #XXXX
assert EventType.CLEARANCE in event_types_set
+ assert EventType.INTERCEPTION in event_types_set
class TestSportecTrackingData:
From c5917dac89670fe50b83682fd358bb394d575a64 Mon Sep 17 00:00:00 2001
From: Mark Thompson <41844090+mrkthmpsn@users.noreply.github.com>
Date: Tue, 6 May 2025 19:53:24 +0100
Subject: [PATCH 04/14] Formatting
---
kloppy/tests/test_sportec.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py
index c9d53681d..ff55ac99a 100644
--- a/kloppy/tests/test_sportec.py
+++ b/kloppy/tests/test_sportec.py
@@ -135,7 +135,7 @@ def test_correct_event_data_deserialization_new(
# raw_event must be flattened dict
assert isinstance(dataset.events[0].raw_event, dict)
-
+
# Test the kloppy event types that are being parsed
event_types_set = set(event.event_type for event in dataset.events)
From 3763e61f84483fa2481a2bb64e0ca478e5549d17 Mon Sep 17 00:00:00 2001
From: Mark Thompson <41844090+mrkthmpsn@users.noreply.github.com>
Date: Mon, 9 Jun 2025 21:51:25 +0100
Subject: [PATCH 05/14] Add sorting of events to account for file misordering
I'm not sure whether this is just the test file or is something systematic with Sportec data, that certain event types are grouped at the end of the file despite their timestamps placing them throughout the match
---
.../infra/serializers/event/sportec/deserializer.py | 11 +++++++++++
kloppy/tests/test_sportec.py | 5 +++++
2 files changed, 16 insertions(+)
diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py
index e0b073572..e144dd993 100644
--- a/kloppy/infra/serializers/event/sportec/deserializer.py
+++ b/kloppy/infra/serializers/event/sportec/deserializer.py
@@ -487,8 +487,19 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset:
period_id = 0
events = []
+ # We need to re-order the event_elm objects by EventTime, because
+ # certain types of events are all positioned at the end of the files
+ event_chains = []
for event_elm in event_root.iterchildren("Event"):
event_chain = _event_chain_from_xml_elm(event_elm)
+ event_chains.append(event_chain)
+
+ sorted_event_chains = sorted(
+ event_chains,
+ key=lambda x: _parse_datetime(x["Event"]["EventTime"]),
+ )
+
+ for event_chain in sorted_event_chains:
timestamp = _parse_datetime(event_chain["Event"]["EventTime"])
if (
diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py
index ff55ac99a..c936e02cb 100644
--- a/kloppy/tests/test_sportec.py
+++ b/kloppy/tests/test_sportec.py
@@ -152,6 +152,11 @@ def test_correct_event_data_deserialization_new(
assert EventType.CLEARANCE in event_types_set
assert EventType.INTERCEPTION in event_types_set
+ interceptions = dataset.find_all("interception")
+ # All interceptions in the sportec_events_J03WPY.xml are at the end of the file,
+ # but should be distributed throughout the match properly by the deserializer
+ assert interceptions[0].period.id == 1
+
class TestSportecTrackingData:
"""
From b2dfbb402e887529b29a73bccb93988f94bab652 Mon Sep 17 00:00:00 2001
From: Mark Thompson <41844090+mrkthmpsn@users.noreply.github.com>
Date: Wed, 11 Jun 2025 22:24:20 +0100
Subject: [PATCH 06/14] Parse most combinations of Sportec TacklingGame events
Based on the data attributes and visualising some examples of events, I think the interpretations in the code comments are correct. I suspect that 'layoff' is a kind of 'loose ball'/'no outcome' variant, but I'm not confident on that interpretation or what it would be classed as in kloppy
---
.../serializers/event/sportec/deserializer.py | 102 ++++++++++++++++++
1 file changed, 102 insertions(+)
diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py
index e144dd993..aad5c89a3 100644
--- a/kloppy/infra/serializers/event/sportec/deserializer.py
+++ b/kloppy/infra/serializers/event/sportec/deserializer.py
@@ -30,6 +30,7 @@
Official,
OfficialType,
)
+from kloppy.domain.models.event import DuelResult, DuelType, TakeOnResult
from kloppy.exceptions import DeserializationError
from kloppy.infra.serializers.event.deserializer import EventDataDeserializer
from kloppy.utils import performance_logging
@@ -304,6 +305,7 @@ def _event_chain_from_xml_elm(event_elm):
SPORTEC_EVENT_NAME_SUBSTITUTION = "Substitution"
SPORTEC_EVENT_NAME_CAUTION = "Caution"
SPORTEC_EVENT_NAME_FOUL = "Foul"
+SPORTEC_EVENT_NAME_TACKLING_GAME = "TacklingGame"
SPORTEC_EVENT_NAME_OTHER = "OtherBallAction"
SPORTEC_EVENT_TYPE_OF_SHOT = "TypeOfShot"
@@ -441,6 +443,22 @@ def _parse_foul(event_attributes: Dict, teams: List[Team]) -> Dict:
return dict(team=team, player=player)
+def _parse_successful_tackling_game(event_attributes: Dict) -> Dict:
+ """Parsing the appropriate player and team of successful TacklingGame events"""
+ return dict(
+ team=event_attributes["WinnerTeam"],
+ player=event_attributes["Winner"],
+ )
+
+
+def _parse_unsuccessful_tackling_game(event_attributes: Dict) -> Dict:
+ """Parsing the appropriate player and team of unsuccessful TacklingGame events"""
+ return dict(
+ team=event_attributes["LoserTeam"],
+ player=event_attributes["Loser"],
+ )
+
+
def _parse_coordinates(event_attributes: Dict) -> Point:
if "X-Position" not in event_attributes:
return None
@@ -624,6 +642,90 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset:
qualifiers=None,
**generic_event_kwargs,
)
+ elif event_name == SPORTEC_EVENT_NAME_TACKLING_GAME:
+ # Different combinations of TacklingGame winnerRole and winnerResult identified in file J03WPY
+ # [X] WinnerRole='withBallControl', WinnerResult='dribbledAround' -- this seems like a Take On
+ # [X] WinnerRole='withBallControl', WinnerResult='fouled' -- this is obviously a foul
+ # [X] WinnerRole='withBallControl', WinnerResult='ballcontactSucceeded' -- this seems like an 'unsuccessful tackle', in Opta speak
+ # [X] WinnerRole='withBallControl', WinnerResult='ballControlRetained' -- this seems like a failed tackle
+ # WinnerRole='withBallControl', WinnerResult='layoff' -- *might* be loose ball duels
+ # [X] WinnerRole='withoutBallControl', WinnerResult='ballcontactSucceeded' -- this seems like a 'successful tackle', in Opta speak
+ # WinnerRole='withoutBallControl', WinnerResult='layoff' -- *might* be loose ball duels
+ # [X] WinnerRole='withoutBallControl', WinnerResult='fouled' -- this is obviously a foul
+ # [X] WinnerRole='withoutBallControl', WinnerResult='ballClaimed' -- this seems like a tackle
+ duel_type = event_attributes.get("Type", "ground")
+ kloppy_duel_type = (
+ DuelType.AERIAL
+ if duel_type == "air"
+ else DuelType.GROUND
+ )
+
+ if (
+ event_attributes.get("WinnerRole") == "withBallControl"
+ and event_attributes.get("WinnerResult")
+ == "dribbledAround"
+ ):
+ tackling_game_kwargs = _parse_successful_tackling_game(
+ event_attributes
+ )
+ generic_event_kwargs.update(tackling_game_kwargs)
+ event = self.event_factory.build_take_on(
+ result=TakeOnResult.COMPLETE,
+ qualifiers=None,
+ **generic_event_kwargs,
+ )
+ elif event_attributes.get(
+ "WinnerRole"
+ ) == "withoutBallControl" and event_attributes.get(
+ "WinnerResult"
+ ) in [
+ "ballClaimed",
+ "ballContactSucceeded",
+ ]:
+ tackling_game_kwargs = _parse_successful_tackling_game(
+ event_attributes
+ )
+ generic_event_kwargs.update(tackling_game_kwargs)
+ event = self.event_factory.build_duel(
+ qualifiers=[kloppy_duel_type, DuelType.TACKLE],
+ result=DuelResult.WON,
+ **generic_event_kwargs,
+ )
+ elif event_attributes.get(
+ "WinnerRole"
+ ) == "withBallControl" and event_attributes.get(
+ "WinnerResult"
+ ) in [
+ "ballcontactSucceeded",
+ "ballControlRetained",
+ ]:
+ tackling_game_kwargs = (
+ _parse_unsuccessful_tackling_game(event_attributes)
+ )
+ generic_event_kwargs.update(tackling_game_kwargs)
+ event = self.event_factory.build_duel(
+ qualifiers=[kloppy_duel_type],
+ result=DuelResult.LOST,
+ **generic_event_kwargs,
+ )
+ elif event_attributes.get("WinnerResult") == "fouled":
+ tackle_game_foul_kwargs = (
+ _parse_unsuccessful_tackling_game(event_attributes)
+ )
+ generic_event_kwargs.update(tackle_game_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=["silly layoff events"],
+ event_name=event_name,
+ **generic_event_kwargs,
+ )
+
elif (
event_name == SPORTEC_EVENT_NAME_OTHER
and event_attributes.get("DefensiveClearance") == "true"
From f44929da64ec611cf18f944a6aac66ba282d9e6a Mon Sep 17 00:00:00 2001
From: Mark Thompson <41844090+mrkthmpsn@users.noreply.github.com>
Date: Wed, 11 Jun 2025 23:04:16 +0100
Subject: [PATCH 07/14] Add event types derived from TacklingGame to test
---
kloppy/tests/test_sportec.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py
index 67c147ffd..b6a691b6f 100644
--- a/kloppy/tests/test_sportec.py
+++ b/kloppy/tests/test_sportec.py
@@ -151,6 +151,8 @@ def test_correct_event_data_deserialization_new(
# Kloppy types added in PR #XXXX
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
interceptions = dataset.find_all("interception")
# All interceptions in the sportec_events_J03WPY.xml are at the end of the file,
From 86cc09ef3ddc532e91337260ba6d79c79a3816a0 Mon Sep 17 00:00:00 2001
From: Mark Thompson <41844090+mrkthmpsn@users.noreply.github.com>
Date: Wed, 11 Jun 2025 23:40:04 +0100
Subject: [PATCH 08/14] Clean up catch-all 'TacklingGame' events flow
---
kloppy/infra/serializers/event/sportec/deserializer.py | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py
index 56f675f76..feba4e79e 100644
--- a/kloppy/infra/serializers/event/sportec/deserializer.py
+++ b/kloppy/infra/serializers/event/sportec/deserializer.py
@@ -724,10 +724,11 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset:
qualifiers=None,
**generic_event_kwargs,
)
+ # Some 'TacklingGame' events are still not parsed
else:
event = self.event_factory.build_generic(
result=None,
- qualifiers=["silly layoff events"],
+ qualifiers=None,
event_name=event_name,
**generic_event_kwargs,
)
From 5db81fad20107e49dc4d25d1a5b6c3f5aef8cf25 Mon Sep 17 00:00:00 2001
From: Pieter Robberechts
Date: Tue, 23 Dec 2025 21:14:37 +0100
Subject: [PATCH 09/14] test/fix/feat(sportec): metadata
- fix starting position of sub players
- fix dataset flags
- add formation
- add metadata tests
---
.../serializers/event/sportec/deserializer.py | 62 +++--
kloppy/tests/test_sportec.py | 224 ++++++++++++++++--
2 files changed, 246 insertions(+), 40 deletions(-)
diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py
index 94ea757a2..5f0cfb9a3 100644
--- a/kloppy/infra/serializers/event/sportec/deserializer.py
+++ b/kloppy/infra/serializers/event/sportec/deserializer.py
@@ -13,6 +13,7 @@
DatasetFlag,
EventDataset,
EventType,
+ FormationType,
Ground,
Metadata,
Official,
@@ -68,13 +69,34 @@
logger = logging.getLogger(__name__)
-def _team_from_xml_elm(team_elm) -> Team:
+def _parse_name(elem) -> str:
+ """Parse a full name from a Sportec XML element."""
+ if elem.attrib.get("Shortname"):
+ return elem.attrib.get("Shortname")
+ elif elem.attrib.get("FirstName") and elem.attrib.get("LastName"):
+ return f"{elem.attrib.get('FirstName')} {elem.attrib.get('LastName')}"
+ else:
+ raise DeserializationError("Could not parse name")
+
+
+def _extract_team_and_players(team_elm) -> Team:
+ """Extract a Team and its Players from a Sportec XML team element."""
+ head_coach = [
+ _parse_name(trainer)
+ for trainer in team_elm.TrainerStaff.iterchildren("Trainer")
+ if trainer.attrib["Role"] == "headcoach"
+ ]
+ formation_string = 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[0] if len(head_coach) else None,
+ starting_formation=FormationType(formation_string)
+ if formation_string
+ else FormationType.UNKNOWN,
)
team.players = [
Player(
@@ -86,7 +108,9 @@ def _team_from_xml_elm(team_elm) -> Team:
last_name=player_elm.attrib["LastName"],
starting_position=position_types_mapping.get(
player_elm.attrib.get("PlayingPosition"), PositionType.Unknown
- ),
+ )
+ if player_elm.attrib["Starting"] == "true"
+ else None,
starting=player_elm.attrib["Starting"] == "true",
)
for player_elm in team_elm.Players.iterchildren("Player")
@@ -122,45 +146,38 @@ def sportec_metadata_from_xml_elm(match_root) -> SportecMetadata:
x_max = float(match_root.MatchInformation.Environment.attrib["PitchX"])
y_max = float(match_root.MatchInformation.Environment.attrib["PitchY"])
+ # Parse teams
team_path = objectify.ObjectPath("PutDataRequest.MatchInformation.Teams")
team_elms = list(team_path.find(match_root).iterchildren("Team"))
- home_team = away_team = None
+ home_team, away_team = None, 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
+ home_team = _extract_team_and_players(team_elm)
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
+ away_team = _extract_team_and_players(team_elm)
else:
raise DeserializationError(
f"Unknown side: {team_elm.attrib['Role']}"
)
- if not home_team:
+ if home_team is None:
raise DeserializationError("Home team is missing from metadata")
- if not away_team:
+ if away_team is None:
raise DeserializationError("Away team is missing from metadata")
+ if len(home_team.players) == 0 or len(away_team.players) == 0:
+ raise DeserializationError("Line-up incomplete")
+ teams = [home_team, away_team]
+
+ # Parse scoreline
(
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")
-
+ # Parse periods
# 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 = (
@@ -226,6 +243,7 @@ def sportec_metadata_from_xml_elm(match_root) -> SportecMetadata:
]
)
+ # Parse referees
if hasattr(match_root, "MatchInformation") and hasattr(
match_root.MatchInformation, "Referees"
):
@@ -824,7 +842,7 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset:
score=sportec_metadata.score,
frame_rate=None,
orientation=orientation,
- flags=~(DatasetFlag.BALL_STATE | DatasetFlag.BALL_OWNING_TEAM),
+ flags=DatasetFlag(0),
provider=Provider.SPORTEC,
coordinate_system=transformer.get_to_coordinate_system(),
date=date,
diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py
index 2d115273a..fe4d1edec 100644
--- a/kloppy/tests/test_sportec.py
+++ b/kloppy/tests/test_sportec.py
@@ -8,25 +8,210 @@
BallState,
BodyPart,
BodyPartQualifier,
+ DatasetFlag,
DatasetType,
+ Dimension,
EventDataset,
+ FormationType,
+ MetricPitchDimensions,
Official,
OfficialType,
Orientation,
+ Origin,
Point,
Point3D,
PositionType,
Provider,
+ Score,
SetPieceQualifier,
SetPieceType,
ShotResult,
+ SportecEventDataCoordinateSystem,
+ Time,
TrackingDataset,
+ VerticalOrientation,
)
from kloppy.domain.models.event import EventType
-class TestSportecEventData:
- """"""
+@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_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(0)
+
+
+class TestSportecLegacyEventData:
+ """Tests on some old private Sportec event data."""
@pytest.fixture
def event_data(self, base_dir) -> str:
@@ -36,14 +221,6 @@ def event_data(self, base_dir) -> str:
def meta_data(self, base_dir) -> str:
return base_dir / "files/sportec_meta.xml"
- @pytest.fixture
- def event_data_new(self, base_dir) -> str:
- return base_dir / "files/sportec_events_J03WPY.xml"
-
- @pytest.fixture
- def meta_data_new(self, base_dir) -> str:
- return base_dir / "files/sportec_meta_J03WPY.xml"
-
@pytest.fixture
def dataset(self, event_data: Path, meta_data: Path):
return sportec.load_event(
@@ -121,17 +298,28 @@ def test_pass_receiver_coordinates(self, dataset: EventDataset):
assert first_pass.receiver_coordinates != first_pass.next().coordinates
assert first_pass.receiver_coordinates == Point(x=77.75, y=38.71)
- def test_correct_event_data_deserialization_new(
- self, event_data_new: Path, meta_data_new: Path
- ):
- """A basic version of the event data deserialization test, for a newer event data file."""
- dataset = sportec.load_event(
- event_data=event_data_new,
- meta_data=meta_data_new,
- coordinates="sportec",
+class TestSportecPublicEventData:
+ """"""
+
+ @pytest.fixture
+ def event_data(self, base_dir) -> str:
+ return base_dir / "files/sportec_events_J03WPY.xml"
+
+ @pytest.fixture
+ def meta_data(self, base_dir) -> str:
+ return base_dir / "files/sportec_meta_J03WPY.xml"
+
+ @pytest.fixture
+ def dataset(self, event_data: Path, meta_data: Path):
+ return sportec.load_event(
+ event_data=event_data, meta_data=meta_data, coordinates="sportec"
)
+ def test_correct_event_data_deserialization_new(
+ self, dataset: EventDataset
+ ):
+ """A basic version of the event data deserialization test, for a newer event data file."""
assert dataset.metadata.provider == Provider.SPORTEC
assert dataset.dataset_type == DatasetType.EVENT
assert len(dataset.metadata.periods) == 2
From a6036958253d87a58dab66f3051a7096fd2adaa8 Mon Sep 17 00:00:00 2001
From: Pieter Robberechts
Date: Tue, 23 Dec 2025 21:25:52 +0100
Subject: [PATCH 10/14] test(sportec): test Caution event
---
kloppy/tests/test_sportec.py | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py
index fe4d1edec..beecbe20b 100644
--- a/kloppy/tests/test_sportec.py
+++ b/kloppy/tests/test_sportec.py
@@ -8,6 +8,8 @@
BallState,
BodyPart,
BodyPartQualifier,
+ CardQualifier,
+ CardType,
DatasetFlag,
DatasetType,
Dimension,
@@ -210,6 +212,26 @@ def test_flags(self, dataset):
assert dataset.metadata.flags == DatasetFlag(0)
+class TestsSportecCautionEvent:
+ """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 TestSportecLegacyEventData:
"""Tests on some old private Sportec event data."""
From d21f3b22faa4775eaef60f1eb931d6bdf4f5104c Mon Sep 17 00:00:00 2001
From: Pieter Robberechts
Date: Tue, 23 Dec 2025 22:35:36 +0100
Subject: [PATCH 11/14] test(sportec): more tests
---
kloppy/tests/test_sportec.py | 216 ++++++++++++++++++++++++++---------
1 file changed, 164 insertions(+), 52 deletions(-)
diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py
index beecbe20b..029e22a31 100644
--- a/kloppy/tests/test_sportec.py
+++ b/kloppy/tests/test_sportec.py
@@ -20,6 +20,9 @@
OfficialType,
Orientation,
Origin,
+ PassQualifier,
+ PassResult,
+ PassType,
Point,
Point3D,
PositionType,
@@ -212,6 +215,149 @@ def test_flags(self, dataset):
assert dataset.metadata.flags == DatasetFlag(0)
+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):
+ """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_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.PASS in event_types_set
+ assert EventType.SHOT 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.GENERIC 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
+
+
+class TestSportecPassEvent:
+ """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
+
+ @pytest.mark.xfail(reason="Not yet implemented")
+ def test_pass_qualifiers(self, dataset: EventDataset):
+ """It should add pass qualifiers"""
+ pass_event = dataset.get_event_by_id("18237400000007")
+ assert pass_event.get_qualifier_value(PassQualifier) == [
+ 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 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) == 27
+
+ 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 (TODO)
+ # 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 TestsSportecCautionEvent:
"""Tests related to deserializing Caution events"""
@@ -232,6 +378,24 @@ def test_attributes(self, dataset: EventDataset):
assert card.get_qualifier_value(CardQualifier) is None
+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
+ assert interception.get_qualifier_value(BodyPartQualifier) is None
+
+
class TestSportecLegacyEventData:
"""Tests on some old private Sportec event data."""
@@ -321,58 +485,6 @@ def test_pass_receiver_coordinates(self, dataset: EventDataset):
assert first_pass.receiver_coordinates == Point(x=77.75, y=38.71)
-class TestSportecPublicEventData:
- """"""
-
- @pytest.fixture
- def event_data(self, base_dir) -> str:
- return base_dir / "files/sportec_events_J03WPY.xml"
-
- @pytest.fixture
- def meta_data(self, base_dir) -> str:
- return base_dir / "files/sportec_meta_J03WPY.xml"
-
- @pytest.fixture
- def dataset(self, event_data: Path, meta_data: Path):
- return sportec.load_event(
- event_data=event_data, meta_data=meta_data, coordinates="sportec"
- )
-
- def test_correct_event_data_deserialization_new(
- self, dataset: EventDataset
- ):
- """A basic version of the event data deserialization test, for a newer event data file."""
- assert dataset.metadata.provider == Provider.SPORTEC
- assert dataset.dataset_type == DatasetType.EVENT
- assert len(dataset.metadata.periods) == 2
-
- # raw_event must be flattened dict
- assert isinstance(dataset.events[0].raw_event, dict)
-
- # Test the kloppy event types that are being parsed
- event_types_set = set(event.event_type for event in dataset.events)
-
- # Kloppy types that were already being deserialized
- 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.GENERIC in event_types_set
-
- # Kloppy types added in PR #XXXX
- 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
-
- interceptions = dataset.find_all("interception")
- # All interceptions in the sportec_events_J03WPY.xml are at the end of the file,
- # but should be distributed throughout the match properly by the deserializer
- assert interceptions[0].period.id == 1
-
-
class TestSportecTrackingData:
"""
Tests for loading Sportec tracking data.
From ba114f315897419f082c2a542e4c7e3ef84d6320 Mon Sep 17 00:00:00 2001
From: Pieter Robberechts
Date: Tue, 23 Dec 2025 22:44:17 +0100
Subject: [PATCH 12/14] fix: parse team, not team_id
---
.../serializers/event/sportec/deserializer.py | 34 ++++++++++++++-----
1 file changed, 25 insertions(+), 9 deletions(-)
diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py
index 5f0cfb9a3..eb897b119 100644
--- a/kloppy/infra/serializers/event/sportec/deserializer.py
+++ b/kloppy/infra/serializers/event/sportec/deserializer.py
@@ -452,26 +452,40 @@ def _parse_caution(event_attributes: dict) -> dict:
def _parse_foul(event_attributes: dict, teams: list[Team]) -> dict:
team = (
teams[0]
- if event_attributes["TeamFouler"] == teams[0].team_id
+ if event_attributes["teamfouler"] == teams[0].team_id
else teams[1]
)
- player = team.get_player_by_id(event_attributes["Fouler"])
+ player = team.get_player_by_id(event_attributes["fouler"])
return dict(team=team, player=player)
-def _parse_successful_tackling_game(event_attributes: dict) -> dict:
+def _parse_successful_tackling_game(
+ event_attributes: dict, teams: list[Team]
+) -> dict:
"""Parsing the appropriate player and team of successful TacklingGame events"""
+ team = (
+ teams[0]
+ if event_attributes["WinnerTeam"] == teams[0].team_id
+ else teams[1]
+ )
return dict(
- team=event_attributes["WinnerTeam"],
+ team=team,
player=event_attributes["Winner"],
)
-def _parse_unsuccessful_tackling_game(event_attributes: dict) -> dict:
+def _parse_unsuccessful_tackling_game(
+ event_attributes: dict, teams: list[Team]
+) -> dict:
"""Parsing the appropriate player and team of unsuccessful TacklingGame events"""
+ team = (
+ teams[0]
+ if event_attributes["LoserTeam"] == teams[0].team_id
+ else teams[1]
+ )
return dict(
- team=event_attributes["LoserTeam"],
+ team=team,
player=event_attributes["Loser"],
)
@@ -681,7 +695,7 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset:
== "dribbledAround"
):
tackling_game_kwargs = _parse_successful_tackling_game(
- event_attributes
+ event_attributes, teams
)
generic_event_kwargs.update(tackling_game_kwargs)
event = self.event_factory.build_take_on(
@@ -698,7 +712,7 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset:
"ballContactSucceeded",
]:
tackling_game_kwargs = _parse_successful_tackling_game(
- event_attributes
+ event_attributes, teams
)
generic_event_kwargs.update(tackling_game_kwargs)
event = self.event_factory.build_duel(
@@ -715,7 +729,9 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset:
"ballControlRetained",
]:
tackling_game_kwargs = (
- _parse_unsuccessful_tackling_game(event_attributes)
+ _parse_unsuccessful_tackling_game(
+ event_attributes, teams
+ )
)
generic_event_kwargs.update(tackling_game_kwargs)
event = self.event_factory.build_duel(
From b468f0c2e0079dc3a9719631365f20404224186b Mon Sep 17 00:00:00 2001
From: Pieter Robberechts
Date: Tue, 23 Dec 2025 22:49:22 +0100
Subject: [PATCH 13/14] oops
---
kloppy/infra/serializers/event/sportec/deserializer.py | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py
index eb897b119..82622b455 100644
--- a/kloppy/infra/serializers/event/sportec/deserializer.py
+++ b/kloppy/infra/serializers/event/sportec/deserializer.py
@@ -452,10 +452,10 @@ def _parse_caution(event_attributes: dict) -> dict:
def _parse_foul(event_attributes: dict, teams: list[Team]) -> dict:
team = (
teams[0]
- if event_attributes["teamfouler"] == teams[0].team_id
+ if event_attributes["TeamFouler"] == teams[0].team_id
else teams[1]
)
- player = team.get_player_by_id(event_attributes["fouler"])
+ player = team.get_player_by_id(event_attributes["Fouler"])
return dict(team=team, player=player)
@@ -741,7 +741,9 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset:
)
elif event_attributes.get("WinnerResult") == "fouled":
tackle_game_foul_kwargs = (
- _parse_unsuccessful_tackling_game(event_attributes)
+ _parse_unsuccessful_tackling_game(
+ event_attributes, teams
+ )
)
generic_event_kwargs.update(tackle_game_foul_kwargs)
event = self.event_factory.build_foul_committed(
From 9bd4f5bbb99249e50c9a8f55f4f62e5e59093811 Mon Sep 17 00:00:00 2001
From: Pieter Robberechts
Date: Sun, 28 Dec 2025 23:36:29 +0100
Subject: [PATCH 14/14] refactor(sportec): complete rewrite of event data
deserializer
---
docs/reference/event-data/spec.yaml | 245 ++--
docs/user-guide/loading-data/sportec.ipynb | 229 ++--
.../serializers/event/sportec/__init__.py | 2 +
.../serializers/event/sportec/deserializer.py | 1054 ++++-------------
.../serializers/event/sportec/helpers.py | 34 +
.../serializers/event/sportec/metadata.py | 217 ++++
.../event/sportec/specification.py | 971 +++++++++++++++
.../tracking/sportec/deserializer.py | 17 +-
kloppy/tests/test_sportec.py | 394 +++++-
9 files changed, 1986 insertions(+), 1177 deletions(-)
create mode 100644 kloppy/infra/serializers/event/sportec/helpers.py
create mode 100644 kloppy/infra/serializers/event/sportec/metadata.py
create mode 100644 kloppy/infra/serializers/event/sportec/specification.py
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 82622b455..028b81576 100644
--- a/kloppy/infra/serializers/event/sportec/deserializer.py
+++ b/kloppy/infra/serializers/event/sportec/deserializer.py
@@ -1,504 +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,
- FormationType,
- Ground,
Metadata,
- Official,
- OfficialType,
Orientation,
PassResult,
Period,
- Player,
Point,
- PositionType,
Provider,
- Qualifier,
- Score,
- SetPieceQualifier,
- SetPieceType,
- ShotResult,
- Team,
)
-from kloppy.domain.models.event import DuelResult, DuelType, TakeOnResult
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 _parse_name(elem) -> str:
- """Parse a full name from a Sportec XML element."""
- if elem.attrib.get("Shortname"):
- return elem.attrib.get("Shortname")
- elif elem.attrib.get("FirstName") and elem.attrib.get("LastName"):
- return f"{elem.attrib.get('FirstName')} {elem.attrib.get('LastName')}"
- else:
- raise DeserializationError("Could not parse name")
-
-
-def _extract_team_and_players(team_elm) -> Team:
- """Extract a Team and its Players from a Sportec XML team element."""
- head_coach = [
- _parse_name(trainer)
- for trainer in team_elm.TrainerStaff.iterchildren("Trainer")
- if trainer.attrib["Role"] == "headcoach"
- ]
- formation_string = 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[0] if len(head_coach) else None,
- starting_formation=FormationType(formation_string)
- if formation_string
- else FormationType.UNKNOWN,
- )
- 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
- )
- if player_elm.attrib["Starting"] == "true"
- else None,
- 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"])
-
- # Parse teams
- team_path = objectify.ObjectPath("PutDataRequest.MatchInformation.Teams")
- team_elms = list(team_path.find(match_root).iterchildren("Team"))
-
- home_team, away_team = None, None
- for team_elm in team_elms:
- if team_elm.attrib["Role"] == "home":
- home_team = _extract_team_and_players(team_elm)
- elif team_elm.attrib["Role"] == "guest":
- away_team = _extract_team_and_players(team_elm)
- else:
- raise DeserializationError(
- f"Unknown side: {team_elm.attrib['Role']}"
- )
-
- if home_team is None:
- raise DeserializationError("Home team is missing from metadata")
- if away_team is None:
- raise DeserializationError("Away team is missing from metadata")
- if len(home_team.players) == 0 or len(away_team.players) == 0:
- raise DeserializationError("Line-up incomplete")
-
- teams = [home_team, away_team]
-
- # Parse scoreline
- (
- home_score,
- away_score,
- ) = match_root.MatchInformation.General.attrib["Result"].split(":")
- score = Score(home=int(home_score), away=int(away_score))
-
- # Parse periods
- # 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
- ),
- ),
- ]
- )
-
- # Parse referees
- 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_NAME_TACKLING_GAME = "TacklingGame"
-SPORTEC_EVENT_NAME_OTHER = "OtherBallAction"
-
-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_successful_tackling_game(
- event_attributes: dict, teams: list[Team]
-) -> dict:
- """Parsing the appropriate player and team of successful TacklingGame events"""
- team = (
- teams[0]
- if event_attributes["WinnerTeam"] == teams[0].team_id
- else teams[1]
- )
- return dict(
- team=team,
- player=event_attributes["Winner"],
- )
-
-
-def _parse_unsuccessful_tackling_game(
- event_attributes: dict, teams: list[Team]
-) -> dict:
- """Parsing the appropriate player and team of unsuccessful TacklingGame events"""
- team = (
- teams[0]
- if event_attributes["LoserTeam"] == teams[0].team_id
- else teams[1]
- )
- return dict(
- team=team,
- player=event_attributes["Loser"],
- )
-
-
-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]
@@ -512,364 +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"]
- )
- 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,
+ # 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"])
)
- periods = []
- period_id = 0
- events = []
+ # Parse metadata
+ with performance_logging("parse metadata", logger=logger):
+ meta: SportecMetadata = load_metadata(match_root)
- # We need to re-order the event_elm objects by EventTime, because
- # certain types of events are all positioned at the end of the files
- event_chains = []
- for event_elm in event_root.iterchildren("Event"):
- event_chain = _event_chain_from_xml_elm(event_elm)
- event_chains.append(event_chain)
+ # Initialize coordinate system transformer
+ transformer = self.get_transformer(
+ pitch_length=meta.x_max, pitch_width=meta.y_max
+ )
- sorted_event_chains = sorted(
- event_chains,
- key=lambda x: _parse_datetime(x["Event"]["EventTime"]),
+ # 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
)
- for event_chain in sorted_event_chains:
- timestamp = _parse_datetime(event_chain["Event"]["EventTime"])
+ # 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)
- 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
+ ]
+
+ 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,
+ )
- 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:
- if event_attributes.get("Type") == "BallClaimed":
- event = self.event_factory.build_recovery(
- result=None,
- qualifiers=None,
- **generic_event_kwargs,
- )
- elif event_attributes.get("Type") == "InterceptedBall":
- event = self.event_factory.build_interception(
- result=None,
- qualifiers=None,
- **generic_event_kwargs,
+ if receiver:
+ coords = copy(receiver.coordinates)
+ if (
+ "X-Source-Position" in receiver.raw_event
+ and "Y-Source-Position" in receiver.raw_event
+ ):
+ raw = receiver.raw_event
+ temp = receiver.replace(
+ coordinates=Point(
+ x=float(raw["X-Source-Position"]),
+ y=float(raw["Y-Source-Position"]),
)
- 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,
- )
- elif event_name == SPORTEC_EVENT_NAME_TACKLING_GAME:
- # Different combinations of TacklingGame winnerRole and winnerResult identified in file J03WPY
- # [X] WinnerRole='withBallControl', WinnerResult='dribbledAround' -- this seems like a Take On
- # [X] WinnerRole='withBallControl', WinnerResult='fouled' -- this is obviously a foul
- # [X] WinnerRole='withBallControl', WinnerResult='ballcontactSucceeded' -- this seems like an 'unsuccessful tackle', in Opta speak
- # [X] WinnerRole='withBallControl', WinnerResult='ballControlRetained' -- this seems like a failed tackle
- # WinnerRole='withBallControl', WinnerResult='layoff' -- *might* be loose ball duels
- # [X] WinnerRole='withoutBallControl', WinnerResult='ballcontactSucceeded' -- this seems like a 'successful tackle', in Opta speak
- # WinnerRole='withoutBallControl', WinnerResult='layoff' -- *might* be loose ball duels
- # [X] WinnerRole='withoutBallControl', WinnerResult='fouled' -- this is obviously a foul
- # [X] WinnerRole='withoutBallControl', WinnerResult='ballClaimed' -- this seems like a tackle
- duel_type = event_attributes.get("Type", "ground")
- kloppy_duel_type = (
- DuelType.AERIAL
- if duel_type == "air"
- else DuelType.GROUND
)
+ coords = transformer.transform_event(temp).coordinates
- if (
- event_attributes.get("WinnerRole") == "withBallControl"
- and event_attributes.get("WinnerResult")
- == "dribbledAround"
- ):
- tackling_game_kwargs = _parse_successful_tackling_game(
- event_attributes, teams
- )
- generic_event_kwargs.update(tackling_game_kwargs)
- event = self.event_factory.build_take_on(
- result=TakeOnResult.COMPLETE,
- qualifiers=None,
- **generic_event_kwargs,
- )
- elif event_attributes.get(
- "WinnerRole"
- ) == "withoutBallControl" and event_attributes.get(
- "WinnerResult"
- ) in [
- "ballClaimed",
- "ballContactSucceeded",
- ]:
- tackling_game_kwargs = _parse_successful_tackling_game(
- event_attributes, teams
- )
- generic_event_kwargs.update(tackling_game_kwargs)
- event = self.event_factory.build_duel(
- qualifiers=[kloppy_duel_type, DuelType.TACKLE],
- result=DuelResult.WON,
- **generic_event_kwargs,
- )
- elif event_attributes.get(
- "WinnerRole"
- ) == "withBallControl" and event_attributes.get(
- "WinnerResult"
- ) in [
- "ballcontactSucceeded",
- "ballControlRetained",
- ]:
- tackling_game_kwargs = (
- _parse_unsuccessful_tackling_game(
- event_attributes, teams
- )
- )
- generic_event_kwargs.update(tackling_game_kwargs)
- event = self.event_factory.build_duel(
- qualifiers=[kloppy_duel_type],
- result=DuelResult.LOST,
- **generic_event_kwargs,
- )
- elif event_attributes.get("WinnerResult") == "fouled":
- tackle_game_foul_kwargs = (
- _parse_unsuccessful_tackling_game(
- event_attributes, teams
- )
- )
- generic_event_kwargs.update(tackle_game_foul_kwargs)
- event = self.event_factory.build_foul_committed(
- result=None,
- qualifiers=None,
- **generic_event_kwargs,
- )
- # Some 'TacklingGame' events are still not parsed
- else:
- event = self.event_factory.build_generic(
- result=None,
- qualifiers=None,
- event_name=event_name,
- **generic_event_kwargs,
- )
+ pass_event.receiver_coordinates = coords
- elif (
- event_name == SPORTEC_EVENT_NAME_OTHER
- and event_attributes.get("DefensiveClearance") == "true"
- ):
- event = self.event_factory.build_clearance(
- 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,
- )
- if (
- event.event_type == EventType.PASS
- and event.get_qualifier_value(SetPieceQualifier)
- in (
- SetPieceType.THROW_IN,
- SetPieceType.GOAL_KICK,
- SetPieceType.CORNER_KICK,
- )
- ):
- # 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,
- )
- events.append(transformer.transform_event(out_event))
+def parse_sportec_xml(root: objectify.ObjectifiedElement) -> list[dict]:
+ """Parses Sportec XML content into a structured list of event dictionaries.
- events.append(transformer.transform_event(event))
+ 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.
- 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
+ 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': {...}}).
- events = list(
- filter(
- self.should_include_event,
- events,
- )
- )
+ Args:
+ root (objectify.ObjectifiedElement): The root element of the Sportec XML data.
- 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(0),
- provider=Provider.SPORTEC,
- coordinate_system=transformer.get_to_coordinate_system(),
- date=date,
- game_week=game_week,
- game_id=game_id,
- officials=sportec_metadata.officials,
+ 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": {},
+ }
)
- return EventDataset(
- metadata=metadata,
- records=events,
- )
+ # 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/test_sportec.py b/kloppy/tests/test_sportec.py
index 029e22a31..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
@@ -13,6 +14,9 @@
DatasetFlag,
DatasetType,
Dimension,
+ DuelQualifier,
+ DuelResult,
+ DuelType,
EventDataset,
FormationType,
MetricPitchDimensions,
@@ -32,6 +36,8 @@
SetPieceType,
ShotResult,
SportecEventDataCoordinateSystem,
+ SubstitutionEvent,
+ TakeOnResult,
Time,
TrackingDataset,
VerticalOrientation,
@@ -41,12 +47,12 @@
@pytest.fixture(scope="module")
def event_data(base_dir) -> str:
- return base_dir / "files/sportec_events_J03WPY.xml"
+ 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"
+ return base_dir / "files" / "sportec_meta_J03WPY.xml"
@pytest.fixture(scope="module")
@@ -69,6 +75,14 @@ def test_date(self, dataset):
"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
@@ -212,7 +226,7 @@ def test_officials(self, dataset):
def test_flags(self, dataset):
"""It should set the correct flags"""
- assert dataset.metadata.flags == DatasetFlag(0)
+ assert dataset.metadata.flags == DatasetFlag.BALL_STATE
class TestSportecEventData:
@@ -241,13 +255,33 @@ def test_generic_attributes(self, dataset: EventDataset):
)
assert event.ball_state == BallState.ALIVE
- def test_timestamp(self, dataset):
+ 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
):
@@ -266,20 +300,80 @@ def test_supported_events(self, dataset: EventDataset):
# Test the kloppy event types that are being parsed
event_types_set = set(event.event_type for event in dataset.events)
- assert EventType.PASS in event_types_set
+ 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.GENERIC 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 TestSportecPassEvent:
+
+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):
@@ -303,13 +397,38 @@ def test_open_play_pass(self, dataset: EventDataset):
# A pass can have set piece qualifiers
assert pass_event.get_qualifier_value(SetPieceQualifier) is None
- @pytest.mark.xfail(reason="Not yet implemented")
+ 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 pass_event.get_qualifier_value(PassQualifier) == [
- PassType.LONG_BALL
- ]
+ 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"""
@@ -320,45 +439,25 @@ def test_set_piece(self, dataset: EventDataset):
)
-class TestSportecShotEvent:
- """Tests related to deserializing Shot events"""
+class TestSportecBallClaimingEvent:
+ """Tests related to deserializing BallClaiming events"""
- def test_deserialize_all(self, dataset: EventDataset):
- """It should deserialize all shot events"""
- events = dataset.find_all("shot")
- assert len(events) == 27
+ 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_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 (TODO)
- # assert (
- # next(
- # statistic
- # for statistic in shot.statistics
- # if statistic.name == "xG"
- # ).value
- # == 0.5062
- # )
+ 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
- # 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
- # )
+ interception = dataset.get_event_by_id("18237403501368")
+ assert interception.result is None # TODO: infer result
+ assert interception.get_qualifier_value(BodyPartQualifier) is None
-class TestsSportecCautionEvent:
+class TestSportecCautionEvent:
"""Tests related to deserializing Caution events"""
def test_deserialize_all(self, dataset: EventDataset):
@@ -378,22 +477,177 @@ def test_attributes(self, dataset: EventDataset):
assert card.get_qualifier_value(CardQualifier) is None
-class TestSportecBallClaimingEvent:
- """Tests related to deserializing BallClaiming events"""
+class TestSportecFoulEvent:
+ """Tests related to deserializing Foul 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_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
+ )
- 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
+ foul_without_card = dataset.get_event_by_id("18237400001114")
+ assert foul_without_card.get_qualifier_value(CardQualifier) is None
- interception = dataset.get_event_by_id("18237403501368")
- assert interception.result is None
- assert interception.get_qualifier_value(BodyPartQualifier) 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:
@@ -421,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
@@ -443,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"
@@ -453,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
)
@@ -683,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)