Skip to content

Commit 3309d85

Browse files
authored
Implement configuration module and utility functions for reference spiral generation, including logger setup. Refactor velocity metrics calculations to improve clarity and add median metrics. Update tests to cover all velocity types and reflect changes in function names and expected values.
1 parent 1516fc8 commit 3309d85

8 files changed

Lines changed: 128 additions & 59 deletions

File tree

src/graphomotor/core/config.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Configuration module for Graphomotor."""
2+
3+
import logging
4+
5+
import numpy as np
6+
7+
8+
class _SpiralConfig:
9+
"""Configuration for the reference spiral generation."""
10+
11+
SPIRAL_CENTER_X = 50
12+
SPIRAL_CENTER_Y = 50
13+
SPIRAL_START_RADIUS = 0
14+
SPIRAL_GROWTH_RATE = 1.075
15+
SPIRAL_START_ANGLE = 0
16+
SPIRAL_END_ANGLE = 8 * np.pi
17+
SPIRAL_NUM_POINTS = 10000
18+
19+
20+
def get_logger() -> logging.Logger:
21+
"""Get the Graphomotor logger."""
22+
logger = logging.getLogger("graphomotor")
23+
if logger.hasHandlers():
24+
return logger
25+
logger.setLevel(logging.INFO)
26+
handler = logging.StreamHandler()
27+
formatter = logging.Formatter(
28+
"%(asctime)s - %(name)s - %(levelname)s - "
29+
"%(filename)s:%(lineno)s - %(funcName)s - %(message)s",
30+
)
31+
handler.setFormatter(formatter)
32+
logger.addHandler(handler)
33+
return logger

src/graphomotor/features/velocity.py

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from graphomotor.core import models
77

88

9-
def _get_velocity_metrics(velocity: np.ndarray, type_: str) -> dict[str, float]:
9+
def _get_velocity_statistics(velocity: np.ndarray, type_: str) -> dict[str, float]:
1010
"""Calculate velocity metrics for a given type of velocity.
1111
1212
Args:
@@ -19,6 +19,7 @@ def _get_velocity_metrics(velocity: np.ndarray, type_: str) -> dict[str, float]:
1919
"""
2020
return {
2121
f"{type_}_sum": np.sum(np.abs(velocity)),
22+
f"{type_}_median": np.median(np.abs(velocity)),
2223
f"{type_}_variation": stats.variation(velocity),
2324
f"{type_}_skewness": stats.skew(velocity),
2425
f"{type_}_kurtosis": stats.kurtosis(velocity),
@@ -34,18 +35,17 @@ def calculate_velocity_metrics(spiral: models.Spiral) -> dict[str, float]:
3435
1. Linear velocity: The magnitude of change of Euclidean distance in pixels
3536
per second. This is calculated as the square root of the sum of squares of
3637
the differences in x and y coordinates divided by the difference in time.
37-
2. Radial velocity: The magnitude of change of distance from center in pixels
38-
per second. Radius is calculated as the square root of the sum of squares of
39-
the x and y coordinates. The radial velocity is the change in radius divided
40-
by the change in time.
38+
2. Radial velocity: The magnitude of change of distance from center (radius) in
39+
pixels per second. Radius is calculated as the square root of the sum of
40+
squares of x and y coordinates.
4141
3. Angular velocity: The magnitude of change of angle in radians per second.
4242
Angle is calculated using the arctangent of y coordinates divided by x
43-
coordinates. It is assumed that the drawing starts in the first quadrant
44-
The angle is unwrapped to ensure continuity. The angular
45-
velocity is the change in angle divided by the change in time.
43+
coordinates, and then unwrapped to maintain continuity across the -π to π
44+
boundary.
4645
4746
For each velocity type, the following metrics are calculated:
48-
- Sum: Sum of absolute velocity values
47+
- Sum: Total absolute velocity over the entire drawing
48+
- Median: Median of absolute velocity values
4949
- Variation: Coefficient of variation
5050
- Skewness: Asymmetry of the velocity distribution
5151
- Kurtosis: Tailedness of the velocity distribution
@@ -68,20 +68,12 @@ def calculate_velocity_metrics(spiral: models.Spiral) -> dict[str, float]:
6868
dr = np.diff(radius)
6969
dtheta = np.diff(theta)
7070

71-
vx = dx / dt
72-
vy = dy / dt
73-
linear_velocity = np.sqrt(vx**2 + vy**2)
74-
71+
linear_velocity = np.sqrt(dx**2 + dy**2) / dt
7572
radial_velocity = dr / dt
7673
angular_velocity = dtheta / dt
7774

78-
linear_velocity_metrics = _get_velocity_metrics(linear_velocity, "linear_velocity")
79-
radial_velocity_metrics = _get_velocity_metrics(radial_velocity, "radial_velocity")
80-
angular_velocity_metrics = _get_velocity_metrics(
81-
angular_velocity, "angular_velocity"
82-
)
8375
return {
84-
**linear_velocity_metrics,
85-
**radial_velocity_metrics,
86-
**angular_velocity_metrics,
76+
**_get_velocity_statistics(linear_velocity, "linear_velocity"),
77+
**_get_velocity_statistics(radial_velocity, "radial_velocity"),
78+
**_get_velocity_statistics(angular_velocity, "angular_velocity"),
8779
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Utility functions for centering a spiral."""
2+
3+
from graphomotor.core import config, models
4+
5+
6+
def center_spiral(spiral: models.Spiral) -> models.Spiral:
7+
"""Center a spiral by translating it to the origin.
8+
9+
Args:
10+
spiral: Spiral object containing spiral data.
11+
12+
Returns:
13+
Spiral object with centered spiral data.
14+
"""
15+
spiral.data["x"] -= config._SpiralConfig.SPIRAL_CENTER_X
16+
spiral.data["y"] -= config._SpiralConfig.SPIRAL_CENTER_Y
17+
18+
return spiral

