Skip to content

feat: implement compute_path_sinuosity core logic (draft)#981

Draft
isha822 wants to merge 2 commits into
neuroinformatics-unit:mainfrom
isha822:feature/sinuosity
Draft

feat: implement compute_path_sinuosity core logic (draft)#981
isha822 wants to merge 2 commits into
neuroinformatics-unit:mainfrom
isha822:feature/sinuosity

Conversation

@isha822
Copy link
Copy Markdown
Contributor

@isha822 isha822 commented May 16, 2026

Opening this as a draft to get early eyes on the core logic and math implementation (Benhamou 2004).

Key implementation details:

  • Added min_step_length to propagate down to compute_turning_angle for noise filtering.
  • Reused internal _slice_and_validate and _warn_about_nan_proportion.
  • Added NaN safety for stationary paths to prevent division-by-zero.

I will start adding the pytest suite for this once the core mathematical approach looks good.

@sonarqubecloud
Copy link
Copy Markdown

Copy link
Copy Markdown
Member

@niksirbi niksirbi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @isha822.

I had a first look at this PR and left some comments that we should address first before proceeding.

from movement.kinematics.kinematics import (
compute_backward_displacement,
compute_forward_displacement,
compute_turning_angle,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compute_turning_angle is in movement.kinematics.orientation not in movement.kinematics.kinematics. You can either import from the correct specific submodule, or else import all 3 functions from the higher-level module movement.kinematics (both should work I think.)

Comment on lines +262 to +274
# Reuse the internal validation helper (checks for >= 2 points)
data = _slice_and_validate(data, start, stop, "path sinuosity")

# Sinuosity specifically needs 3 points for angles
n_time = data.sizes["time"]
if n_time < 3:
raise logger.error(
ValueError(
"At least 3 time points are required to compute sinuosity "
f"(got {n_time}). A minimum of two consecutive displacements "
"is needed to produce one turning angle."
)
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this is a good chance to modify _slice_and_validate so it accepts min_points kwarg (defaulting to 2). Then we could fold all this validation into one helper.

nan_policy: Literal["ffill", "scale"] = "ffill",
nan_warn_threshold: float = 0.2,
) -> xr.DataArray:
r"""Compute the sinuosity of a path (Benhamou 2004).
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re the docsting, I would add the following sections:

  • See Also: (linking to underlying function compute_turning_angle, and to the existing related metric compute_path_straightness)
  • Notes: containing the reference and the math equations, and also a short note about sinuosity having units of 1/sqrt(length), and therefore interpretation depends on position units.
  • Examples: similar to the other functions in this file (tackle only after the function signature is settled)

)

# --- Step lengths -------------------------------------------------------
step_lengths = compute_norm(compute_forward_displacement(data))
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

after this line I would add:

step_lengths = step_lengths.where(step_lengths > min_step_length)

The current implementation applies min_step_length assymetrically. Small steps are masked in turning_angles (so exlcuded from $\bar{c}$) but not in step_lengths (kept it $\bar{p}$ and $\bar{b}$).

Masking them in both makes sense, because the intention of min_step_length in compute_turning_angle is to exlude steps we consider as 'noise'.

Comment on lines +278 to +286
if nan_policy == "ffill":
data = data.ffill(dim="time")
elif nan_policy != "scale":
raise logger.error(
ValueError(
f"Invalid value for nan_policy: {nan_policy}. "
"Must be one of 'ffill' or 'scale'."
)
)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that these NaN policies (originally made for path length) make much sense here.
I would remove the nan_policy kwarg entitely, and just let NaNs propagate, but compute all the statistics with skipna=True. This effectively keeps the current behaviour of the 'scale' branch, but it's not worth calling it a 'policy' here.

I would keep nan_warn_threshold as a still useful sanity check. Optionally you can add a paragraph to 'Notes' like this:

NaN positions propagate to NaN step lengths and turning angles; the statistics are computed over the remaining valid samples.

With the above in mind, the argument order in the function signature should be:

data, start, stop, nan_warn_threshold, min_step_length

result = 2.0 * (p_bar_safe * (angle_term + b**2)) ** -0.5

result = xr.where(is_straight, 0.0, result)
result = xr.where(is_stationary, np.nan, result)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this final xr.where(is_stationary, np.nan, result) is redundant. p_bar_safe is already NaN where stationary, so it should propagate to the result anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants