Repository: aeye-lab/pymovements
Pull request #725 (2024-04-26 11:49:41)

feat: parse eyelink mount config and pupil data type
## Description

Please include a clear and concise description of the change and which issue is fixed.
Please also include excepted improvements, relevant motivation and context.
List any dependencies that are required for this change.

Fixes issue #510 

## Implemented changes

Insert a description of the changes implemented in the pull request.

- [x] added parsing of mount configuration
- [x] added parsing of pupil data type
- [x] more tests for data loss ratio calculation 

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] This change is or requires a documentation update

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes.
Provide instructions so we can reproduce.
Please also list any relevant details for your test configuration

- [ ] unit tests

## 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/725?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`44673c7`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/44673c734a8a9f2bde57f82b8516f5769d3e3b41?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) to head [(`f36cb1d`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/725?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).


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


```diff
@@            Coverage Diff            @@
##              main      #725   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           60        60           
  Lines         2801      2808    +7     
  Branches       712       714    +2     
=========================================
+ Hits          2801      2808    +7     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/725?dropdown=coverage&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/utils/parsing.py b/src/pymovements/utils/parsing.py
index 966c6ba..96072ca 100755
--- a/src/pymovements/utils/parsing.py
+++ b/src/pymovements/utils/parsing.py
@@ -43,23 +43,31 @@ EYELINK_META_REGEXES = [
     {'pattern': r'\*\*\s+VERSION:\s+(?P<version_1>.*)\s+'},
     {
         'pattern': r'\*\*\s+DATE:\s+(?P<weekday>[A-Z,a-z]+)\s+(?P<month>[A-Z,a-z]+)'
-                   r'\s+(?P<day>\d\d?)\s+(?P<time>\d\d:\d\d:\d\d)\s+(?P<year>\d{4})\s+',
+                   r'\s+(?P<day>\d\d?)\s+(?P<time>\d\d:\d\d:\d\d)\s+(?P<year>\d{4})\s*',
     },
     {'pattern': r'\*\*\s+(?P<version_2>EYELINK.*)'},
     {
-        'pattern': r'SAMPLES\s+GAZE\s+(?P<tracked_eye>LEFT|RIGHT)\s+RATE\s+'
-                   r'(?P<sampling_rate>[-]?\d*[.]\d*)\s+TRACKING\s+(?P<tracking>\S+)'
-                   r'\s+FILTER\s+(?P<filter>\d+)\s+INPUT',
+        'pattern': r'MSG\s+\d+\s+DISPLAY_COORDS\s+(?P<resolution>.*)',
     },
     {
-        'pattern': r'MSG\s+\d+\s+DISPLAY_COORDS\s+(?P<resolution>.*)',
+        'pattern': r'MSG\s+\d+\s+RECCFG\s+(?P<tracking_mode>[A-Z,a-z]+)\s+'
+                   r'(?P<sampling_rate>\d+)\s+'
+                   r'(?P<file_sample_filter>(0|1|2))\s+'
+                   r'(?P<link_sample_filter>(0|1|2))\s+'
+                   r'(?P<tracked_eye>(L|R|LR))\s*',
+    },
+    {
+        'pattern': r'PUPIL\s+(?P<pupil_data_type>(AREA|DIAMETER))\s*',
+    },
+    {
+        'pattern': r'MSG\s+\d+\s+ELCLCFG\s+(?P<mount_configuration>.*)',
     },
 ]
 
 VALIDATION_REGEX = (
     r'MSG\s+(?P<timestamp>\d+)\s+!CAL\s+VALIDATION\s+HV'
     r'(?P<num_points>\d\d?).*'
-    r'(?P<eye_tracked>LEFT|RIGHT)\s+'
+    r'(?P<tracked_eye>LEFT|RIGHT)\s+'
     r'(?P<error>\D*)\s+'
     r'(?P<validation_score_avg>\d.\d\d)\s+avg\.\s+'
     r'(?P<validation_score_max>\d.\d\d)\s+max'
@@ -281,9 +289,9 @@ def parse_eyelink(
             blink = False
             parsed_blink = match.groupdict()
             blink_info = {
-                'start_timestamp': parsed_blink['timestamp_start'],
-                'stop_timestamp': parsed_blink['timestamp_end'],
-                'duration_ms': parsed_blink['duration_ms'],
+                'start_timestamp': int(parsed_blink['timestamp_start']),
+                'stop_timestamp': int(parsed_blink['timestamp_end']),
+                'duration_ms': int(parsed_blink['duration_ms']),
                 'num_samples': num_blink_samples,
             }
             num_blink_samples = 0
@@ -409,7 +417,7 @@ def _pre_process_metadata(metadata: defaultdict[str, Any]) -> dict[str, Any]:
     else:
         metadata['sampling_rate'] = 'unknown'
 
-    # if the data has been parsed fully, convert the date to a datetime object
+    # if the date has been parsed fully, convert the date to a datetime object
     if 'day' in metadata and 'year' in metadata and 'month' in metadata and 'time' in metadata:
         metadata['day'] = int(metadata['day'])
         metadata['year'] = int(metadata['year'])
@@ -418,6 +426,11 @@ def _pre_process_metadata(metadata: defaultdict[str, Any]) -> dict[str, Any]:
         time = datetime.datetime.strptime(metadata['time'], '%H:%M:%S')
         metadata['datetime'] = datetime.datetime.combine(date_time, time.time())
 
+    if 'mount_configuration' in metadata:
+        metadata['mount_configuration'] = _parse_eyelink_mount_config(
+            metadata['mount_configuration'],
+        )
+
     return_metadata: dict[str, Any] = dict(metadata)
 
     return return_metadata
@@ -515,3 +528,118 @@ def _parse_full_eyelink_version(version_str_1: str, version_str_2: str) -> tuple
             version_number = 'unknown'
 
     return version_number, model
+
+
+def _parse_eyelink_mount_config(mount_config: str) -> dict[str, str]:
+    """Return a dictionary with the mount configuration based on the config short name.
+
+    Parameters
+    ----------
+    mount_config: str
+        Short name of the mount configuration.
+
+    Returns
+    -------
+    dict[str, str]
+        Dictionary with the mount configuration spelled out.
+
+    """
+    possible_mounts = {
+        'MTABLER': {
+            'mount_type': 'Desktop',
+            'head_stabilization': 'stabilized',
+            'eyes_recorded': 'monocular',
+            'short_name': 'MTABLER',
+        },
+        'BTABLER': {
+            'mount_type': 'Desktop',
+            'head_stabilization': 'stabilized',
+            'eyes_recorded': 'binocular / monocular',
+            'short_name': 'BTABLER',
+        },
+        'RTABLER': {
+            'mount_type': 'Desktop',
+            'head_stabilization': 'remote',
+            'eyes_recorded': 'monocular',
+            'short_name': 'RTABLER',
+        },
+        'RBTABLER': {
+            'mount_type': 'Desktop',
+            'head_stabilization': 'remote',
+            'eyes_recorded': 'binocular / monocular',
+            'short_name': 'RBTABLER',
+        },
+        'AMTABLER': {
+            'mount_type': 'Arm Mount',
+            'head_stabilization': 'stabilized',
+            'eyes_recorded': 'monocular',
+            'short_name': 'AMTABLER',
+        },
+        'ABTABLER': {
+            'mount_type': 'Arm Mount',
+            'head_stabilization': 'stabilized',
+            'eyes_recorded': 'binocular / monocular',
+            'short_name': 'ABTABLER',
+        },
+        'ARTABLER': {
+            'mount_type': 'Arm Mount',
+            'head_stabilization': 'remote',
+            'eyes_recorded': 'monocular',
+            'short_name': 'ARTABLER',
+        },
+        'ABRTABLE': {
+            'mount_type': 'Arm Mount',
+            'head_stabilization': 'remote',
+            'eyes_recorded': 'binocular / monocular',
+            'short_name': 'ABRTABLE',
+        },
+        'BTOWER': {
+            'mount_type': 'Binocular Tower Mount',
+            'head_stabilization': 'stabilized',
+            'eyes_recorded': 'binocular / monocular',
+            'short_name': 'BTOWER',
+        },
+        'TOWER': {
+            'mount_type': 'Tower Mount',
+            'head_stabilization': 'stabilized',
+            'eyes_recorded': 'monocular',
+            'short_name': 'TOWER',
+        },
+        'MPRIM': {
+            'mount_type': 'Primate Mount',
+            'head_stabilization': 'stabilized',
+            'eyes_recorded': 'monocular',
+            'short_name': 'MPRIM',
+        },
+        'BPRIM': {
+            'mount_type': 'Primate Mount',
+            'head_stabilization': 'stabilized',
+            'eyes_recorded': 'binocular / monocular',
+            'short_name': 'BPRIM',
+        },
+        'MLRR': {
+            'mount_type': 'Long-Range Mount',
+            'head_stabilization': 'stabilized',
+            'eyes_recorded': 'monocular',
+            'camera_position': 'level',
+            'short_name': 'MLRR',
+        },
+        'BLRR': {
+            'mount_type': 'Long-Range Mount',
+            'head_stabilization': 'stabilized',
+            'eyes_recorded': 'binocular / monocular',
+            'camera_position': 'angled',
+            'short_name': 'BLRR',
+        },
+    }
+
+    if mount_config in possible_mounts:
+        return possible_mounts[mount_config]
+
+    return {
+        'mount_type': 'unknown',
+        'head_stabilization': 'unknown',
+        'eyes_recorded': 'unknown',
+        'camera_position': 'unknown',
+        'short_name': mount_config,
+    }


Test patch:
diff --git a/tests/functional/gaze_file_processing_test.py b/tests/functional/gaze_file_processing_test.py
index c8e40a7..caa5431 100644
--- a/tests/functional/gaze_file_processing_test.py
+++ b/tests/functional/gaze_file_processing_test.py
@@ -133,7 +133,7 @@ def test_gaze_file_processing(gaze_from_kwargs):
     # Load in gaze file.
     file_extension = os.path.splitext(gaze_from_kwargs['file'])[1]
     gaze = None
-    if file_extension in {'.txt', '.csv', '.tsv', '.txt'}:
+    if file_extension in {'.txt', '.csv', '.tsv'}:
         gaze = pm.gaze.from_csv(**gaze_from_kwargs)
     elif file_extension in {'.feather', '.ipc'}:
         gaze = pm.gaze.from_ipc(**gaze_from_kwargs)
diff --git a/tests/unit/utils/parsing_test.py b/tests/unit/utils/parsing_test.py
index 534583e..d3bbc65 100644
--- a/tests/unit/utils/parsing_test.py
+++ b/tests/unit/utils/parsing_test.py
@@ -43,8 +43,8 @@ ASC_TEXT = r"""
 
 some
 lines
-to
-ignore
+MSG	2154555 RECCFG CR 1000 2 1 L
+MSG	2154555 ELCLCFG BTABLER
 MSG	2095865 DISPLAY_COORDS 0 0 1279 1023
 PRESCALER	1
 VPRESCALER	1
@@ -150,9 +150,11 @@ EXPECTED_METADATA = {
     'model': 'EyeLink Portable Duo',
     'version_number': '6.12',
     'sampling_rate': 1000.00,
-    'filter': '2',
-    'tracking': 'CR',
-    'tracked_eye': 'LEFT',
+    'file_sample_filter': '2',
+    'link_sample_filter': '1',
+    'pupil_data_type': 'AREA',
+    'tracking_mode': 'CR',
+    'tracked_eye': 'L',
     'calibrations': [],
     'validations': [],
     'resolution': (1280, 1024),
@@ -161,11 +163,17 @@ EXPECTED_METADATA = {
     'total_recording_duration_ms': 11,
     'datetime': datetime.datetime(2023, 3, 8, 9, 25, 20),
     'blinks': [{
-        'duration_ms': '2',
+        'duration_ms': 2,
         'num_samples': 2,
-        'start_timestamp': '10000018',
-        'stop_timestamp': '10000020',
+        'start_timestamp': 10000018,
+        'stop_timestamp': 10000020,
     }],
+    'mount_configuration': {
+        'mount_type': 'Desktop',
+        'head_stabilization': 'stabilized',
+        'eyes_recorded': 'binocular / monocular',
+        'short_name': 'BTABLER',
+    },
 }
 
 
@@ -328,7 +336,7 @@ def test_no_metadata_warning(tmp_path, metadata, expected_msg):
             'OFFSET 0.19 deg. 4.2,6.3 pix.\n',
             [{
                 'error': 'POOR ERROR',
-                'eye_tracked': 'RIGHT',
+                'tracked_eye': 'RIGHT',
                 'num_points': '9',
                 'timestamp': '1076158',
                 'validation_score_avg': '2.40',
@@ -383,7 +391,7 @@ def test_parse_val_cal_eyelink_monocular_file():
 
     expected_validation = [{
         'error': 'GOOD ERROR',
-        'eye_tracked': 'LEFT',
+        'tracked_eye': 'LEFT',
         'num_points': '9',
         'timestamp': '2148587',
         'validation_score_avg': '0.27',
@@ -395,3 +403,396 @@ def test_parse_val_cal_eyelink_monocular_file():
 
     assert metadata['calibrations'] == expected_calibration
     assert metadata['validations'] == expected_validation
+
+
+@pytest.mark.parametrize(
+    ('metadata', 'expected_blinks'),
+    [
+        pytest.param(
+            '** DATE: Wed Mar  8 09:25:20 2023\n'
+            'EVENTS	GAZE	LEFT	RATE	1000.00	TRACKING	CR	FILTER	2\n'
+            'SBLINK R 10000018\n'
+            '10000019	   .	   .	    0.0	    0.0	...\n'
+            '10000020	   .	   .	    0.0	    0.0	...\n'
+            'EBLINK R 10000018	10000020	2\n',
+            [{
+                'duration_ms': 2,
+                'num_samples': 2,
+                'start_timestamp': 10000018,
+                'stop_timestamp': 10000020,
+            }],
+            id='blink',
+        ),
+        pytest.param(
+            '** DATE: Wed Mar  8 09:25:20 2023\n'
+            'EVENTS	GAZE	LEFT	RATE	1000.00	TRACKING	CR	FILTER	2\n'
+            'SBLINK R 10000018\n'
+            '10000019	   .	   .	    0.0	    0.0	...\n'
+            '10000020	   .	   .	    0.0	    0.0	...\n'
+            'EBLINK R 10000018	10000020	2\n'
+            'SBLINK R 10000021\n'
+            '10000021	   .	   .	    0.0	    0.0	...\n'
+            '10000022	   .	   .	    0.0	    0.0	...\n'
+            '10000023	   .	   .	    0.0	    0.0	...\n'
+            '10000024	   .	   .	    0.0	    0.0	...\n'
+            'EBLINK R 10000021	10000024	4\n',
+            [
+                {
+                    'duration_ms': 2,
+                    'num_samples': 2,
+                    'start_timestamp': 10000018,
+                    'stop_timestamp': 10000020,
+                },
+                {
+                    'duration_ms': 4,
+                    'num_samples': 4,
+                    'start_timestamp': 10000021,
+                    'stop_timestamp': 10000024,
+                },
+            ],
+            id='multiple_blinks',
+        ),
+        pytest.param(
+            '** DATE: Wed Mar  8 09:25:20 2023\n'
+            'SBLINK R 10000018\n'
+            '10000019	   .	   .	    0.0	    0.0	...\n'
+            '10000020	   .	   .	    0.0	    0.0	...\n'
+            'EBLINK R 10000018	10000020	2\n',
+            [{
+                'duration_ms': 2,
+                'num_samples': 2,
+                'start_timestamp': 10000018,
+                'stop_timestamp': 10000020,
+            }],
+            id='blinks_no_sampling_rate',
+        ),
+    ],
+)
+def test_parse_eyelink_blinks(tmp_path, metadata, expected_blinks):
+    filepath = tmp_path / 'sub.asc'
+    filepath.write_text(metadata)
+
+    _, parsed_metadata = pm.utils.parsing.parse_eyelink(filepath)
+
+    assert parsed_metadata['blinks'] == expected_blinks
+
+
+@pytest.mark.parametrize(
+    ('metadata', 'expected_blink_ratio', 'expected_overall_ratio'),
+    [
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'START	10000018 	RIGHT	SAMPLES	EVENTS\n'
+            'SBLINK R 10000018\n'
+            '10000019	   .	   .	    0.0	    0.0	...\n'
+            '10000020	   .	   .	    0.0	    0.0	...\n'
+            'EBLINK R 10000018	10000020	2\n'
+            'END	10000020 	SAMPLES	EVENTS	RES	  38.54	  31.12\n',
+            1,
+            1,
+            id='only_blinks',
+        ),
+        pytest.param(
+            '** DATE: Wed Mar  8 09:25:20 2023\n'
+            'START	10000018 	RIGHT	SAMPLES	EVENTS\n'
+            'SBLINK R 10000018\n'
+            '10000019	   .	   .	    0.0	    0.0	...\n'
+            '10000020	   .	   .	    0.0	    0.0	...\n'
+            'EBLINK R 10000018	10000020	2\n'
+            'END	10000020 	SAMPLES	EVENTS	RES	  38.54	  31.12\n',
+            'unknown',
+            'unknown',
+            id='unknown_sampling_rate_only_blinks',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'START	10000018 	RIGHT	SAMPLES	EVENTS\n'
+            '10000019	   .	   .	    0.0	    0.0	...\n'
+            'END	10000019 	SAMPLES	EVENTS	RES	  38.54	  31.12\n',
+            0,
+            1,
+            id='lost_samples_no_blinks',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'START	10000018 	RIGHT	SAMPLES	EVENTS\n'
+            'SBLINK R 10000018\n'
+            '10000019	   .	   .	    0.0	    0.0	...\n'
+            'EBLINK R 10000018	10000019	1\n'
+            '10000020	   .	   .	    0.0	    0.0	...\n'
+            'END	10000020 	SAMPLES	EVENTS	RES	  38.54	  31.12\n',
+            0.5,
+            1.0,
+            id='blinks_and_lost_samples',
+        ),
+        pytest.param(
+            '** DATE: Wed Mar  8 09:25:20 2023\n'
+            'START	10000021 	RIGHT	SAMPLES	EVENTS\n'
+            '10000021	   .	   .	    0.0	    0.0	...\n'
+            '10000022	   .	   .	    0.0	    0.0	...\n'
+            'END	10000022 	SAMPLES	EVENTS	RES	  38.54	  31.12\n',
+            'unknown',
+            'unknown',
+            id='lost_samples_no_sampling_rate',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'START	10000018 	RIGHT	SAMPLES	EVENTS\n'
+            '10000019	   850.7	  717.5	  714.0	    0.0	...\n'
+            '10000020	   850.7	  717.5	  714.0	    0.0	...\n'
+            '10000022	   850.7	  717.5	  714.0	    0.0	...\n'
+            'END	10000022 	SAMPLES	EVENTS	RES	  38.54	  31.12\n',
+            0,
+            0.25,
+            id='missing_timestamps',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'START	10000018 	RIGHT	SAMPLES	EVENTS\n'
+            '10000019	   850.7	  717.5	  714.0	    0.0	...\n'
+            '10000020	   850.7	  717.5	  714.0	    0.0	...\n'
+            '10000022	   .	   .	    0.0	    0.0	...\n'
+            'END	10000022 	SAMPLES	EVENTS	RES	  38.54	  31.12\n',
+            0,
+            0.5,
+            id='missing_timestamps_lost_samples',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'START	10000020 	RIGHT	SAMPLES	EVENTS\n'
+            '10000020	   850.7	  717.5	  714.0	    0.0	...\n'
+            '10000022	   .	   .	    0.0	    0.0	...\n'
+            'SBLINK R 10000023\n'
+            '10000024	   .	   .	    0.0	    0.0	...\n'
+            'EBLINK R 10000023	10000024	1\n'
+            'END	10000024 	SAMPLES	EVENTS	RES	  38.54	  31.12\n',
+            0.25,
+            0.75,
+            id='missing_timestamps_lost_samples',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'SBLINK R 10000018\n'
+            '10000019	   .	   .	    0.0	    0.0	...\n'
+            '10000020	   .	   .	    0.0	    0.0	...\n'
+            'EBLINK R 10000018	10000020	2\n'
+            '10000021	   .	   .	    0.0	    0.0	...\n'
+            '10000022	   .	   .	    0.0	    0.0	...\n',
+            'unknown',
+            'unknown',
+            id='blinks_and_lost_samples_no_start_end',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'START	10000020 	RIGHT	SAMPLES	EVENTS\n'
+            'END	10000021 	SAMPLES	EVENTS	RES	  38.54	  31.12\n',
+            0,
+            1,
+            id='missing_timestamps_lost_samples',
+        ),
+    ],
+)
+def test_parse_eyelink_data_loss_ratio(
+        tmp_path, metadata, expected_blink_ratio, expected_overall_ratio,
+):
+    filepath = tmp_path / 'sub.asc'
+    filepath.write_text(metadata)
+
+    _, parsed_metadata = pm.utils.parsing.parse_eyelink(filepath)
+
+    assert parsed_metadata['data_loss_ratio_blinks'] == expected_blink_ratio
+    assert parsed_metadata['data_loss_ratio'] == expected_overall_ratio
+
+
+def test_parse_eyelink_datetime(tmp_path):
+    metadata = '** DATE: Wed Mar  8 09:25:20 2023\n'
+    expected_datetime = datetime.datetime(2023, 3, 8, 9, 25, 20)
+
+    filepath = tmp_path / 'sub.asc'
+    filepath.write_text(metadata)
+
+    _, parsed_metadata = pm.utils.parsing.parse_eyelink(filepath)
+
+    assert parsed_metadata['datetime'] == expected_datetime
+
+
+@pytest.mark.parametrize(
+    ('metadata', 'expected_mount_config'),
+    [
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG BTABLER\n',
+            {
+                'mount_type': 'Desktop',
+                'head_stabilization': 'stabilized',
+                'eyes_recorded': 'binocular / monocular',
+                'short_name': 'BTABLER',
+            },
+            id='desktop_stabilized_binocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG MTABLER\n',
+            {
+                'mount_type': 'Desktop',
+                'head_stabilization': 'stabilized',
+                'eyes_recorded': 'monocular',
+                'short_name': 'MTABLER',
+            },
+            id='desktop_stabilized_monocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG RTABLER\n',
+            {
+                'mount_type': 'Desktop',
+                'head_stabilization': 'remote',
+                'eyes_recorded': 'monocular',
+                'short_name': 'RTABLER',
+            },
+            id='desktop_remote_monocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG RBTABLER\n',
+            {
+                'mount_type': 'Desktop',
+                'head_stabilization': 'remote',
+                'eyes_recorded': 'binocular / monocular',
+                'short_name': 'RBTABLER',
+            },
+            id='desktop_remote_binocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG AMTABLER\n',
+            {
+                'mount_type': 'Arm Mount',
+                'head_stabilization': 'stabilized',
+                'eyes_recorded': 'monocular',
+                'short_name': 'AMTABLER',
+            },
+            id='arm_stabilized_monocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG ABTABLER\n',
+            {
+                'mount_type': 'Arm Mount',
+                'head_stabilization': 'stabilized',
+                'eyes_recorded': 'binocular / monocular',
+                'short_name': 'ABTABLER',
+            },
+            id='arm_stabilized_binocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG ARTABLER\n',
+            {
+                'mount_type': 'Arm Mount',
+                'head_stabilization': 'remote',
+                'eyes_recorded': 'monocular',
+                'short_name': 'ARTABLER',
+            },
+            id='arm_remote_monocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG ABRTABLE\n',
+            {
+                'mount_type': 'Arm Mount',
+                'head_stabilization': 'remote',
+                'eyes_recorded': 'binocular / monocular',
+                'short_name': 'ABRTABLE',
+            },
+            id='arm_remote_binocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG BTOWER\n',
+            {
+                'mount_type': 'Binocular Tower Mount',
+                'head_stabilization': 'stabilized',
+                'eyes_recorded': 'binocular / monocular',
+                'short_name': 'BTOWER',
+            },
+            id='binocular_tower_stabilized_binocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG TOWER\n',
+            {
+                'mount_type': 'Tower Mount',
+                'head_stabilization': 'stabilized',
+                'eyes_recorded': 'monocular',
+                'short_name': 'TOWER',
+            },
+            id='tower_stabilized_monocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG MPRIM\n',
+            {
+                'mount_type': 'Primate Mount',
+                'head_stabilization': 'stabilized',
+                'eyes_recorded': 'monocular',
+                'short_name': 'MPRIM',
+            },
+            id='primate_stabilized_binocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG BPRIM\n',
+            {
+                'mount_type': 'Primate Mount',
+                'head_stabilization': 'stabilized',
+                'eyes_recorded': 'binocular / monocular',
+                'short_name': 'BPRIM',
+            },
+            id='primate_stabilized_monocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG MLRR\n',
+            {
+                'mount_type': 'Long-Range Mount',
+                'head_stabilization': 'stabilized',
+                'eyes_recorded': 'monocular',
+                'camera_position': 'level',
+                'short_name': 'MLRR',
+            },
+            id='long_range_level_monocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG BLRR\n',
+            {
+                'mount_type': 'Long-Range Mount',
+                'head_stabilization': 'stabilized',
+                'eyes_recorded': 'binocular / monocular',
+                'camera_position': 'angled',
+                'short_name': 'BLRR',
+            },
+            id='long_range_angled_binocular',
+        ),
+        pytest.param(
+            'MSG	2154555 RECCFG CR 1000 2 1 L\n'
+            'MSG	2154555 ELCLCFG XXXXX\n',
+            {
+                'mount_type': 'unknown',
+                'head_stabilization': 'unknown',
+                'eyes_recorded': 'unknown',
+                'camera_position': 'unknown',
+                'short_name': 'XXXXX',
+            },
+            id='unknown_mount_config',
+        ),
+    ],
+)
+def test_parse_eyelink_mount_config(tmp_path, metadata, expected_mount_config):
+    filepath = tmp_path / 'sub.asc'
+    filepath.write_text(metadata)
+
+    _, parsed_metadata = pm.utils.parsing.parse_eyelink(filepath)
+
+    assert parsed_metadata['mount_configuration'] == expected_mount_config