Repository: PedestrianDynamics/PedPy

Issue:
compute_crossing_frames may count multiple crossings when movement ends on line
Opposite of what the definition of the method (description and code comment) says, when the movement of a pedestrian ends on the line it counts as crossed.

**To reproduce:**
```python
import numpy as np
import pandas as pd

from pedpy import *
from pedpy.methods.method_utils import compute_crossing_frames

x = np.concatenate(
    [
        np.linspace(10, 0, 11),
        np.linspace(0, 0, 4),
        np.linspace(0, -5, 6),
    ]
)
y = np.zeros_like(x)

frames = np.arange(0, len(x))
id = np.ones_like(x)

df_traj_simple = pd.DataFrame({"id": id, "frame": frames, "x": x, "y": y})
traj_simple = TrajectoryData(data=df_traj_simple, frame_rate=1)

ml_simple = MeasurementLine([(0, -0.5), (0, 0.5)])
result = compute_crossing_frames(traj_data=traj_simple, measurement_line=ml_simple)
```

**Expected result:**
|    | id | frame |
|----|----|-------|
| 16 | 1  | 16    |

**Actual result:**\
|    | id | frame |
|----|----|-------|
| 10 | 1  | 10    |
| 16 | 1  | 16    |


Discussion:

Patch:
diff --git a/pedpy/methods/method_utils.py b/pedpy/methods/method_utils.py
index 59e2cca..722052a 100644
--- a/pedpy/methods/method_utils.py
+++ b/pedpy/methods/method_utils.py
@@ -6,7 +6,7 @@ import logging
 from collections import defaultdict
 from dataclasses import dataclass
 from enum import Enum, auto
-from typing import List, Optional, Tuple
+from typing import Final, List, Optional, Tuple
 
 import numpy as np
 import pandas as pd
@@ -175,11 +175,13 @@ def compute_frame_range_in_area(
         traj_data=traj_data, measurement_area=measurement_area
     )
 