src/graphomotor/utils/reference_spiral.py renamed to src/graphomotor/utils/generate_reference_spiral.py

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
1-
"""Generate a reference spiral with equidistant points along its arc length."""
1+
"""Utility functions for generating an equidistant reference spiral."""
22

33
import numpy as np
44
from scipy import integrate, optimize
55

6-
_SPIRAL_CENTER_X = 50
7-
_SPIRAL_CENTER_Y = 50
8-
_SPIRAL_START_RADIUS = 0
9-
_SPIRAL_GROWTH_RATE = 1.075
10-
_SPIRAL_START_ANGLE = 0
11-
_SPIRAL_END_ANGLE = 8 * np.pi
12-
_SPIRAL_NUM_POINTS = 10000
6+
from graphomotor.core.config import _SpiralConfig
137

148

159
def _arc_length_integrand(t: float) -> float:
@@ -21,8 +15,8 @@ def _arc_length_integrand(t: float) -> float:
2115
Returns:
2216
Differential arc length value.
2317
"""
24-
r_t = _SPIRAL_START_RADIUS + _SPIRAL_GROWTH_RATE * t
25-
return np.sqrt(r_t**2 + _SPIRAL_GROWTH_RATE**2)
18+
r_t = _SpiralConfig.SPIRAL_START_RADIUS + _SpiralConfig.SPIRAL_GROWTH_RATE * t
19+
return np.sqrt(r_t**2 + _SpiralConfig.SPIRAL_GROWTH_RATE**2)
2620

2721

2822
def _calculate_arc_length(theta: float) -> float:
@@ -35,7 +29,7 @@ def _calculate_arc_length(theta: float) -> float:
3529
The arc length of the spiral from _SPIRAL_START_ANGLE to theta.
3630
"""
3731
return integrate.quad(
38-
lambda t: _arc_length_integrand(t), _SPIRAL_START_ANGLE, theta
32+
lambda t: _arc_length_integrand(t), _SpiralConfig.SPIRAL_START_ANGLE, theta
3933
)[0]
4034

4135

