Repository: aeye-lab/pymovements
Pull request #590 (2023-10-05 14:44:07)

feat: Add gaze.from_asc()
## Description

Implement from_eyelink_asc() which returns a GazeDataFrame read from an eyelink asc file.

Fixes issue #508 

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

- [x] Created test asc file and check GazeDataFrame for correct shape

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my own code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream modules
- [x] I have checked my code and corrected any misspellings


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/aeye-lab/pymovements/pull/590?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
All modified lines are covered by tests :white_check_mark:
> Comparison is base [(`ec40022`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/ec400220c0cbe591c8426c42dae1b83e1536a6f0?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00% compared to head [(`c6703e7`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/590?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) 100.00%.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #590   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           52        52           
  Lines         2283      2289    +6     
  Branches       568       568           
=========================================
+ Hits          2283      2289    +6     
```


| [Files](https://app.codecov.io/gh/aeye-lab/pymovements/pull/590?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Coverage Δ | |
|---|---|---|
| [src/pymovements/gaze/\_\_init\_\_.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/590?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvX19pbml0X18ucHk=) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/gaze/io.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/590?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL2dhemUvaW8ucHk=) | `100.00% <100.00%> (ø)` | |
| [src/pymovements/utils/parsing.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/590?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3V0aWxzL3BhcnNpbmcucHk=) | `100.00% <ø> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/590?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).


Patch:
diff --git a/src/pymovements/gaze/__init__.py b/src/pymovements/gaze/__init__.py
index dc7d3cf..a596311 100644
--- a/src/pymovements/gaze/__init__.py
+++ b/src/pymovements/gaze/__init__.py
@@ -62,6 +62,7 @@ from pymovements.gaze.experiment import Experiment
 from pymovements.gaze.gaze_dataframe import GazeDataFrame
 from pymovements.gaze.integration import from_numpy
 from pymovements.gaze.integration import from_pandas
+from pymovements.gaze.io import from_asc
 from pymovements.gaze.io import from_csv
 from pymovements.gaze.io import from_ipc
 from pymovements.gaze.screen import Screen
@@ -75,6 +76,7 @@ __all__ = [
     'Screen',
     'transforms_numpy',
     'transforms',
+    'from_asc',
     'from_csv',
     'from_ipc',
 ]
diff --git a/src/pymovements/gaze/io.py b/src/pymovements/gaze/io.py
index 5b22d5b..3099611 100644
--- a/src/pymovements/gaze/io.py
+++ b/src/pymovements/gaze/io.py
@@ -27,6 +27,7 @@ import polars as pl
 
 from pymovements.gaze import Experiment  # pylint: disable=cyclic-import
 from pymovements.gaze.gaze_dataframe import GazeDataFrame  # pylint: disable=cyclic-import
+from pymovements.utils.parsing import parse_eyelink
 
 
 def from_csv(
@@ -102,7 +103,7 @@ def from_csv(
 
     Examples
     --------
-    First let's assume a CSV file stored `tests/gaze/io/files/monocular_example.csv`
+    First let's assume a CSV file stored `tests/files/monocular_example.csv`
     with the following content:
     shape: (10, 3)
     ┌──────┬────────────┬────────────┐
@@ -148,10 +149,10 @@ def from_csv(
     └──────┴───────────┘
 
     """
-    # read data
+    # Read data.
     gaze_data = pl.read_csv(file, **read_csv_kwargs)
 
-    # create gaze data frame
+    # Create gaze data frame.
     gaze_df = GazeDataFrame(
         gaze_data,
         experiment=experiment,
@@ -166,6 +167,73 @@ def from_csv(
     return gaze_df
 
 
+def from_asc(
+        file: str | Path,
+        *,
+        patterns: str | list | None = 'eyelink',
+        schema: dict | None = None,
+        experiment: Experiment | None = None,
+) -> GazeDataFrame:
+    """Initialize a :py:class:`pymovements.gaze.gaze_dataframe.GazeDataFrame`.
+
+    Parameters
+    ----------
+    file:
+        Path of IPC/feather file.
+    patterns:
+        list of patterns to match for additional columns or a key identifier of eye tracker specific
+        default patterns. Supported values are: eyelink.
+    schema:
+        Dictionary to optionally specify types of columns parsed by patterns.
+    experiment : Experiment
+        The experiment definition.
+
+    Examples
+    --------
+    Let's assume we have an EyeLink asc file stored at `tests/files/eyelink_monocular_example.asc`.
+    We can then load the data into a ``GazeDataFrame``:
+
+    >>> from pymovements.gaze.io import from_asc
+    >>> gaze = from_asc(file='tests/files/eyelink_monocular_example.asc', patterns='eyelink')
+    >>> gaze.frame
+    shape: (16, 3)
+    ┌─────────┬───────┬────────────────┐
+    │ time    ┆ pupil ┆ pixel          │
+    │ ---     ┆ ---   ┆ ---            │
+    │ i64     ┆ f64   ┆ list[f64]      │
+    ╞═════════╪═══════╪════════════════╡
+    │ 2154556 ┆ 778.0 ┆ [138.1, 132.8] │
+    │ 2154557 ┆ 778.0 ┆ [138.2, 132.7] │
+    │ 2154560 ┆ 777.0 ┆ [137.9, 131.6] │
+    │ 2154564 ┆ 778.0 ┆ [138.1, 131.0] │
+    │ …       ┆ …     ┆ …              │
+    │ 2339271 ┆ 617.0 ┆ [639.4, 531.9] │
+    │ 2339272 ┆ 617.0 ┆ [639.0, 531.9] │
+    │ 2339290 ┆ 618.0 ┆ [637.6, 531.4] │
+    │ 2339291 ┆ 618.0 ┆ [637.3, 531.2] │
+    └─────────┴───────┴────────────────┘
+
+    """
+    if isinstance(patterns, str):
+        if patterns == 'eyelink':
+            # We use the default patterns of parse_eyelink then.
+            patterns = None
+        else:
+            raise ValueError(f"unknown pattern key '{patterns}'. Supported keys are: eyelink")
+
+    # Read data.
+    gaze_data = parse_eyelink(file, patterns=patterns, schema=schema)
+
+    # Create gaze data frame.
+    gaze_df = GazeDataFrame(
+        gaze_data,
+        experiment=experiment,
+        time_column='time',
+        pixel_columns=['x_pix', 'y_pix'],
+    )
+    return gaze_df
+
+
 def from_ipc(
         file: str | Path,
         experiment: Experiment | None = None,
@@ -184,13 +252,11 @@ def from_ipc(
 
     Examples
     --------
-    Let's assume we have an IPC file stored at `tests/gaze/io/files/monocular_example.feather`.
+    Let's assume we have an IPC file stored at `tests/files/monocular_example.feather`.
     We can then load the data into a ``GazeDataFrame``:
 
     >>> from pymovements.gaze.io import from_ipc
-    >>> gaze = from_ipc(
-    ...     file='tests/files/monocular_example.feather',
-    ...     )
+    >>> gaze = from_ipc(file='tests/files/monocular_example.feather')
     >>> gaze.frame
     shape: (10, 2)
     ┌──────┬───────────┐
@@ -210,10 +276,10 @@ def from_ipc(
     └──────┴───────────┘
 
     """
-    # read data
+    # Read data.
     gaze_data = pl.read_ipc(file, **read_ipc_kwargs)
 
-    # create gaze data frame
+    # Create gaze data frame.
     gaze_df = GazeDataFrame(
         gaze_data,
         experiment=experiment,
diff --git a/src/pymovements/utils/parsing.py b/src/pymovements/utils/parsing.py
index 439e8d7..abd74b8 100755
--- a/src/pymovements/utils/parsing.py
+++ b/src/pymovements/utils/parsing.py
@@ -108,7 +108,7 @@ def get_additional_columns(compiled_patterns: list[dict[str, Any]]) -> set[str]:
 
 
 def parse_eyelink(
-        filepath: Path,
+        filepath: Path | str,
         patterns: list | None = None,
         schema: dict | None = None,
 ) -> pl.DataFrame:
@@ -179,17 +179,19 @@ def parse_eyelink(
             for additional_column in additional_columns:
                 samples[additional_column].append(current_additional[additional_column])
 
+    schema_overrides = {
+        'time': pl.Int64,
+        'x_pix': pl.Float64,
+        'y_pix': pl.Float64,
+        'pupil': pl.Float64,
+    }
+    if schema is not None:
+        for column, dtype in schema.items():
+            schema_overrides[column] = dtype
+
     df = pl.from_dict(
         data=samples,
-        schema_overrides={
-            'time': pl.Int64,
-            'x_pix': pl.Float64,
-            'y_pix': pl.Float64,
-            'pupil': pl.Float64,
-        },
+        schema_overrides=schema_overrides,
     )
 
-    if schema is not None:
-        df = df.with_columns([pl.col(column).cast(dtype) for column, dtype in schema.items()])
-
     return df


Test patch:
diff --git a/tests/files/eyelink_monocular_example.asc b/tests/files/eyelink_monocular_example.asc
new file mode 100644
index 0000000..a1ccac1
--- /dev/null
+++ b/tests/files/eyelink_monocular_example.asc
@@ -0,0 +1,160 @@
+** CONVERTED FROM D:\pymovements\results\sub_1\sub_1.edf using edfapi 4.2.1 Win32  EyeLink Dataviewer Sub ComponentApr 12 2021 on Fri Mar 10 18:07:57 2023
+** DATE: Wed Mar  8 09:25:20 2023
+** TYPE: EDF_FILE BINARY EVENT SAMPLE TAGGED
+** VERSION: EYELINK II 1
+** SOURCE: EYELINK CL
+** EYELINK II CL v6.12 Feb  1 2018 (EyeLink Portable Duo)
+** CAMERA: EyeLink USBCAM Version 1.01
+** SERIAL NUMBER: CLU-DAB50
+** CAMERA_CONFIG: DAB50200.SCD
+** RECORDED BY pymovements
+** SREB2.2.299 WIN32 LID:20A87A96 Mod:2023.03.08 11:03 MEZ
+**
+
+MSG	2091650 !CMD 1 select_parser_configuration 0
+MSG	2091659 !CMD 0 fixation_update_interval = 50
+MSG	2091659 !CMD 0 fixation_update_accumulate = 50
+MSG	2091681 !CMD 1 auto_calibration_messages = YES
+MSG	2095865 DISPLAY_COORDS 0 0 1279 1023
+MSG	2095865 RETRACE_INTERVAL  16.646125144
+MSG	2095865 ENVIRONMENT   OpenGL on Windows (6, 2, 9200, 2, '')
+MSG	2095980 TRACKER_TIME 0 2095980.470
+MSG	2096367 -4 SYNCTIME 766 0
+MSG	2100624 SYNCTIME_READING
+INPUT	2117909	0
+INPUT	2126823	0
+MSG	2135819 !CAL
+>>>>>>> CALIBRATION (HV9,P-CR) FOR LEFT: <<<<<<<<<
+MSG	2135819 !CAL Calibration points:
+MSG	2135819 !CAL -32.6, -47.7        -0,    227
+MSG	2135819 !CAL -32.2, -63.6        -0,  -2267
+MSG	2135819 !CAL -32.6, -31.7        -0,   2624
+MSG	2135819 !CAL -58.0, -47.3     -3291,    227
+MSG	2135819 !CAL -7.7, -46.8      3291,    227
+MSG	2135820 !CAL -58.8, -65.2     -3358,  -2267
+MSG	2135820 !CAL -7.9, -61.4      3358,  -2267
+MSG	2135820 !CAL -55.5, -31.2     -3227,   2624
+MSG	2135820 !CAL -9.0, -31.3      3227,   2624
+MSG	2135820 !CAL  0.0,  0.0         0,      0
+MSG	2135820 !CAL eye check box: (L,R,T,B)
+	  -65     6   -72     7
+MSG	2135820 !CAL href cal range: (L,R,T,B)
+	-5037  5037 -3489  3847
+MSG	2135820 !CAL Cal coeff:(X=a+bx+cy+dxx+eyy,Y=f+gx+goaly+ixx+jyy)
+  -0.00043008  131.07  1.437  0.051949 -0.1007
+   227.35 -1.5024  153.47 -0.1679 -0.22845
+MSG	2135820 !CAL Prenormalize: offx, offy = -32.583 -47.715
+MSG	2135820 !CAL Quadrant center: centx, centy =
+  -0.00043025  227.35
+MSG	2135820 !CAL Corner correction:
+   9.5364e-06,  3.4194e-05
+  -1.6932e-05,  2.9132e-05
+   3.3933e-05,  3.5e-06
+   1.5902e-05,  8.6479e-06
+MSG	2135820 !CAL Gains: cx:152.074 lx:170.107 rx:152.936
+MSG	2135820 !CAL Gains: cy:128.550 ty:155.848 by:116.611
+MSG	2135820 !CAL Resolution (upd) at screen center: X=1.7, Y=2.0
+MSG	2135820 !CAL Gain Change Proportion: X: 0.112 Y: 0.336
+MSG	2135821 !CAL Gain Ratio (Gy/Gx) = 0.845
+MSG	2135821 !CAL Bad Y/X gain ratio: 0.845
+MSG	2135821 !CAL PCR gain ratio(x,y) = 2.507, 2.179
+MSG	2135821 !CAL CR gain match(x,y) = 1.010, 1.010
+MSG	2135821 !CAL Slip rotation correction OFF
+MSG	2135821 !CAL CALIBRATION HV9 L LEFT    GOOD
+INPUT	2137650	0
+MSG	2148587 !CAL VALIDATION HV9 L LEFT  GOOD ERROR 0.27 avg. 0.83 max  OFFSET 0.11 deg. 3.7,2.4 pix.
+MSG	2148587 VALIDATE L POINT 0  LEFT  at 640,512  OFFSET 0.19 deg.  7.2,1.0 pix.
+MSG	2148587 VALIDATE L POINT 1  LEFT  at 640,159  OFFSET 0.12 deg.  3.9,-2.2 pix.
+MSG	2148587 VALIDATE L POINT 2  LEFT  at 640,864  OFFSET 0.42 deg.  -15.8,0.9 pix.
+MSG	2148587 VALIDATE L POINT 3  LEFT  at 172,512  OFFSET 0.83 deg.  26.3,17.5 pix.
+MSG	2148587 VALIDATE L POINT 4  LEFT  at 1107,512  OFFSET 0.19 deg.  -4.3,5.7 pix.
+MSG	2148587 VALIDATE L POINT 5  LEFT  at 228,201  OFFSET 0.06 deg.  1.3,1.9 pix.
+MSG	2148587 VALIDATE L POINT 6  LEFT  at 1051,201  OFFSET 0.33 deg.  -3.0,-12.5 pix.
+MSG	2148587 VALIDATE L POINT 7  LEFT  at 228,822  OFFSET 0.18 deg.  -6.8,0.2 pix.
+MSG	2148587 VALIDATE L POINT 8  LEFT  at 1051,822  OFFSET 0.18 deg.  3.8,5.5 pix.
+INPUT	2153108	0
+MSG	2154447 DRIFTCORRECT L LEFT  at 133,133  OFFSET 0.38 deg.  12.5,7.9 pix.
+MSG	2154540 TRIALID 0
+MSG	2154555 RECCFG CR 1000 2 1 L
+MSG	2154555 ELCLCFG BTABLER
+MSG	2154555 GAZE_COORDS 0.00 0.00 1279.00 1023.00
+MSG	2154555 THRESHOLDS L 102 242
+MSG	2154555 ELCL_WINDOW_SIZES 176 188 0 0
+MSG	2154555 CAMERA_LENS_FOCAL_LENGTH 27.00
+MSG	2154555 PUPIL_DATA_TYPE RAW_AUTOSLIP
+MSG	2154555 ELCL_PROC CENTROID (3)
+MSG	2154555 ELCL_PCR_PARAM 5 3.0
+START	2154556 	LEFT	SAMPLES	EVENTS
+PRESCALER	1
+VPRESCALER	1
+PUPIL	AREA
+EVENTS	GAZE	LEFT	RATE	1000.00	TRACKING	CR	FILTER	2
+SAMPLES	GAZE	LEFT	RATE	1000.00	TRACKING	CR	FILTER	2	INPUT
+INPUT	2154556	0
+MSG	2154556 !MODE RECORD CR 1000 2 1 L
+2154556	  138.1	  132.8	  778.0	    0.0	...
+MSG	2154598 SYNCTIME_READING_SCREEN_0
+2154557	  138.2	  132.7	  778.0	    0.0	...
+MSG	2154598 SYNCTIME_READING_SCREEN_1
+SFIX L   2154563
+2154560	  137.9	  131.6	  777.0	    0.0	...
+MSG 2154562 READING.STOP
+MSG	2154562 SYNCTIME_JUDO
+MSG 2154562 SYNCTIME.P0
+MSG 2154562 TRIALID 1
+2154564	  138.1	  131.0	  778.0	    0.0	...
+MSG 2154566 P0.STOP
+MSG 2154566 SYNCTIME.P1
+2154596	  139.6	  132.1	  784.0	    0.0	...
+MSG 2154597 P1.STOP
+MSG 2154597 SYNCTIME.P2
+2154598	  139.5	  131.9	  784.0	    0.0	...
+MSG	2154599 -11 !V DRAW_LIST ../../runtime/dataviewer/sub_1/graphics/VC_1.vcl
+MSG 2154599 P2.STOP
+MSG 2154599 SYNCTIME.P3
+2154599	  139.5	  131.8	  784.0	    0.0	...
+MSG 2154694 P3.STOP
+MSG 2154694 TRIALID 2
+2154695	  147.2	  134.4	  798.0	    0.0	...
+MSG 2154562 SYNCTIME.P0
+EFIX L   2154563	2154695	133	  141.0	  132.2	    791
+SSACC L  2154696
+2154696	  147.3	  134.1	  799.0	    0.0	...
+SSACC L  2339227
+MSG 2339225 P0.STOP
+MSG 2339225 TRIALID 3
+MSG 2339225 SYNCTIME.P0
+2339227	  673.2	  523.8	  619.0	    0.0	...
+2339245	  629.0	  531.4	  621.0	    0.0	...
+ESACC L  2339227	2339245	19	  673.2	  523.8	  629.0	  531.4	   1.21	    103
+SFIX L   2339246
+MSG 2339246 P0.STOP
+MSG 2339246 SYNCTIME.P1
+2339246	  629.9	  531.9	  622.0	    0.0	...
+MSG 2339271 P1.STOP
+MSG 2339271 SYNCTIME.P2
+2339271	  639.4	  531.9	  617.0	    0.0	...
+MSG 2154560 TRIALID 4
+MSG 2339272 SYNCTIME.P0
+2339272	  639.0	  531.9	  617.0	    0.0	...
+MSG 2339290 P0.STOP
+MSG 2339290 SYNCTIME.P1
+2339290	  637.6	  531.4	  618.0	    0.0	...
+EFIX L   2339246	2339290	45	  638.5	  531.8	    618
+MSG 2339291 P1.STOP
+MSG 2339291 READING.STOP
+MSG 2339291 TRIAL_RESULT
+2339291	  637.3	  531.2	  618.0	    0.0	...
+END	2339292 	SAMPLES	EVENTS	RES	  37.17	  37.75
+MSG	2339832 !V TRIAL_VAR Session_Name_ sub_1
+MSG	2339833 !V TRIAL_VAR Trial_Index_ 13
+MSG	2339839 !V TRIAL_VAR TRIAL_TYPE -1
+MSG	2339840 !V TRIAL_VAR CONTROL_FLOW [1, 2, 1, 4, 2, 1, 3, 1, 2, 1, 5]
+MSG	2339841 !V TRIAL_VAR CONTROL_FLOW_INDEX -1
+MSG	2339895 !V TRIAL_VAR SESSION_ID -1
+MSG	2339896 !V TRIAL_VAR SESSION_TYPE "start"
+MSG	2339897 !V TRIAL_VAR SUBJECT_ID -1
+MSG	2339898 !V TRIAL_VAR SCREEN_ID 0
+MSG	2339899 !V TRIAL_VAR STIMULUS_COMBINATION_ID start
+MSG	2339900 !V TRIAL_VAR VIDEO_SERVER_MESSAGE none
+MSG	2339985 TRIAL_RESULT 0
diff --git a/tests/functional/gaze_file_processing_test.py b/tests/functional/gaze_file_processing_test.py
index 8ae5221..2379567 100644
--- a/tests/functional/gaze_file_processing_test.py
+++ b/tests/functional/gaze_file_processing_test.py
@@ -32,6 +32,7 @@ import pymovements as pm
         'csv_binocular',
         'ipc_monocular',
         'ipc_binocular',
+        'eyelink_monocular',
         'hbn',
         'sbsat',
         'gaze_on_faces',
@@ -62,6 +63,10 @@ def fixture_gaze_init_kwargs(request):
             'file': 'tests/files/binocular_example.feather',
             'experiment': pm.Experiment(1024, 768, 38, 30, 60, 'center', 1000),
         },
+        'eyelink_monocular': {
+            'file': 'tests/files/eyelink_monocular_example.asc',
+            'experiment': pm.datasets.ToyDatasetEyeLink().experiment,
+        },
         'hbn': {
             'file': 'tests/files/hbn_example.csv',
             'time_column': pm.datasets.HBN().time_column,
@@ -114,6 +119,8 @@ def test_gaze_file_processing(gaze_from_kwargs):
         gaze = pm.gaze.from_csv(**gaze_from_kwargs)
     elif file_extension in {'.feather', '.ipc'}:
         gaze = pm.gaze.from_ipc(**gaze_from_kwargs)
+    elif file_extension == '.asc':
+        gaze = pm.gaze.from_asc(**gaze_from_kwargs)
 
     assert gaze is not None
 
diff --git a/tests/unit/gaze/io/asc_test.py b/tests/unit/gaze/io/asc_test.py
new file mode 100644
index 0000000..18e7e03
--- /dev/null
+++ b/tests/unit/gaze/io/asc_test.py
@@ -0,0 +1,127 @@
+# Copyright (c) 2023 The pymovements Project Authors
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+"""Test read from eyelink asc files."""
+import polars as pl
+import pytest
+from polars.testing import assert_frame_equal
+
+import pymovements as pm
+
+
+@pytest.mark.parametrize(
+    ('kwargs', 'expected_frame'),
+    [
+        pytest.param(
+            {
+                'file': 'tests/files/eyelink_monocular_example.asc',
+                'patterns': 'eyelink',
+            },
+            pl.from_dict(
+                data={
+                    'time': [
+                        2154556, 2154557, 2154560, 2154564, 2154597, 2154598, 2154599, 2154695,
+                        2154696, 2339227, 2339245, 2339246, 2339271, 2339272, 2339290, 2339291,
+                    ],
+                    'pupil': [
+                        778.0, 778.0, 777.0, 778.0, 784.0, 784.0, 784.0, 798.0,
+                        799.0, 619.0, 621.0, 622.0, 617.0, 617.0, 618.0, 618.0,
+                    ],
+                    'pixel': [
+                        [138.1, 132.8], [138.2, 132.7], [137.9, 131.6], [138.1, 131.0],
+                        [139.6, 132.1], [139.5, 131.9], [139.5, 131.8], [147.2, 134.4],
+                        [147.3, 134.1], [673.2, 523.8], [629.0, 531.4], [629.9, 531.9],
+                        [639.4, 531.9], [639.0, 531.9], [637.6, 531.4], [637.3, 531.2],
+                    ],
+                },
+                schema={
+                    'time': pl.Int64,
+                    'pupil': pl.Float64,
+                    'pixel': pl.List(pl.Float64),
+                },
+            ),
+            id='eyelink_asc_mono_pattern_eyelink',
+        ),
+        pytest.param(
+            {
+                'file': 'tests/files/eyelink_monocular_example.asc',
+                'patterns': pm.datasets.ToyDatasetEyeLink().custom_read_kwargs['patterns'],
+                'schema': pm.datasets.ToyDatasetEyeLink().custom_read_kwargs['schema'],
+            },
+            pl.DataFrame(
+                data={
+                    'time': [
+                        2154556, 2154557, 2154560, 2154564, 2154597, 2154598, 2154599, 2154695,
+                        2154696, 2339227, 2339245, 2339246, 2339271, 2339272, 2339290, 2339291,
+                    ],
+                    'pupil': [
+                        778.0, 778.0, 777.0, 778.0, 784.0, 784.0, 784.0, 798.0,
+                        799.0, 619.0, 621.0, 622.0, 617.0, 617.0, 618.0, 618.0,
+                    ],
+                    'pixel': [
+                        [138.1, 132.8], [138.2, 132.7], [137.9, 131.6], [138.1, 131.0],
+                        [139.6, 132.1], [139.5, 131.9], [139.5, 131.8], [147.2, 134.4],
+                        [147.3, 134.1], [673.2, 523.8], [629.0, 531.4], [629.9, 531.9],
+                        [639.4, 531.9], [639.0, 531.9], [637.6, 531.4], [637.3, 531.2],
+                    ],
+                    'trial_id': [0, 0, 0, 1, 1, 1, 1, 2, 2, 3, 3, 3, 3, 4, 4, None],
+                    'point_id': 3 * [None] + [0, 1, 2, 3] + [None, 0] + [0, 0, 1, 2] + [0, 1, None],
+                    'screen_id': [None, 0, 1] + 13 * [None],
+                    'task': [None] + 2 * ['reading'] + 12 * ['judo'] + [None],
+                },
+                schema={
+                    'time': pl.Int64,
+                    'pupil': pl.Float64,
+                    'task': pl.Utf8,
+                    'trial_id': pl.Int64,
+                    'point_id': pl.Int64,
+                    'screen_id': pl.Int64,
+                    'pixel': pl.List(pl.Float64),
+                },
+            ),
+            id='eyelink_asc_mono_pattern_list',
+        ),
+    ],
+)
+def test_from_asc_has_shape_and_schema(kwargs, expected_frame):
+    gaze = pm.gaze.from_asc(**kwargs)
+
+    assert_frame_equal(gaze.frame, expected_frame, check_column_order=False)
+
+
+@pytest.mark.parametrize(
+    ('kwargs', 'exception', 'message'),
+    [
+        pytest.param(
+            {
+                'file': 'tests/files/eyelink_monocular_example.asc',
+                'patterns': 'foobar',
+            },
+            ValueError,
+            "unknown pattern key 'foobar'. Supported keys are: eyelink",
+            id='unknown_pattern',
+        ),
+    ],
+)
+def test_from_asc_raises_exception(kwargs, exception, message):
+    with pytest.raises(exception) as excinfo:
+        pm.gaze.from_asc(**kwargs)
+
+    msg, = excinfo.value.args
+    assert msg == message