-    crossing_frames_first = compute_crossing_frames(
-        traj_data=traj_data, measurement_line=measurement_line
+    crossing_frames_first = _compute_crossing_frames(
+        traj_data=traj_data,
+        measurement_line=measurement_line,
+        count_on_line=True,
     )
-    crossing_frames_second = compute_crossing_frames(
-        traj_data=traj_data, measurement_line=second_line
+    crossing_frames_second = _compute_crossing_frames(
+        traj_data=traj_data, measurement_line=second_line, count_on_line=True
     )
 
     start_crossed_1 = _check_crossing_in_frame_range(
@@ -522,7 +524,9 @@ def compute_intersecting_polygons(
 
 
 def compute_crossing_frames(
-    *, traj_data: TrajectoryData, measurement_line: MeasurementLine
+    *,
+    traj_data: TrajectoryData,
+    measurement_line: MeasurementLine,
 ) -> pd.DataFrame:
     """Compute the frames at the pedestrians pass the measurement line.
 
@@ -545,6 +549,35 @@ def compute_crossing_frames(
         traj_data (pandas.DataFrame): trajectory data
         measurement_line (MeasurementLine): measurement line which is crossed
 
+    Returns:
+        DataFrame containing the columns 'id', 'frame', where 'frame' is
+        the frame where the measurement line is crossed.
+    """
+    return _compute_crossing_frames(
+        traj_data=traj_data,
+        measurement_line=measurement_line,
+        count_on_line=False,
+    )
+
+
+def _compute_crossing_frames(
+    *,
+    traj_data: TrajectoryData,
+    measurement_line: MeasurementLine,
+    count_on_line: bool,
+) -> pd.DataFrame:
+    """Compute the frames at which pedestrians pass the measurement line.
+
+    If count_on_line is set to True, the crossing frame is the one where the
+    pedestrian touches the line. Otherwise, it is the frame where the
+    pedestrian crosses the line without stopping on it.
+
+    Args:
+        traj_data (pandas.DataFrame): trajectory data
+        measurement_line (MeasurementLine): measurement line which is crossed
+        count_on_line (bool): Count movement ending on line (True) or only if
+            movement crosses line, but does not end on line.
+
     Returns:
         DataFrame containing the columns 'id', 'frame', where 'frame' is
         the frame where the measurement line is crossed.
@@ -567,11 +600,29 @@ def compute_crossing_frames(
         )
     )
 
-    # crossing means, the current movement crosses the line and the end point
-    # of the movement is not on the line. The result is sorted by frame number
-    crossing_frames = df_movement.loc[
-        shapely.intersects(df_movement.movement, measurement_line.line)
-    ][[ID_COL, FRAME_COL]]
+    movement_crosses_line = shapely.intersects(
+        df_movement.movement, measurement_line.line
+    )
+
+    if count_on_line:
+        crossing_frames = df_movement.loc[movement_crosses_line][
+            [ID_COL, FRAME_COL]
+        ]
+    else:
+        # Case when crossing means movement crosses the line, but the end point
+        # is not on it
+
+        # Minimum distance to consider crossing complete
+        CROSSING_THRESHOLD: Final = 1e-5  # noqa: N806
+
+        movement_ends_on_line = (
+            shapely.distance(df_movement.end_position, measurement_line.line)
+            < CROSSING_THRESHOLD
+        )
+
+        crossing_frames = df_movement.loc[
+            (movement_crosses_line) & (~movement_ends_on_line)
+        ][[ID_COL, FRAME_COL]]
 
     return crossing_frames
 


Test patch:
diff --git a/tests/reference_tests/reference_test.py b/tests/reference_tests/reference_test.py
index 3b96769..c848c9c 100644
--- a/tests/reference_tests/reference_test.py
+++ b/tests/reference_tests/reference_test.py
@@ -527,6 +527,16 @@ def test_nt(line, folder):
         traj_data=trajectory,
         measurement_line=line,
     )
+
+    # In JPSreport crossing was counted when a pedestrians touches the
+    # measurement line the first time. In PedPy the crossing is counted
+    # when the movement crossed the line and does not end on it. Hence,
+    # some slight modifications in edge need to be done to adapt the
+    # reference results.
+    if folder.name == "corridor":
+        reference_result.loc[243, CUMULATED_COL] -= 1
+        reference_result.loc[3082, CUMULATED_COL] -= 1
+
     assert (reference_result.index.values == result.index.values).all()
     assert np.isclose(
         result[TIME_COL], reference_result[TIME_COL], atol=TOLERANCE
diff --git a/tests/unit_tests/methods/test_method_utils.py b/tests/unit_tests/methods/test_method_utils.py
new file mode 100644
index 0000000..7d023b2
--- /dev/null
+++ b/tests/unit_tests/methods/test_method_utils.py
@@ -0,0 +1,103 @@
+import numpy as np
+import pandas as pd
+import pytest
+import shapely
+
+from pedpy.data.geometry import MeasurementLine
+from pedpy.data.trajectory_data import TrajectoryData
+from pedpy.methods.method_utils import compute_crossing_frames
+
+
+def create_single_straight_trajectory_crossing_zero_line(
+    *, start_x=-10, frames_before, frames_on_line=0, end_x=10, frames_after
+):
+    x = np.concatenate(
+        [
+            np.linspace(
+                start_x, -0.1 if frames_on_line == 0 else 0, frames_before
+            ),
+            np.linspace(0, 0, frames_on_line - 1 if frames_on_line > 0 else 0),
+            np.linspace(0.1 if frames_on_line == 0 else 0, end_x, frames_after),
+        ]
+    )
+    y = np.zeros_like(x)
+
+    frames = np.arange(0, len(x))
+    id = np.ones_like(x)
+
+    df_traj_simple = pd.DataFrame({"id": id, "frame": frames, "x": x, "y": y})
+    return TrajectoryData(data=df_traj_simple, frame_rate=1)
+
+
+@pytest.mark.parametrize(
+    "frames_before, frames_after",
+    [
+        (10, 5),
+        (1, 10),
+    ],
+)
+def test_compute_crossing_frame_movement_across_line(
+    frames_before, frames_after
+):
+    traj = create_single_straight_trajectory_crossing_zero_line(
+        frames_before=frames_before, frames_after=frames_after
+    )
+    ml = MeasurementLine([(0, -0.5), (0, 0.5)])
+    crossing_frames = compute_crossing_frames(
+        traj_data=traj, measurement_line=ml
+    )
+
+    assert len(crossing_frames) == 1
+    assert crossing_frames.iloc[0].id == 1
+    assert crossing_frames.iloc[0].frame == frames_before
+
+
+@pytest.mark.parametrize(
+    "frames_before, frames_on_line",
+    [
+        (3, 1),
+        (10, 5),
+        (7, 10),
+        (0, 1),
+        (0, 10),
+    ],
+)
+def test_compute_crossing_frame_movement_stops_on_line(
+    frames_before, frames_on_line
+):
+    traj = create_single_straight_trajectory_crossing_zero_line(
+        frames_before=frames_before,
+        frames_after=5,
+        frames_on_line=frames_on_line,
+    )
+    ml = MeasurementLine([(0, -0.5), (0, 0.5)])
+
+    crossing_frames = compute_crossing_frames(
+        traj_data=traj, measurement_line=ml
+    )
+
+    assert len(crossing_frames) == 1
+    assert crossing_frames.iloc[0].id == 1
+    assert crossing_frames.iloc[0].frame == frames_before + frames_on_line
+
+
+@pytest.mark.parametrize(
+    "frames_on_line",
+    [
+        1,
+        5,
+        10,
+    ],
+)
+def test_compute_crossing_frame_trajectory_ends_on_line(frames_on_line):
+    traj = create_single_straight_trajectory_crossing_zero_line(
+        frames_before=10,
+        frames_after=0,
+        frames_on_line=frames_on_line,
+    )
+    ml = MeasurementLine([(0, -0.5), (0, 0.5)])
+    crossing_frames = compute_crossing_frames(
+        traj_data=traj, measurement_line=ml
+    )
+
+    assert len(crossing_frames) == 0