Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/test_on_pr.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
name: Test on Pull Request
on:
pull_request:
paths:
- 'src/**'
- 'test/**'
- 'requirements.txt'
- 'pyproject.toml'
- 'setup.py'
- '.github/workflows/test_on_pr.yml'
jobs:
test:
runs-on: ubuntu-latest
Expand Down
9 changes: 9 additions & 0 deletions src/pywib/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class ComponentTypes:

class ColumnNames:
""" Standard column names for DataFrame operations."""
# Columns present on input DataFrame
SESSION_ID = 'sessionId'
SCENE_ID = 'sceneId'
EVENT_TYPE = 'eventType'
Expand All @@ -68,6 +69,7 @@ class ColumnNames:
KEY_VALUE_EVENT = 'keyValueEvent'
KEY_CODE_EVENT = 'keyCodeEvent'
SOURCE_SESSION_ID = 'sourceSessionId'
# From here downwards we have the columns generated by methods
DT = 'dt'
DX = 'dx'
DY = 'dy'
Expand All @@ -77,6 +79,13 @@ class ColumnNames:
AUC_RATIO = 'auc_ratio'
NUMBER_OF_PAUSES = 'num_pauses'
MEAN_PAUSE_PER_TRACE = 'mean_pauses_per_trace'
CLICK_SLIPS = 'click_slips'
MAX_CLICK_SLIP = 'max_click_slip'
MIN_CLICK_SLIP = 'min_click_slip'
MEAN_CLICK_SLIP = 'mean_click_slip'
MAX_CLICK_DURATION = 'max_click_duration'
MIN_CLICK_DURATION = 'min_click_duration'
MEAN_CLICK_DURATION = 'mean_click_duration'

class KeyCodeEvents:
""" Key code event constants for keyboard interactions."""
Expand Down
16 changes: 8 additions & 8 deletions src/pywib/core/mouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,19 +91,19 @@ def click_slip(df: pd.DataFrame, threshold: float = 5.0) -> dict:
last_move_y = None
mouse_down_time = None
accumulated_move_distance = 0.0
# TODO this variable is never used??
click_slips_per_session[session_id] = {
"slips": slips,
"distances": distances,
}
metrics = {
"click_slips": slips,
"longest_click_slip": max(distances) if distances else 0,
"shortest_click_slip": min(distances) if distances else 0,
"average_click_slip": slips / len(distances) if distances else 0,
"average_click_slip_distance": np.mean(distances) if distances else 0,
"average_click_duration": np.mean(durations) if durations else 0,
"max_click_duration": max(durations) if durations else 0,
"min_click_duration": min(durations) if durations else 0,
ColumnNames.CLICK_SLIPS: slips,
ColumnNames.MAX_CLICK_SLIP: max(distances) if distances else 0,
ColumnNames.MIN_CLICK_SLIP: min(distances) if distances else 0,
ColumnNames.MEAN_CLICK_SLIP: np.mean(distances) if distances else 0,
ColumnNames.MEAN_CLICK_DURATION: np.mean(durations) if durations else 0,
ColumnNames.MAX_CLICK_DURATION: max(durations) if durations else 0,
ColumnNames.MIN_CLICK_DURATION: min(durations) if durations else 0,
}
metrics_per_session[session_id] = metrics
return metrics_per_session
44 changes: 30 additions & 14 deletions src/pywib/core/movement.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
compute_metrics_from_traces, extract_traces_by_session,
auc_ratio_traces, auc_ratio_df)
from pywib.constants import ColumnNames
from pywib.utils.validation import validate_not_none, validate_one_not_none

def velocity(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None, per_traces: bool = False) -> dict[str, list[pd.DataFrame]]:
"""
Expand All @@ -22,8 +23,8 @@ def velocity(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = No
Returns:
dict[str, list[pd.DataFrame]]: Dictionary of traces with computed 'velocity' column.
"""
if df is None and traces is None:
raise ValueError("Either 'df' or 'traces' must be provided.")

validate_one_not_none(df, traces)

if not per_traces:
# Compute directly on the DataFrame (no trace extraction)
Expand All @@ -50,13 +51,21 @@ def velocity_metrics(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]] = N
Returns:
dict: A dictionary with keys as (sessionId) and values as dictionaries with 'mean ', 'max', and 'min' velocity.
"""
validate_one_not_none(df, traces)

if (traces is None):
if df is not None or (ColumnNames.VELOCITY not in df.columns):
validate_dataframe(df)
traces = velocity(df, per_traces=True)
else:
raise ValueError("Either 'df' or 'traces' must be provided.")

return compute_metrics_from_traces(
df=df,
traces=traces,
column_name=ColumnNames.VELOCITY,
compute_traces_fn=velocity,
preprocess_fn=lambda s: s[s > 0] # Exclude zero velocities
compute_traces_fn=lambda _: traces, # Already computed above
preprocess_fn=lambda s: s[s != 0] # Exclude zero accelerations
)

def acceleration(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None, per_traces: bool = False) -> dict[str, list[pd.DataFrame]]:
Expand All @@ -65,8 +74,7 @@ def acceleration(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]]

If `traces` is None, they will be computed from the DataFrame.
"""
if df is None and traces is None:
raise ValueError("Either 'df' or 'traces' must be provided.")
validate_one_not_none(df, traces)