@@ -50,7 +44,7 @@ def _find_theta_for_arc_length(target_arc_length: float) -> float:
5044
"""
5145
solution = optimize.root_scalar(
5246
lambda theta: _calculate_arc_length(theta) - target_arc_length,
53-
bracket=[_SPIRAL_START_ANGLE, _SPIRAL_END_ANGLE],
47+
bracket=[_SpiralConfig.SPIRAL_START_ANGLE, _SpiralConfig.SPIRAL_END_ANGLE],
5448
)
5549
return solution.root
5650

@@ -86,14 +80,19 @@ def generate_reference_spiral() -> np.ndarray:
8680
Returns:
8781
Array with shape (N, 2) containing Cartesian coordinates of the spiral points.
8882
"""
89-
total_arc_length = _calculate_arc_length(_SPIRAL_END_ANGLE)
83+
total_arc_length = _calculate_arc_length(_SpiralConfig.SPIRAL_END_ANGLE)
9084

91-
arc_length_values = np.linspace(0, total_arc_length, _SPIRAL_NUM_POINTS)
85+
arc_length_values = np.linspace(
86+
0, total_arc_length, _SpiralConfig.SPIRAL_NUM_POINTS
87+
)
9288

9389
theta_values = np.array([_find_theta_for_arc_length(s) for s in arc_length_values])
9490

95-
r_values = _SPIRAL_START_RADIUS + _SPIRAL_GROWTH_RATE * theta_values
96-
x_values = _SPIRAL_CENTER_X + r_values * np.cos(theta_values)
97-
y_values = _SPIRAL_CENTER_Y + r_values * np.sin(theta_values)
91+
r_values = (
92+
_SpiralConfig.SPIRAL_START_RADIUS
93+
+ _SpiralConfig.SPIRAL_GROWTH_RATE * theta_values
94+
)
95+
x_values = _SpiralConfig.SPIRAL_CENTER_X + r_values * np.cos(theta_values)
96+
y_values = _SpiralConfig.SPIRAL_CENTER_Y + r_values * np.sin(theta_values)
9897

9998
return np.column_stack((x_values, y_values))

tests/conftest.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import pytest
99

1010
from graphomotor.core import models
11-
from graphomotor.utils import reference_spiral
11+
from graphomotor.utils import generate_reference_spiral
1212

1313

1414
@pytest.fixture
@@ -56,4 +56,4 @@ def valid_spiral(
5656
@pytest.fixture
5757
def ref_spiral() -> np.ndarray:
5858
"""Create a reference spiral for testing."""
59-
return reference_spiral.generate_reference_spiral()
59+
return generate_reference_spiral.generate_reference_spiral()

tests/unit/test_drawing_error.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ def test_calculate_area_under_curve(valid_spiral: models.Spiral) -> None:
2020
valid_spiral, np.column_stack((x, y2))
2121
)["area_under_curve"]
2222

23-
assert np.isclose(calculated_area, expected_area, rtol=1e-3)
23+
assert np.isclose(calculated_area, expected_area, atol=0, rtol=1e-3)

tests/unit/test_reference_spiral.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,36 @@
22

33
import numpy as np
44

5-
from graphomotor.utils import reference_spiral
5+
from graphomotor.core import config
6+
from graphomotor.utils import generate_reference_spiral
67

78

89
def test_generate_reference_spiral() -> None:
910
"""Test the generation of a reference spiral."""
10-
expected_mean_arc_length = reference_spiral._calculate_arc_length(
11-
reference_spiral._SPIRAL_END_ANGLE
12-
) / (reference_spiral._SPIRAL_NUM_POINTS - 1)
11+
expected_mean_arc_length = generate_reference_spiral._calculate_arc_length(
12+
config._SpiralConfig.SPIRAL_END_ANGLE
13+
) / (config._SpiralConfig.SPIRAL_NUM_POINTS - 1)
1314

14-
spiral = reference_spiral.generate_reference_spiral()
15+
spiral = generate_reference_spiral.generate_reference_spiral()
1516
arc_lengths = np.linalg.norm(spiral[1:] - spiral[:-1], axis=1)
1617
mean_arc_length = np.mean(arc_lengths)
1718

