Adding Traits¶
This guide explains how to add new trait computations to sleap-roots, from initial implementation to testing and documentation.
Overview¶
Adding a new trait involves:
- Implementing the trait computation function
- Adding it to the appropriate module
- Writing comprehensive tests
- Documenting the trait
- Integrating it into pipelines
- Adding to trait reference
Trait Computation Principles¶
Design Philosophy¶
Good traits are: - Biologically meaningful: Captures relevant morphological feature - Reproducible: Same inputs always give same outputs - Well-documented: Clear description, units, and formula - Tested: Comprehensive unit and integration tests - Efficient: Vectorized when possible, reasonable performance
Avoid: - Overly complex computations without clear biological interpretation - Traits that are just combinations of existing traits (do this at analysis time) - Platform-specific or non-portable code
Trait Categories¶
sleap-roots organizes traits into modules:
lengths.py: Length-based measurementsangles.py: Angular measurementstips.py: Root tip-related traitsbases.py: Root base-related traitsconvhull.py: Convex hull and area metricsnetworklength.py: Network-level traitsmonocots.py: Monocot-specific traitstrait_pipelines.py: Complete pipeline implementations
Step-by-Step: Adding a New Trait¶
Step 1: Implement the Function¶
Create your trait computation function in the appropriate module.
Example: Adding a "root sinuosity" trait to lengths.py
# sleap_roots/lengths.py
import numpy as np
from typing import Union
def get_root_sinuosity(pts: np.ndarray) -> float:
"""
Compute root sinuosity (tortuosity index).
Sinuosity measures how much a root deviates from a straight line,
defined as the ratio of path length to Euclidean distance.
Args:
pts: Array of shape (n, 2) containing root coordinates from
base to tip.
Returns:
Sinuosity value. 1.0 indicates perfectly straight root,
higher values indicate more curved/tortuous roots.
Raises:
ValueError: If pts has fewer than 2 points.
Example:
>>> pts = np.array([[0, 0], [1, 0], [2, 0]]) # Straight line
>>> get_root_sinuosity(pts)
1.0
>>> pts = np.array([[0, 0], [1, 1], [2, 0]]) # Curved
>>> get_root_sinuosity(pts)
1.414...
Note:
- Value of 1.0 = perfectly straight
- Higher values = more tortuous/sinuous
- Sensitive to tracking noise; consider smoothing pts first
"""
if len(pts) < 2:
raise ValueError("Need at least 2 points to compute sinuosity")
# Path length: sum of segment lengths
segment_lengths = np.linalg.norm(np.diff(pts, axis=0), axis=1)
path_length = np.sum(segment_lengths)
# Euclidean distance: straight line from base to tip
euclidean_distance = np.linalg.norm(pts[-1] - pts[0])
# Handle edge case: coincident points
if euclidean_distance == 0:
return np.inf if path_length > 0 else 1.0
return path_length / euclidean_distance
Step 2: Write Tests¶
Create comprehensive tests in the corresponding test file.
# tests/test_lengths.py
import numpy as np
import pytest
from sleap_roots import lengths
class TestRootSinuosity:
"""Tests for get_root_sinuosity function."""
def test_straight_line(self):
"""Straight line should have sinuosity of 1.0."""
pts = np.array([[0, 0], [1, 0], [2, 0], [3, 0]])
sinuosity = lengths.get_root_sinuosity(pts)
assert np.isclose(sinuosity, 1.0)
def test_curved_path(self):
"""Curved path should have sinuosity > 1.0."""
pts = np.array([[0, 0], [1, 1], [2, 0]])
sinuosity = lengths.get_root_sinuosity(pts)
assert sinuosity > 1.0
def test_circular_path(self):
"""Circular/highly curved path should have high sinuosity."""
# Create semicircle
theta = np.linspace(0, np.pi, 50)
pts = np.column_stack([np.cos(theta), np.sin(theta)])
sinuosity = lengths.get_root_sinuosity(pts)
expected = np.pi / 2 # Semicircle path length / diameter
assert np.isclose(sinuosity, expected, rtol=0.1)
def test_minimum_points(self):
"""Should work with exactly 2 points."""
pts = np.array([[0, 0], [1, 1]])
sinuosity = lengths.get_root_sinuosity(pts)
assert np.isclose(sinuosity, 1.0)
def test_insufficient_points(self):
"""Should raise error with < 2 points."""
pts = np.array([[0, 0]])
with pytest.raises(ValueError, match="at least 2 points"):
lengths.get_root_sinuosity(pts)
def test_coincident_points(self):
"""Should handle case where base == tip."""
pts = np.array([[1, 1], [1.1, 1.05], [1, 1]]) # Returns to start
sinuosity = lengths.get_root_sinuosity(pts)
assert sinuosity == np.inf # Path length > 0 but Euclidean = 0
def test_single_point_repeated(self):
"""All same point should return 1.0."""
pts = np.array([[5, 5], [5, 5], [5, 5]])
sinuosity = lengths.get_root_sinuosity(pts)
assert sinuosity == 1.0
def test_numerical_precision(self):
"""Test with realistic noisy data."""
# Create slightly noisy straight line
np.random.seed(42)
x = np.linspace(0, 10, 100)
y = np.random.normal(0, 0.1, 100) # Small noise
pts = np.column_stack([x, y])
sinuosity = lengths.get_root_sinuosity(pts)
assert 1.0 <= sinuosity <= 1.1 # Should be close to 1.0
def test_different_scales(self):
"""Sinuosity should be scale-invariant."""
pts_small = np.array([[0, 0], [1, 1], [2, 0]])
pts_large = pts_small * 100
sin_small = lengths.get_root_sinuosity(pts_small)
sin_large = lengths.get_root_sinuosity(pts_large)
assert np.isclose(sin_small, sin_large)
def test_real_world_data(self, test_data_fixture):
"""Test with actual SLEAP tracking data."""
series = test_data_fixture
pts = series.primary_pts[0]
sinuosity = lengths.get_root_sinuosity(pts)
# Sanity checks for real data
assert 1.0 <= sinuosity <= 5.0 # Reasonable range
assert not np.isnan(sinuosity)
assert not np.isinf(sinuosity)
Step 3: Document the Trait¶
Add the trait to the trait reference documentation.
<!-- docs/guides/trait-reference.md -->
#### Root Sinuosity
**Module**: `sleap_roots.lengths.get_root_sinuosity`
**Description**: Measures root tortuosity as the ratio of actual path length
to straight-line distance from base to tip.
**Formula**:
$$
\\text{sinuosity} = \\frac{\\text{path length}}{\\text{Euclidean distance}}
$$
**Units**: Dimensionless ratio
**Range**: [1.0, ∞)
- 1.0 = perfectly straight root
- 1.5 = moderately curved
- 2.0+ = highly tortuous
**Biological Relevance**: Sinuosity reflects root growth patterns and
environmental responses. Higher sinuosity may indicate:
- Obstacle avoidance behavior
- Resource seeking (e.g., following nutrient patches)
- Response to soil compaction or impedance
- Gravitropic or thigmotropic responses
**Interpretation**:
- **Low sinuosity (1.0-1.2)**: Direct, efficient growth path
- **Medium sinuosity (1.2-1.8)**: Moderate curvature, normal exploration
- **High sinuosity (>1.8)**: Highly curved, possibly stress response
**Limitations**:
- Sensitive to tracking noise (consider smoothing)
- May be affected by frame rate/sampling density
- Less meaningful for very short roots
**Related Traits**:
- `primary_length`: Path length component
- `primary_euclidean_length`: Straight-line distance component
**Example**:
```python
import sleap_roots as sr
series = sr.Series.load(...)
pts = series.primary_pts[0]
sinuosity = sr.lengths.get_root_sinuosity(pts)
print(f"Root sinuosity: {sinuosity:.2f}")
### Step 4: Integrate into Pipelines
Add the new trait to relevant pipelines.
```python
# sleap_roots/trait_pipelines.py
class DicotPipeline:
"""Pipeline for dicot root systems."""
def compute_plant_traits(self, series, write_csv=False, csv_path=None):
"""Compute all dicot traits including new sinuosity trait."""
traits = {}
# Get root points
primary_pts = series.get_primary_root_points()
# Existing traits
traits['primary_length'] = lengths.get_root_lengths([primary_pts])[0]
# ... other traits ...
# NEW TRAIT: Add sinuosity
traits['primary_sinuosity'] = lengths.get_root_sinuosity(primary_pts)
# ... rest of pipeline ...
return pd.DataFrame([traits])
Step 5: Add to Trait Definitions¶
Update trait metadata for documentation generation.
# sleap_roots/trait_definitions.py (if exists)
TRAIT_DEFINITIONS = {
'primary_sinuosity': {
'name': 'Primary Root Sinuosity',
'description': 'Ratio of root path length to Euclidean distance',
'units': 'dimensionless',
'range': '[1.0, inf)',
'formula': 'path_length / euclidean_distance',
'module': 'sleap_roots.lengths',
'function': 'get_root_sinuosity',
'category': 'morphology',
'biological_relevance': 'Indicates root tortuosity and growth pattern'
}
}
Complex Example: Multi-Value Trait¶
Some traits return arrays or multiple values. Here's how to handle them:
# sleap_roots/angles.py
def get_lateral_emergence_angles(
primary_pts: np.ndarray,
lateral_pts_list: list[np.ndarray]
) -> tuple[np.ndarray, float, float]:
"""
Compute emergence angles for all lateral roots.
Args:
primary_pts: Primary root coordinates (n, 2)
lateral_pts_list: List of lateral root coordinate arrays
Returns:
Tuple of:
- angles: Array of emergence angles for each lateral (degrees)
- mean_angle: Mean emergence angle (degrees)
- angle_std: Standard deviation of angles (degrees)
Example:
>>> angles, mean, std = get_lateral_emergence_angles(primary, laterals)
>>> print(f"Mean emergence angle: {mean:.1f}° ± {std:.1f}°")
"""
angles = []
for lateral_pts in lateral_pts_list:
if len(lateral_pts) < 2:
continue
# Get base and direction points
base_pt = lateral_pts[0]
direction_pt = lateral_pts[min(5, len(lateral_pts)-1)]
# Compute angle relative to primary axis
lateral_vector = direction_pt - base_pt
primary_vector = primary_pts[-1] - primary_pts[0]
angle = compute_angle_between_vectors(lateral_vector, primary_vector)
angles.append(angle)
angles = np.array(angles)
mean_angle = np.mean(angles) if len(angles) > 0 else np.nan
angle_std = np.std(angles) if len(angles) > 0 else np.nan
return angles, mean_angle, angle_std
Using in pipeline:
# In pipeline
angles, mean_angle, angle_std = angles.get_lateral_emergence_angles(
primary_pts, lateral_pts_list
)
traits['lateral_emergence_angles'] = angles # Store array
traits['mean_emergence_angle'] = mean_angle # Store scalar
traits['emergence_angle_std'] = angle_std # Store scalar
Testing Strategy¶
Unit Tests¶
Test individual functions in isolation:
def test_basic_functionality():
"""Test with simple, known input."""
pts = np.array([[0, 0], [1, 0]])
result = my_trait_function(pts)
assert result == expected_value
def test_edge_cases():
"""Test edge cases."""
# Empty input
# Single point
# Coincident points
# Very large/small values
def test_error_handling():
"""Test that errors are raised appropriately."""
with pytest.raises(ValueError):
my_trait_function(invalid_input)
Integration Tests¶
Test traits within pipelines:
def test_trait_in_pipeline(test_series):
"""Test new trait integrates correctly in pipeline."""
pipeline = sr.DicotPipeline()
traits = pipeline.compute_plant_traits(test_series)
# Verify trait is computed
assert 'my_new_trait' in traits.columns
# Verify trait value is reasonable
value = traits['my_new_trait'].iloc[0]
assert not np.isnan(value)
assert 0 <= value <= 100 # Expected range
Performance Tests¶
For computationally intensive traits:
import time
def test_performance():
"""Ensure trait computation is reasonably fast."""
# Large but realistic input
pts = np.random.rand(1000, 2)
start = time.time()
result = my_trait_function(pts)
elapsed = time.time() - start
assert elapsed < 0.1 # Should complete in <100ms
Code Style Guidelines¶
Function Signatures¶
def my_trait_function(
pts: np.ndarray,
param1: float = 1.0,
param2: Optional[str] = None
) -> Union[float, np.ndarray]:
"""
One-line summary.
Detailed description explaining what the trait measures,
biological relevance, and any important caveats.
Args:
pts: Root coordinates (n, 2) from base to tip.
param1: Description of parameter 1 (default: 1.0).
param2: Optional description (default: None).
Returns:
Trait value(s) with units and expected range.
Raises:
ValueError: When and why this error occurs.
Example:
>>> pts = np.array([[0, 0], [1, 1]])
>>> result = my_trait_function(pts)
>>> print(result)
1.414
Note:
Any important implementation details or limitations.
"""
# Implementation
pass
Type Hints¶
Always use type hints:
from typing import Union, Optional, Tuple, List
import numpy as np
def process_roots(
primary_pts: np.ndarray,
lateral_pts_list: List[np.ndarray],
threshold: float = 0.5
) -> Tuple[float, int]:
"""Type hints make code self-documenting and enable static checking."""
pass
Docstring Style¶
Follow Google style docstrings:
def example_function(arg1: int, arg2: str = "default") -> bool:
"""
Short one-line summary ending with period.
Longer description providing context, explaining what the function
does, and any important details.
Args:
arg1: Description of arg1.
arg2: Description of arg2 (default: "default").
Returns:
Description of return value.
Raises:
ValueError: When arg1 is negative.
TypeError: When arg2 is not a string.
Example:
>>> result = example_function(5, "test")
>>> print(result)
True
"""
pass
Vectorization Tips¶
Vectorize computations for better performance:
Before (Slow)¶
def compute_lengths_slow(pts_list):
"""Iterative computation."""
lengths = []
for pts in pts_list:
length = 0
for i in range(len(pts) - 1):
segment = pts[i+1] - pts[i]
length += np.linalg.norm(segment)
lengths.append(length)
return np.array(lengths)
After (Fast)¶
def compute_lengths_fast(pts_list):
"""Vectorized computation."""
lengths = []
for pts in pts_list:
# Vectorized: compute all segments at once
segments = np.diff(pts, axis=0)
length = np.sum(np.linalg.norm(segments, axis=1))
lengths.append(length)
return np.array(lengths)
Contributing Your Trait¶
To contribute your new trait to sleap-roots:
- Fork the repository
- Create a feature branch:
git checkout -b add-sinuosity-trait - Implement trait with tests and documentation
- Run all tests:
pytest tests/ -v - Run code quality checks:
- Create pull request with:
- Clear description of the new trait
- Biological motivation
- Test results
- Documentation updates
Next Steps¶
- Review Testing Guide for comprehensive testing strategies
- See Code Style for style guidelines
- Read Architecture for module organization
- Check Contributing for PR process
- Explore Trait Reference for existing traits