if not per_traces:
# Compute directly on the DataFrame (no trace extraction)
Expand All @@ -91,11 +99,14 @@ def acceleration_metrics(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]]
Returns:
dict: A dictionary with keys as (sessionId) and values as dictionaries with 'mean', 'max', and 'min' acceleration.
"""
if (ColumnNames.ACCELERATION not in df.columns) and (traces is None):

validate_one_not_none(df, traces)

if (ColumnNames.ACCELERATION not in df.columns) or (traces is None):
validate_dataframe(df)
if ColumnNames.VELOCITY not in df.columns:
traces = velocity(df, per_traces=True)
traces = acceleration(None, traces, per_traces=True)
traces = velocity(df, traces, per_traces=True)
traces = acceleration(df, traces, per_traces=True)

return compute_metrics_from_traces(
df=df,
Expand Down Expand Up @@ -123,8 +134,8 @@ def jerkiness(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = N
dict[str, list[pd.DataFrame]]
Dictionary of traces, each containing the computed 'jerkiness' column.
"""
if df is None and traces is None:
raise ValueError("Either 'df' or 'traces' must be provided.")

validate_one_not_none(df, traces)

if not per_traces:
# Compute directly on the DataFrame (no trace extraction)
Expand All @@ -149,13 +160,16 @@ def jerkiness_metrics(df: pd.DataFrame, traces: dict[str, list[pd.DataFrame]] =
Returns:
dict: A dictionary with keys as (sessionId) and values as dictionaries with 'mean', 'max', and 'min' jerkiness.
"""
if((ColumnNames.JERKINESS not in df.columns) and (traces is None)):

validate_one_not_none(df, traces)

if((ColumnNames.JERKINESS not in df.columns) or (traces is None)):
validate_dataframe(df)
if(ColumnNames.ACCELERATION not in df.columns):
if(ColumnNames.VELOCITY not in df.columns):
traces = velocity(df, per_traces=True)
traces = acceleration(None, traces, per_traces=True)
traces = jerkiness(None, traces, per_traces=True)
traces = acceleration(df, traces, per_traces=True)
traces = jerkiness(df, traces, per_traces=True)

return compute_metrics_from_traces(
df=df,
Expand All @@ -177,6 +191,8 @@ def path(df: pd.DataFrame = None, traces: dict[str, list[pd.DataFrame]] = None)
Returns:
pd.DataFrame: DataFrame with an additional 'distance' column representing the path length.
"""

validate_one_not_none(df, traces)

if traces is None:
validate_dataframe(df)
Expand Down
11 changes: 11 additions & 0 deletions src/pywib/utils/validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@
ColumnNames.KEY_VALUE_EVENT, ColumnNames.KEY_CODE_EVENT
]

def validate_not_none(*params):
for param in params:
if param is None:
raise ValueError("Either 'df' or 'traces' must be provided.")

def validate_one_not_none(*params):
for param in params:
if param is not None:
return
raise ValueError("Either 'df' or 'traces' must be provided.")

def validate_dataframe(df: pd.DataFrame):

for col in required_columns:
Expand Down
8 changes: 4 additions & 4 deletions test/test_mouse.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ class TestData:
'SESSION_B': 2
}
expected_click_slips = {
'SESSION_A': {'click_slips': 0,'longest_click_slip':0, 'shortest_click_slip': 0, 'average_click_slip': 0, 'average_click_slip_distance': 0, 'average_click_duration': np.float64(30.0), 'max_click_duration': np.float64(30.0), 'min_click_duration': np.float64(30.0)},
'SESSION_B': {'click_slips': 1, 'longest_click_slip': np.float64(25.0), 'shortest_click_slip': np.float64(25.0), 'average_click_slip': 1.0, 'average_click_slip_distance': np.float64(25.0), 'average_click_duration': np.float64(30.0), 'max_click_duration': np.float64(30.0), 'min_click_duration': np.float64(30.0)}
'SESSION_A': {'click_slips': 0,'max_click_slip':0, 'min_click_slip': 0, 'mean_click_slip': 0, 'mean_click_duration': np.float64(30.0), 'max_click_duration': 30, 'min_click_duration': 30},
'SESSION_B': {'click_slips': 1, 'max_click_slip': np.float64(25.0), 'min_click_slip': np.float64(25.0), 'mean_click_slip': np.float64(25.0), 'mean_click_duration': np.float64(30.0), 'max_click_duration': np.float64(30.0), 'min_click_duration': 30}
}

