Repository: aeye-lab/pymovements
Pull request #702 (2024-03-01 09:40:38)

feat: parse eyelink metadata
## Description

partially fixes issue #510 

## Implemented changes

Insert a description of the changes implemented in the pull request.

- [x] additionally parses resolution
- [x] parses all validations and calibrations including the result overview
- [x] new method to pre process the metadata (mainly converting types for now)

## 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?

unit tests have been extend, but especially for the calibration and validation parsing more cases need to be added based on acutal data files in order to cover all cases

## 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/702?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) Report
Attention: Patch coverage is `89.74359%` with `4 lines` in your changes are missing coverage. Please review.
> Project coverage is 99.84%. Comparing base [(`53aa819`)](https://app.codecov.io/gh/aeye-lab/pymovements/commit/53aa81926f91ded4990dc04906e0fd40e41cefd7?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) to head [(`288b176`)](https://app.codecov.io/gh/aeye-lab/pymovements/pull/702?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab).

| [Files](https://app.codecov.io/gh/aeye-lab/pymovements/pull/702?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab) | Patch % | Lines |
|---|---|---|
| [src/pymovements/utils/parsing.py](https://app.codecov.io/gh/aeye-lab/pymovements/pull/702?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=aeye-lab#diff-c3JjL3B5bW92ZW1lbnRzL3V0aWxzL3BhcnNpbmcucHk=) | 89.74% | [0 Missing and 4 partials :warning: ](https://app.codecov.io/gh/aeye-lab/pymovements/pull/702?src=pr&el=tree&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     #702      +/-   ##
===========================================
- Coverage   100.00%   99.84%   -0.16%     
===========================================
  Files           55       55              
  Lines         2500     2535      +35     
  Branches       633      583      -50     
===========================================
+ Hits          2500     2531      +31     
- Partials         0        4       +4     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/aeye-lab/pymovements/pull/702?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/CONTRIBUTING.md b/CONTRIBUTING.md
index 44ced80..4661de4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -93,7 +93,7 @@ cd pymovements
 pip install -e .
 ```
 
-If you have a problem e.g. `command not found: pip`, check whether you have activated a virtual enviroment.
+If you have a problem e.g. `command not found: pip`, check whether you have activated a virtual environment.
 
 
 ### Creating a Branch
diff --git a/src/pymovements/utils/parsing.py b/src/pymovements/utils/parsing.py
index a8b7b80..966c6ba 100755
--- a/src/pymovements/utils/parsing.py
+++ b/src/pymovements/utils/parsing.py
@@ -20,6 +20,8 @@
 """Module for parsing input data."""
 from __future__ import annotations
 
+import calendar
+import datetime
 import re
 from collections import defaultdict
 from pathlib import Path
@@ -29,7 +31,7 @@ import numpy as np
 import polars as pl
 
 EYE_TRACKING_SAMPLE = re.compile(
-    r'(?P<time>(\d{7,10}))\s+'
+    r'(?P<time>(\d+))\s+'
     r'(?P<x_pix>[-]?\d*[.]\d*)\s+'
     r'(?P<y_pix>[-]?\d*[.]\d*)\s+'
     r'(?P<pupil>\d*[.]\d*)\s+'
@@ -41,16 +43,49 @@ 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',
+                   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>.*)',
     },
 ]
 
+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<error>\D*)\s+'
+    r'(?P<validation_score_avg>\d.\d\d)\s+avg\.\s+'
+    r'(?P<validation_score_max>\d.\d\d)\s+max'
+)
+
+BLINK_START_REGEX = r'SBLINK\s+(R|L)\s+(?P<timestamp>(\d+))\s*'
+BLINK_STOP_REGEX = (
+    r'EBLINK\s+(R|L)\s+(?P<timestamp_start>(\d+))\s+'
+    r'(?P<timestamp_end>(\d{6,10}))\s+(?P<duration_ms>(\d+))\s*'
+)
+INVALID_SAMPLE_REGEX = r'(?P<timestamp>(\d{6,10}))\s+\.\s+\.\s+0\.0\s+0\.0\s+\.\.\.\s*'
+
+CALIBRATION_TIMESTAMP_REGEX = r'MSG\s+(?P<timestamp>\d+)\s+!CAL\s*\n'
+
+CALIBRATION_REGEX = (
+    r'>+\s+CALIBRATION\s+\(HV(?P<num_points>\d\d?),'
+    r'(?P<type>.*)\).*'
+    r'(?P<tracked_eye>RIGHT|LEFT):\s+<{9}'
+)
+
+START_RECORDING_REGEX = r'START\s+(?P<timestamp>(\d+))\s+(RIGHT|LEFT)\s+(?P<types>.*)'
+STOP_RECORDING_REGEX = (
+    r'END\s+(?P<timestamp>(\d+))\s+\s+(?P<types>.*)\s+RES\s+'
+    r'(?P<xres>[\d\.]*)\s+(?P<yres>[\d\.]*)\s*'
+)
+
 
 def check_nan(sample_location: str) -> float:
     """Return position as float or np.nan depending on validity of sample.
@@ -182,13 +217,37 @@ def parse_eyelink(
         lines = asc_file.readlines()
 
     # will return an empty string if the key does not exist
-    metadata = defaultdict(str)
+    metadata: defaultdict = defaultdict(str)
 
     compiled_metadata_patterns = []
     for metadata_pattern in EYELINK_META_REGEXES:
         compiled_metadata_patterns.append({'pattern': re.compile(metadata_pattern['pattern'])})
 
+    compiled_validation_pattern = re.compile(VALIDATION_REGEX)
+    compiled_calibration_pattern = re.compile(CALIBRATION_REGEX)
+    compiled_calibration_timestamp = re.compile(CALIBRATION_TIMESTAMP_REGEX)
+    cal_timestamp = ''
+
+    compiled_blink_start = re.compile(BLINK_START_REGEX)
+    compiled_blink_stop = re.compile(BLINK_STOP_REGEX)
+    compiled_invalid_sample = re.compile(INVALID_SAMPLE_REGEX)
+
+    compiled_recording_start = re.compile(START_RECORDING_REGEX)
+    compiled_recording_stop = re.compile(STOP_RECORDING_REGEX)
+
+    validations = []
+    calibrations = []
+    blinks = []
+    invalid_samples = []
+
+    blink = False
+
+    start_recording_timestamp = ''
+    total_recording_duration = 0
+    num_blink_samples = 0
+
     for line in lines:
+
         for pattern_dict in compiled_patterns:
 
             if match := pattern_dict['pattern'].match(line):
@@ -200,7 +259,46 @@ def parse_eyelink(
                     for column, value in match.groupdict().items():
                         current_additional[column] = value
 
-        if eye_tracking_sample_match := EYE_TRACKING_SAMPLE.match(line):
+        if cal_timestamp:
+            # if a calibration timestamp has been found, the next line will be a
+            # calibration pattern, if not, there will only be the timestamp added to the overview
+
+            # very ugly pylint solution
+            calibrations.append(
+                {
+                    'timestamp': cal_timestamp,
+                    **match.groupdict(),
+                }
+                if (match := compiled_calibration_pattern.match(line))
+                else {'timestamp': cal_timestamp},
+            )
+            cal_timestamp = ''
+
+        elif compiled_blink_start.match(line):
+            blink = True
+
+        elif match := compiled_blink_stop.match(line):
+            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'],
+                'num_samples': num_blink_samples,
+            }
+            num_blink_samples = 0
+            blinks.append(blink_info)
+
+        elif match := compiled_recording_start.match(line):
+            start_recording_timestamp = match.groupdict()['timestamp']
+
+        elif match := compiled_recording_stop.match(line):
+            stop_recording_timestamp = match.groupdict()['timestamp']
+            block_duration = int(stop_recording_timestamp) - int(start_recording_timestamp)
+
+            total_recording_duration += block_duration
+
+        elif eye_tracking_sample_match := EYE_TRACKING_SAMPLE.match(line):
 
             timestamp_s = eye_tracking_sample_match.group('time')
             x_pix_s = eye_tracking_sample_match.group('x_pix')
@@ -220,6 +318,17 @@ def parse_eyelink(
             for additional_column in additional_columns:
                 samples[additional_column].append(current_additional[additional_column])
 
+            if (match := compiled_invalid_sample.match(line)) and not blink:
+                invalid_samples.append(match.groupdict()['timestamp'])
+            elif compiled_invalid_sample.match(line) and blink:
+                num_blink_samples += 1
+
+        elif match := compiled_calibration_timestamp.match(line):
+            cal_timestamp = match.groupdict()['timestamp']
+
+        elif match := compiled_validation_pattern.match(line):
+            validations.append(match.groupdict())
+
         elif compiled_metadata_patterns:
             for pattern_dict in compiled_metadata_patterns.copy():
                 if match := pattern_dict['pattern'].match(line):
@@ -230,11 +339,27 @@ def parse_eyelink(
                     compiled_metadata_patterns.remove(pattern_dict)
 
     if metadata:
-        # in case the version strings have not been found, they will be empty strings (defaultdict)
-        metadata['version_number'], metadata['model'] = _parse_full_eyelink_version(
-            metadata['version_1'], metadata['version_2'],
+        # if the sampling rate is not found, we cannot calculate the data loss
+        actual_number_of_samples = len(samples['time'])
+
+        data_loss_ratio, data_loss_ratio_blinks = _calculate_data_loss(
+            blinks=blinks,
+            invalid_samples=invalid_samples,
+            actual_num_samples=actual_number_of_samples,
+            total_rec_duration=total_recording_duration,
+            sampling_rate=metadata['sampling_rate'],
         )
 
+        pre_processed_metadata: dict[str, Any] = _pre_process_metadata(metadata)
+
+        # is not yet pre-processed but should be
+        pre_processed_metadata['calibrations'] = calibrations
+        pre_processed_metadata['validations'] = validations
+        pre_processed_metadata['blinks'] = blinks
+        pre_processed_metadata['data_loss_ratio'] = data_loss_ratio
+        pre_processed_metadata['data_loss_ratio_blinks'] = data_loss_ratio_blinks
+        pre_processed_metadata['total_recording_duration_ms'] = total_recording_duration
+
     else:
         raise Warning('No metadata found. Please check the file for errors.')
 
@@ -253,10 +378,97 @@ def parse_eyelink(
         schema_overrides=schema_overrides,
     )
 
-    # convert the defaultdict to a regular dict
-    return_metadata = dict(metadata)
+    return df, pre_processed_metadata
+
+
+def _pre_process_metadata(metadata: defaultdict[str, Any]) -> dict[str, Any]:
+    """Pre-process metadata to suitable types and formats.
+
+    Parameters
+    ----------
+    metadata: defaultdict[str, Any]
+        Metadata to pre-process.
+
+    Returns
+    -------
+    dict[str, Any]
+        Pre-processed metadata.
+    """
+    # in case the version strings have not been found, they will be empty strings (defaultdict)
+    metadata['version_number'], metadata['model'] = _parse_full_eyelink_version(
+        metadata['version_1'], metadata['version_2'],
+    )
+
+    if 'resolution' in metadata:
+        coordinates = [int(coord) for coord in metadata['resolution'].split()]
+        resolution = (coordinates[2] - coordinates[0] + 1, coordinates[3] - coordinates[1] + 1)
+        metadata['resolution'] = resolution
+
+    if metadata['sampling_rate']:
+        metadata['sampling_rate'] = float(metadata['sampling_rate'])
+    else:
+        metadata['sampling_rate'] = 'unknown'
+
+    # if the data 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'])
+        month_num = list(calendar.month_abbr).index(metadata['month'])
+        date_time = datetime.datetime(day=metadata['day'], month=month_num, year=metadata['year'])
+        time = datetime.datetime.strptime(metadata['time'], '%H:%M:%S')
+        metadata['datetime'] = datetime.datetime.combine(date_time, time.time())
+
+    return_metadata: dict[str, Any] = dict(metadata)
+
+    return return_metadata
+
+
+def _calculate_data_loss(
+        blinks: list[dict[str, Any]],
+        invalid_samples: list[str],
+        actual_num_samples: int,
+        total_rec_duration: int,
+        sampling_rate: float,
+) -> tuple[float | str, float | str]:
+    """Calculate data loss and blink loss.
+
+    Parameters
+    ----------
+    blinks: list[dict[str, Any]]
+        List of dicts of blinks. Each dict containing start and stop timestamps and duration.
+    invalid_samples: list[str]
+        List of invalid samples.
+    actual_num_samples: int
+        Number of actual samples recorded.
+    total_rec_duration: int
+        Total duration of the recording.
+    sampling_rate: float
+        Sampling rate of the eye tracker.
+
+    Returns
+    -------
+    tuple[float | str, float | str]
+        Data loss ratio and blink loss ratio.
+    """
+    if not sampling_rate or not total_rec_duration:
+        return 'unknown', 'unknown'
+
+    dl_ratio_blinks = 0.0
+
+    num_expected_samples = total_rec_duration * float(sampling_rate) / 1000
+
+    total_lost_samples = num_expected_samples - actual_num_samples
+
+    if blinks:
+        total_blink_samples = sum(blink['num_samples'] for blink in blinks)
+        dl_ratio_blinks = total_blink_samples / num_expected_samples
+        total_lost_samples += total_blink_samples
+
+    total_lost_samples += len(invalid_samples)
+
+    dl_ratio = total_lost_samples / num_expected_samples
 
-    return df, return_metadata
+    return dl_ratio, dl_ratio_blinks
 
 
 def _parse_full_eyelink_version(version_str_1: str, version_str_2: str) -> tuple[str, str]:


Test patch:
diff --git a/tests/unit/utils/parsing_test.py b/tests/unit/utils/parsing_test.py
index f93b828..534583e 100644
--- a/tests/unit/utils/parsing_test.py
+++ b/tests/unit/utils/parsing_test.py
@@ -18,6 +18,9 @@
 # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 # SOFTWARE.
 """Tests pymovements asc to csv processing."""
+import datetime
+from pathlib import Path
+
 import numpy as np
 import polars as pl
 import pytest
@@ -25,7 +28,6 @@ from polars.testing import assert_frame_equal
 
 import pymovements as pm
 
-
 ASC_TEXT = r"""
 ** DATE: Wed Mar  8 09:25:20 2023
 ** TYPE: EDF_FILE BINARY EVENT SAMPLE TAGGED
@@ -50,31 +52,52 @@ PUPIL	AREA
 EVENTS	GAZE	LEFT	RATE	1000.00	TRACKING	CR	FILTER	2
 SAMPLES	GAZE	LEFT	RATE	1000.00	TRACKING	CR	FILTER	2	INPUT
 the next line has all additional trial columns set to None
+START	10000000 	RIGHT	SAMPLES	EVENTS
 10000000	  850.7	  717.5	  714.0	    0.0	...
+END	10000001 	SAMPLES	EVENTS	RES	  38.54	  31.12
 MSG 10000001 START_A
+START	10000002 	RIGHT	SAMPLES	EVENTS
 the next line now should have the task column set to A
 10000002	  850.7	  717.5	  714.0	    0.0	...
+END	10000002 	SAMPLES	EVENTS	RES	  38.54	  31.12
 MSG 10000003 STOP_A
 the task should be set to None again
+START	10000004 	RIGHT	SAMPLES	EVENTS
 10000004	  850.7	  717.5	  714.0	    0.0	...
+END	10000005 	SAMPLES	EVENTS	RES	  38.54	  31.12
 MSG 10000005 START_B
 the next line now should have the task column set to B
+START	10000006 	RIGHT	SAMPLES	EVENTS
 10000006	  850.7	  717.5	  714.0	    0.0	...
+END	10000007 	SAMPLES	EVENTS	RES	  38.54	  31.12
 MSG 10000007 START_TRIAL_1
 the next line now should have the trial column set to 1
+START	10000008 	RIGHT	SAMPLES	EVENTS
 10000008	  850.7	  717.5	  714.0	    0.0	...
+END	10000009 	SAMPLES	EVENTS	RES	  38.54	  31.12
 MSG 10000009 STOP_TRIAL_1
 MSG 10000010 START_TRIAL_2
 the next line now should have the trial column set to 2
+START	10000011 	RIGHT	SAMPLES	EVENTS
 10000011	  850.7	  717.5	  714.0	    0.0	...
+END	10000012 	SAMPLES	EVENTS	RES	  38.54	  31.12
 MSG 10000012 STOP_TRIAL_2
 MSG 10000013 START_TRIAL_3
 the next line now should have the trial column set to 3
+START	10000014 	RIGHT	SAMPLES	EVENTS
 10000014	  850.7	  717.5	  714.0	    0.0	...
+END	10000015 	SAMPLES	EVENTS	RES	  38.54	  31.12
 MSG 10000015 STOP_TRIAL_3
 MSG 10000016 STOP_B
 task and trial should be set to None again
+START	10000017 	RIGHT	SAMPLES	EVENTS
 10000017	  850.7	  717.5	  .	    0.0	...
+SBLINK R 10000018
+10000019	   .	   .	    0.0	    0.0	...
+10000020	   .	   .	    0.0	    0.0	...
+EBLINK R 10000018	10000020	2
+10000021	   .	   .	    0.0	    0.0	...
+END	10000022 	SAMPLES	EVENTS	RES	  38.54	  31.12
 """
 
 PATTERNS = [
@@ -104,29 +127,45 @@ PATTERNS = [
 
 EXPECTED_DF = pl.from_dict(
     {
-        'time': [10000000, 10000002, 10000004, 10000006, 10000008, 10000011, 10000014, 10000017],
-        'x_pix': [850.7, 850.7, 850.7, 850.7, 850.7, 850.7, 850.7, 850.7],
-        'y_pix': [717.5, 717.5, 717.5, 717.5, 717.5, 717.5, 717.5, 717.5],
-        'pupil': [714.0, 714.0, 714.0, 714.0, 714.0, 714.0, 714.0, np.nan],
-        'task': [None, 'A', None, 'B', 'B', 'B', 'B', None],
-        'trial_id': [None, None, None, None, '1', '2', '3', None],
+        'time': [
+            10000000, 10000002, 10000004, 10000006, 10000008, 10000011, 10000014,
+            10000017, 10000019, 10000020, 10000021,
+        ],
+        'x_pix': [850.7, 850.7, 850.7, 850.7, 850.7, 850.7, 850.7, 850.7, np.nan, np.nan, np.nan],
+        'y_pix': [717.5, 717.5, 717.5, 717.5, 717.5, 717.5, 717.5, 717.5, np.nan, np.nan, np.nan],
+        'pupil': [714.0, 714.0, 714.0, 714.0, 714.0, 714.0, 714.0, np.nan, 0.0, 0.0, 0.0],
+        'task': [None, 'A', None, 'B', 'B', 'B', 'B', None, None, None, None],
+        'trial_id': [None, None, None, None, '1', '2', '3', None, None, None, None],
     },
 )
 
 EXPECTED_METADATA = {
     'weekday': 'Wed',
     'month': 'Mar',
-    'day': '8',
+    'day': 8,
     'time': '09:25:20',
-    'year': '2023',
+    'year': 2023,
     'version_1': 'EYELINK II 1',
     'version_2': 'EYELINK II CL v6.12 Feb  1 2018 (EyeLink Portable Duo)',
     'model': 'EyeLink Portable Duo',
     'version_number': '6.12',
-    'sampling_rate': '1000.00',
+    'sampling_rate': 1000.00,
     'filter': '2',
     'tracking': 'CR',
     'tracked_eye': 'LEFT',
+    'calibrations': [],
+    'validations': [],
+    'resolution': (1280, 1024),
+    'data_loss_ratio_blinks': 0.18181818181818182,
+    'data_loss_ratio': 0.2727272727272727,
+    'total_recording_duration_ms': 11,
+    'datetime': datetime.datetime(2023, 3, 8, 9, 25, 20),
+    'blinks': [{
+        'duration_ms': '2',
+        'num_samples': 2,
+        'start_timestamp': '10000018',
+        'stop_timestamp': '10000020',
+    }],
 }
 
 
@@ -181,7 +220,7 @@ def test_parse_eyelink_raises_value_error(tmp_path, patterns):
         pytest.param(
             '** DATE: Wed Mar  8 09:25:20 2023\n'
             '** VERSION: EYELINK II 1\n'
-            '** EYELINK II CL v5.12 Feb  1 2018',
+            '** EYELINK II CL v5.12 Feb  1 2018\n',
             '5.12',
             'EyeLink 1000 Plus',
             id='eye_link_1000_plus',
@@ -254,47 +293,105 @@ def test_parse_eyelink_version(tmp_path, metadata, expected_version, expected_mo
 
 
 @pytest.mark.parametrize(
-    ('metadata', 'expected_time'),
+    ('metadata', 'expected_msg'),
     [
         pytest.param(
-            '** DATE: Wed Mar  8 09:25:20 2023\n'
-            '** VERSION: EYELINK II 1\n'
-            '** EYELINK II CL v6.12 Feb  1 2018 (EyeLink Portable Duo)',
-            '09:25:20',
-            id='092520',
+            '',
+            'No metadata found. Please check the file for errors.',
+            id='eye_link_no_metadata',
         ),
     ],
 )
-def test_parse_eyelink_time(tmp_path, metadata, expected_time):
+def test_no_metadata_warning(tmp_path, metadata, expected_msg):
     filepath = tmp_path / 'sub.asc'
     filepath.write_text(metadata)
 
-    _, metadata = pm.utils.parsing.parse_eyelink(
-        filepath,
-    )
+    with pytest.raises(Warning) as info:
+        _, metadata = pm.utils.parsing.parse_eyelink(
+            filepath,
+        )
+
+    msg = info.value.args[0]
 
-    assert metadata['time'] == expected_time
+    assert msg == expected_msg
 
 
 @pytest.mark.parametrize(
-    'metadata, expected_msg',
+    ('metadata', 'expected_validation', 'expected_calibration'),
     [
         pytest.param(
-            '',
-            'No metadata found. Please check the file for errors.',
-            id='eye_link_no_metadata',
+            'MSG	2095865 DISPLAY_COORDS 0 0 1279 1023\n'
+            'MSG	7045618 !CAL \n'
+            '>>>>>>> CALIBRATION (HV9,P-CR) FOR LEFT: <<<<<<<<<\n'
+            'MSG	7045618 !CAL Calibration points:  \n'
+            'MSG	1076158 !CAL VALIDATION HV9 R RIGHT POOR ERROR 2.40 avg. 6.03 max  '
+            'OFFSET 0.19 deg. 4.2,6.3 pix.\n',
+            [{
+                'error': 'POOR ERROR',
+                'eye_tracked': 'RIGHT',
+                'num_points': '9',
+                'timestamp': '1076158',
+                'validation_score_avg': '2.40',
+                'validation_score_max': '6.03',
+            }],
+            [{
+                'num_points': '9',
+                'timestamp': '7045618',
+                'tracked_eye': 'LEFT',
+                'type': 'P-CR',
+            }],
+            id='cal_timestamp_with_space',
+        ),
+        pytest.param(
+            '** DATE: Wed Feb  1 04:38:54 2017\n'
+            'MSG	7045618 !CAL\n'
+            '>>>>>>> CALIBRATION (HV9,P-CR) FOR LEFT: <<<<<<<<<\n',
+            [],
+            [{
+                'num_points': '9',
+                'timestamp': '7045618',
+                'tracked_eye': 'LEFT',
+                'type': 'P-CR',
+            }],
+            id='cal_timestamp_no_space_no_val',
+        ),
+        pytest.param(
+            '** DATE: Wed Feb  1 04:38:54 2017\n'
+            'MSG	7045618 !CAL\n'
+            'MSG	7045618 !CAL\n',
+            [],
+            [{'timestamp': '7045618'}],
+            id='cal_timestamp_no_cal_no_val',
         ),
     ],
 )
-def test_no_metadata_warning(tmp_path, metadata, expected_msg):
+def test_val_cal_eyelink(tmp_path, metadata, expected_validation, expected_calibration):
     filepath = tmp_path / 'sub.asc'
     filepath.write_text(metadata)
 
-    with pytest.raises(Warning) as info:
-        _, metadata = pm.utils.parsing.parse_eyelink(
-            filepath,
-        )
+    _, parsed_metadata = pm.utils.parsing.parse_eyelink(filepath)
 
-    msg = info.value.args[0]
+    assert parsed_metadata['calibrations'] == expected_calibration
+    assert parsed_metadata['validations'] == expected_validation
 
-    assert msg == expected_msg
+
+def test_parse_val_cal_eyelink_monocular_file():
+    example_asc_monocular_path = Path(__file__).parent.parent.parent / \
+        'files/eyelink_monocular_example.asc'
+
+    _, metadata = pm.utils.parsing.parse_eyelink(example_asc_monocular_path)
+
+    expected_validation = [{
+        'error': 'GOOD ERROR',
+        'eye_tracked': 'LEFT',
+        'num_points': '9',
+        'timestamp': '2148587',
+        'validation_score_avg': '0.27',
+        'validation_score_max': '0.83',
+    }]
+    expected_calibration = [{
+        'num_points': '9', 'type': 'P-CR', 'tracked_eye': 'LEFT', 'timestamp': '2135819',
+    }]
+
+    assert metadata['calibrations'] == expected_calibration
+    assert metadata['validations'] == expected_validation