1819
assert isinstance(spiral, np.ndarray)
19-
assert spiral.shape == (reference_spiral._SPIRAL_NUM_POINTS, 2)
20+
assert spiral.shape == (config._SpiralConfig.SPIRAL_NUM_POINTS, 2)
2021
assert np.array_equal(
2122
spiral[0],
22-
[reference_spiral._SPIRAL_CENTER_X, reference_spiral._SPIRAL_CENTER_Y],
23+
[config._SpiralConfig.SPIRAL_CENTER_X, config._SpiralConfig.SPIRAL_CENTER_Y],
2324
)
2425
assert np.allclose(
2526
spiral[-1],
2627
[
27-
reference_spiral._SPIRAL_CENTER_X
28-
+ reference_spiral._SPIRAL_GROWTH_RATE * reference_spiral._SPIRAL_END_ANGLE,
29-
reference_spiral._SPIRAL_CENTER_Y,
28+
config._SpiralConfig.SPIRAL_CENTER_X
29+
+ config._SpiralConfig.SPIRAL_GROWTH_RATE
30+
* config._SpiralConfig.SPIRAL_END_ANGLE,
31+
config._SpiralConfig.SPIRAL_CENTER_Y,
3032
],
31-
atol=1e-8,
33+
atol=0,
34+
rtol=1e-8,
3235
)
33-
assert np.allclose(arc_lengths, mean_arc_length, rtol=1e-3)
34-
assert np.isclose(mean_arc_length, expected_mean_arc_length, rtol=1e-6)
36+
assert np.allclose(arc_lengths, mean_arc_length, atol=0, rtol=1e-3)
37+
assert np.isclose(mean_arc_length, expected_mean_arc_length, atol=0, rtol=1e-6)

tests/unit/test_velocity.py

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import numpy as np
44
import pandas as pd
55

6-
from graphomotor.core import models
6+
from graphomotor.core import config, models
77
from graphomotor.features import velocity
88

99

@@ -15,14 +15,38 @@ def test_calculate_velocity_metrics(valid_spiral: models.Spiral) -> None:
1515

1616
t = np.linspace(0, time_total, num_points, endpoint=False)
1717
theta = np.linspace(0, theta_end, num_points, endpoint=False)
18-
x = 50 + theta * np.cos(theta)
19-
y = 50 + theta * np.sin(theta)
18+
r = config._SpiralConfig.SPIRAL_GROWTH_RATE * theta
19+
x = config._SpiralConfig.SPIRAL_CENTER_X + r * np.cos(theta)
20+
y = config._SpiralConfig.SPIRAL_CENTER_Y + r * np.sin(theta)
2021

2122
data = pd.DataFrame({"x": x, "y": y, "seconds": t})
2223
valid_spiral.data = data
2324

24-
expected_angular_velocity_sum = (theta_end / time_total) * (num_points - 1)
25+
expected_angular_velocity_median = theta_end / time_total
26+
expected_radial_velocity_median = (
27+
config._SpiralConfig.SPIRAL_GROWTH_RATE * theta_end / time_total
28+
)
29+
expected_linear_velocity_median = np.median(
30+
np.sqrt(np.gradient(x, t) ** 2 + np.gradient(y, t) ** 2)
31+
)
2532

2633
metrics = velocity.calculate_velocity_metrics(valid_spiral)
2734

28-
assert metrics["angular_velocity_sum"] == expected_angular_velocity_sum
35+
assert np.isclose(
36+
metrics["angular_velocity_median"],
37+
expected_angular_velocity_median,
38+
atol=0,
39+
rtol=1e-14,
40+
)
41+
assert np.isclose(
42+
metrics["radial_velocity_median"],
43+
expected_radial_velocity_median,
44+
atol=0,
45+
rtol=1e-13,
46+
)
47+
assert np.isclose(
48+
metrics["linear_velocity_median"],
49+
expected_linear_velocity_median,
50+
atol=0,
51+
rtol=1e-4,
52+
)

0 commit comments

Comments
 (0)