class TestMouse(unittest.TestCase):
Expand All @@ -46,8 +46,8 @@ def test_click_duration(self):
durations = click_slip(self.test_data)
self.assertIn('SESSION_A', durations)
self.assertIn('SESSION_B', durations)
self.assertGreaterEqual(durations['SESSION_A']['average_click_duration'], 30)
self.assertGreaterEqual(durations['SESSION_B']['average_click_duration'], 30)
self.assertGreaterEqual(durations['SESSION_A']['mean_click_duration'], 30)
self.assertGreaterEqual(durations['SESSION_B']['mean_click_duration'], 30)
self.assertGreaterEqual(durations['SESSION_A']['max_click_duration'], durations['SESSION_B']['min_click_duration'])
self.assertGreaterEqual(durations['SESSION_B']['max_click_duration'], durations['SESSION_B']['min_click_duration'])

Expand Down
68 changes: 61 additions & 7 deletions test/test_movement.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import sys
import os
sys.path.insert(0, os.path.dirname(__file__))
from utils import process_csv, import_pyModule
from utils import assert_between_zero_inf, process_csv, import_pyModule

import_pyModule()
from pywib import (velocity, acceleration, compute_space_time_diff,
Expand Down Expand Up @@ -54,12 +54,37 @@ def test_velocity_metrics(self):
metrics = velocity_metrics(self.test_data.copy())

for _, session_id in metrics.items():
self.assertIn('mean', session_id)
self.assertIn('max', session_id)
self.assertIn('min', session_id)
self.assertGreaterEqual(session_id['mean'], 0)
self.assertGreaterEqual(session_id['max'], 0)
self.assertGreaterEqual(session_id['min'], 0)
assert_between_zero_inf(self, session_id, 'mean')
assert_between_zero_inf(self, session_id, 'max')
assert_between_zero_inf(self, session_id, 'min')

def test_velocity_metrics_from_velocity_df(self):
"""Test velocity metrics from a previosly computed velocity dataframe"""
# Compute space-time differences
df = compute_space_time_diff(self.test_data.copy())

# Calculate velocity
df_velocity = velocity(df)
metrics = velocity_metrics(df_velocity)

for _, session_id in metrics.items():
assert_between_zero_inf(self, session_id, 'mean')
assert_between_zero_inf(self, session_id, 'max')
assert_between_zero_inf(self, session_id, 'min')

def test_velocity_metrics_from_velocity_traces(self):
"""Test velocity metrics from a previosly computed velocity dataframe"""
# Compute space-time differences
df = compute_space_time_diff(self.test_data.copy())

# Calculate velocity
df_velocity = velocity(df, per_traces=True)
metrics = velocity_metrics(None, traces=df_velocity)

for _, session_id in metrics.items():
assert_between_zero_inf(self, session_id, 'mean')
assert_between_zero_inf(self, session_id, 'max')
assert_between_zero_inf(self, session_id, 'min')

def test_acceleration(self):
"""Test acceleration calculation"""
Expand Down Expand Up @@ -103,6 +128,21 @@ def test_acceleration_metrics(self):
self.assertGreaterEqual(session['mean'], 0)
self.assertGreaterEqual(session['max'], session['min'])

def test_acceleration_metrics_from_aceleration_df(self):
"""Test acceleration metrics from precomputed acceleration df"""
df = compute_space_time_diff(self.test_data.copy())

# Calculate acceleration
df_acceleration = acceleration(df)
acc_metrics = acceleration_metrics(df_acceleration)

for _, session in acc_metrics.items():
self.assertIn('mean', session)
self.assertIn('max', session)
self.assertIn('min', session)
self.assertGreaterEqual(session['mean'], 0)
self.assertGreaterEqual(session['max'], session['min'])

def test_jerkiness(self):
"""Test jerkiness calculation"""
jk_df = jerkiness(self.test_data.copy(), per_traces=True)
Expand All @@ -127,6 +167,17 @@ def test_jerkiness_metrics(self):
self.assertGreaterEqual(session['mean'], 0)
self.assertGreaterEqual(session['max'], session['min'])

def test_jerkiness_from_df(self):
jk_df = jerkiness(self.test_data.copy())
metrics = jerkiness_metrics(jk_df)

for _, session in metrics.items():
self.assertIn('mean', session)
self.assertIn('max', session)
self.assertIn('min', session)
self.assertGreaterEqual(session['mean'], 0)
self.assertGreaterEqual(session['max'], session['min'])

def test_auc(self):
auc = auc_ratio(self.test_data_auc.copy())
for _, session in auc.items():
Expand All @@ -143,5 +194,8 @@ def test_auc_metrics(self):
self.assertIn('max_ratio', session)
self.assertGreaterEqual(session['mean_ratio'], 0)




if __name__ == '__main__':
unittest.main()
6 changes: 6 additions & 0 deletions test/utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import numpy as np
import pandas as pd
from collections import defaultdict

Expand Down Expand Up @@ -62,3 +63,8 @@ def csv_to_df_no_checks(file_path):
file_path (str): Path to the CSV file.
"""
return pd.read_csv(file_path, encoding='utf-8', sep=',')

def assert_between_zero_inf(self, data, column):
self.assertIn(column, data)
self.assertGreaterEqual(data[column], 0)
self.assertLessEqual(data[column], np.inf)
Loading