Skip to content

Commit 393ca0f

Browse files
edenoclaude
andcommitted
Prepare v2.4.0 release with type fixes and CHANGELOG
- Fix mypy type errors across all source modules - Add proper type annotations to track_builders interactive functions - Fix numpy array return type issues with type: ignore comments - Update edge list/tuple conversion to maintain type safety - Add TypedDict for TrackBuilderState - Update CHANGELOG.md with comprehensive v2.4.0 release notes - All 117 tests passing - Clean mypy check on all source files 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent c539046 commit 393ca0f

File tree

5 files changed

+106
-53
lines changed

5 files changed

+106
-53
lines changed

CHANGELOG.md

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [2.4.0] - 2025-01-16
11+
1012
### Added
11-
- Pre-commit hooks configuration for automated code quality checks
12-
- Comprehensive CI/CD pipeline with quality, test, build, and publish jobs
13-
- Support for Python 3.13
14-
- PyPI Trusted Publishing (OIDC) support for secure releases
15-
- Automated GitHub release creation with changelog extraction
16-
- Notebook execution testing in CI
17-
- Modern dependency pinning with lower bounds following Scientific Python SPEC 0
18-
- Enhanced ruff configuration with numpy-specific and pandas-vet rules
19-
- Improved mypy configuration with test-specific overrides
20-
- pandas-stubs for better type checking
13+
- **Track Builders Module** (`track_builders.py`):
14+
- `make_linear_track()` - Create simple linear tracks
15+
- `make_circular_track()` - Create circular/annular tracks
16+
- `make_tmaze_track()` - Create T-maze tracks for alternation tasks
17+
- `make_plus_maze_track()` - Create plus/cross maze tracks
18+
- `make_figure8_track()` - Create figure-8 tracks
19+
- `make_wtrack()` - Create W-shaped tracks
20+
- `make_rectangular_track()` - Create rectangular perimeter tracks
21+
- `make_ymaze_track()` - Create Y-maze tracks with configurable angles
22+
- `make_track_from_points()` - Create tracks from manual point specification
23+
- `make_track_from_image_interactive()` - Interactive track builder from images (Jupyter-compatible)
24+
- `_build_track_from_state()` - Helper for retrieving interactive builder results in Jupyter
25+
26+
- **Validation & QC Module** (`validation.py`):
27+
- `check_track_graph_validity()` - Validate track graph structure and attributes
28+
- `get_projection_confidence()` - Calculate confidence scores for position projections
29+
- `detect_linearization_outliers()` - Detect outliers using projection distance and jump detection
30+
- `validate_linearization()` - Comprehensive quality assessment with scoring and recommendations
31+
32+
- **Tutorial Notebooks**:
33+
- `track_linearization_tutorial.ipynb` - Comprehensive pedagogical tutorial for basic usage
34+
- `advanced_features_tutorial.ipynb` - Tutorial covering track builders, validation, and interactive features
35+
36+
- **Core Functionality**:
37+
- `project_1d_to_2d()` - Reverse mapping from 1D linear positions back to 2D coordinates
38+
- Numba-optimized Viterbi algorithm for HMM inference (when numba available)
39+
- Exposed `project_1d_to_2d` in package `__init__.py`
40+
41+
- **Infrastructure**:
42+
- Pre-commit hooks configuration for automated code quality checks
43+
- Comprehensive CI/CD pipeline with quality, test, build, and publish jobs
44+
- Support for Python 3.13
45+
- PyPI Trusted Publishing (OIDC) support for secure releases
46+
- Automated GitHub release creation with changelog extraction
47+
- Notebook execution testing in CI
48+
- Modern dependency pinning with lower bounds following Scientific Python SPEC 0
49+
- Enhanced ruff configuration with numpy-specific and pandas-vet rules
50+
- Improved mypy configuration with test-specific overrides
51+
- pandas-stubs for better type checking
52+
- Comprehensive test suite for track builders and validation (117 tests total)
2153

2254
### Changed
2355
- **BREAKING**: Dropped support for Python 3.9 (minimum version now 3.10)
@@ -27,15 +59,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2759
- matplotlib >= 3.7 (was unpinned)
2860
- pandas >= 2.0 (was unpinned)
2961
- dask[array] >= 2023.5.0 (was unpinned, added array extra)
62+
- networkx >= 3.2.1 (explicit minimum for compatibility)
3063
- Replaced deprecated pandas `.values` with `.to_numpy()` in utils module
3164
- Updated CI workflow to test Python 3.10, 3.11, 3.12, and 3.13
3265
- Replaced old test_package_build.yml with modern release.yml workflow
3366
- Fixed non-breaking hyphen character in error message
67+
- Enhanced `__init__.py` with comprehensive module docstring
68+
- Improved README with new features section and tutorial links
69+
- Interactive track builder uses two-step workflow in Jupyter (non-blocking)
70+
- Outlier detection uses robust statistics (median + MAD) instead of mean + std
3471

3572
### Fixed
3673
- Unused `fig` variables in plotting functions (now use `_` prefix)
3774
- Import sorting and organization per ruff standards
3875
- Regex pattern in pytest match statement (now uses raw string)
76+
- Dict literal style issues in track_builders.py (using `{}` instead of `dict()`)
77+
- Outlier detection false positives on uniform data
78+
- Interactive builder event loop blocking in Jupyter notebooks
3979

4080
### Infrastructure
4181
- New GitHub Actions workflow structure:
@@ -98,7 +138,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
98138
- HMM-based position classification
99139
- Visualization tools
100140

101-
[Unreleased]: https://github.com/LorenFrankLab/track_linearization/compare/v2.3.2...HEAD
141+
[Unreleased]: https://github.com/LorenFrankLab/track_linearization/compare/v2.4.0...HEAD
142+
[2.4.0]: https://github.com/LorenFrankLab/track_linearization/compare/v2.3.2...v2.4.0
102143
[2.3.2]: https://github.com/LorenFrankLab/track_linearization/compare/v2.3.1...v2.3.2
103144
[2.3.1]: https://github.com/LorenFrankLab/track_linearization/compare/v2.3.0...v2.3.1
104145
[2.3.0]: https://github.com/LorenFrankLab/track_linearization/compare/v2.2.0...v2.3.0

src/track_linearization/core.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ def project_points_to_segment(
187187

188188
np.clip(nx_param, 0.0, 1.0, out=nx_param)
189189

190-
return node1[np.newaxis, ...] + (
190+
return node1[np.newaxis, ...] + ( # type: ignore[no-any-return]
191191
nx_param[:, :, np.newaxis] * segment_diff[np.newaxis, ...]
192192
)
193193

@@ -209,7 +209,7 @@ def find_projected_point_distance(
209209
distances : np.ndarray, shape (n_time, n_segments)
210210
Euclidean distance from each point to its projection on each segment.
211211
"""
212-
return np.linalg.norm(
212+
return np.linalg.norm( # type: ignore[no-any-return]
213213
position[:, np.newaxis, :]
214214
- project_points_to_segment(track_segments, position),
215215
axis=2,
@@ -235,7 +235,7 @@ def find_nearest_segment(
235235
Index of the nearest track segment for each time point.
236236
"""
237237
distance = find_projected_point_distance(track_segments, position)
238-
return np.argmin(distance, axis=1)
238+
return np.argmin(distance, axis=1) # type: ignore[no-any-return]
239239

240240

241241
def euclidean_distance_change(position: np.ndarray) -> np.ndarray:
@@ -333,7 +333,7 @@ def route_distance(
333333

334334
track_graph.remove_nodes_from(node_names) # Clean up graph
335335

336-
return dist_matrix_slice
336+
return dist_matrix_slice # type: ignore[no-any-return]
337337

338338

339339
def batch(n_samples: int, batch_size: int = 1) -> Iterator[range]:
@@ -355,7 +355,7 @@ def batch(n_samples: int, batch_size: int = 1) -> Iterator[range]:
355355
yield range(ind, min(ind + batch_size, n_samples))
356356

357357

358-
@dask.delayed
358+
@dask.delayed # type: ignore[misc]
359359
def batch_route_distance(
360360
track_graph: "Graph",
361361
projected_track_position_t: np.ndarray,
@@ -414,7 +414,7 @@ def route_distance_change(position: np.ndarray, track_graph: Graph) -> np.ndarra
414414
projected_track_position = project_points_to_segment(track_segments, position)
415415
n_segments = len(track_segments)
416416

417-
all_distances_results: list[np.ndarray | dask.delayed.Delayed] = [
417+
all_distances_results: list[np.ndarray | Any] = [
418418
np.full((1, n_segments, n_segments), np.nan)
419419
]
420420

@@ -462,7 +462,7 @@ def calculate_position_likelihood(
462462
projected_position_distance = find_projected_point_distance(
463463
track_segments, position
464464
)
465-
return np.exp(-0.5 * (projected_position_distance / sigma) ** 2) / (
465+
return np.exp(-0.5 * (projected_position_distance / sigma) ** 2) / ( # type: ignore[no-any-return]
466466
np.sqrt(2 * np.pi) * sigma
467467
)
468468

@@ -484,7 +484,7 @@ def normalize_to_probability(x: np.ndarray, axis: int = -1) -> np.ndarray:
484484
If a sum along the axis is 0, the original values in that slice will
485485
result in NaNs or Infs after division.
486486
"""
487-
return x / x.sum(axis=axis, keepdims=True)
487+
return x / x.sum(axis=axis, keepdims=True) # type: ignore[no-any-return]
488488

489489

490490
def calculate_empirical_state_transition(
@@ -607,7 +607,7 @@ def viterbi_no_numba(
607607

608608
if NUMBA_AVAILABLE:
609609

610-
@numba.njit(cache=True)
610+
@numba.njit(cache=True) # type: ignore[misc]
611611
def viterbi(
612612
initial_conditions: np.ndarray,
613613
state_transition: np.ndarray,
@@ -980,21 +980,21 @@ def _calculate_linear_position(
980980
edge_spacing_list = _normalize_edge_spacing(edge_spacing, len(edge_order))
981981

982982
counter = 0.0
983-
start_node_linear_position = []
983+
start_node_linear_position_list: list[float] = []
984984

985985
for ind, edge in enumerate(edge_order):
986-
start_node_linear_position.append(counter)
986+
start_node_linear_position_list.append(counter)
987987

988988
try:
989989
counter += track_graph.edges[edge]["distance"] + edge_spacing_list[ind]
990990
except IndexError:
991991
pass
992992

993-
start_node_linear_position = np.asarray(start_node_linear_position)
993+
start_node_linear_position = np.asarray(start_node_linear_position_list)
994994

995995
track_segment_id_to_start_node_linear_position = {
996996
track_graph.edges[e]["edge_id"]: snlp
997-
for e, snlp in zip(edge_order, start_node_linear_position, strict=True)
997+
for e, snlp in zip(edge_order, start_node_linear_position_list, strict=True)
998998
}
999999

10001000
start_node_linear_position = np.asarray(
@@ -1301,4 +1301,4 @@ def project_1d_to_2d(
13011301

13021302
# propagate NaNs from the input
13031303
coords[nan_mask] = np.nan
1304-
return coords
1304+
return coords # type: ignore[no-any-return]

src/track_linearization/track_builders.py

Lines changed: 33 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"""
66

77
from pathlib import Path
8-
from typing import Any
8+
from typing import Any, TypedDict
99

1010
import matplotlib.pyplot as plt
1111
import networkx as nx
@@ -14,6 +14,18 @@
1414
from track_linearization.utils import make_track_graph
1515

1616

17+
class TrackBuilderState(TypedDict):
18+
"""State dictionary for interactive track builder."""
19+
20+
nodes: list[tuple[float, float]]
21+
edges: list[tuple[int, int]]
22+
mode: str
23+
edge_start_node: int | None
24+
edge_preview_line: Any | None
25+
finished: bool
26+
cancelled: bool
27+
28+
1729
def make_linear_track(
1830
length: float, start_pos: tuple[float, float] = (0, 0)
1931
) -> nx.Graph:
@@ -584,7 +596,7 @@ def make_track_from_image_interactive(
584596
is_jupyter = False
585597

586598
# State variables
587-
state = {
599+
state: TrackBuilderState = {
588600
"nodes": [], # List of (x, y) pixel coordinates
589601
"edges": [], # List of (i, j) node index pairs
590602
"edge_start_node": None, # For edge creation
@@ -598,7 +610,7 @@ def make_track_from_image_interactive(
598610
fig = plt.figure(figsize=(12, 11))
599611

600612
# Main image axes
601-
ax = fig.add_axes([0.05, 0.15, 0.9, 0.8])
613+
ax = fig.add_axes((0.05, 0.15, 0.9, 0.8))
602614
ax.imshow(img, origin="upper")
603615

604616
# Title with mode indicator
@@ -629,18 +641,18 @@ def make_track_from_image_interactive(
629641
if not is_jupyter:
630642
from matplotlib.widgets import Button
631643

632-
ax_finish = fig.add_axes([0.7, 0.02, 0.1, 0.05])
644+
ax_finish = fig.add_axes((0.7, 0.02, 0.1, 0.05))
633645
btn_finish = Button(ax_finish, "Finish", color="lightgreen", hovercolor="green")
634646

635-
ax_cancel = fig.add_axes([0.82, 0.02, 0.1, 0.05])
647+
ax_cancel = fig.add_axes((0.82, 0.02, 0.1, 0.05))
636648
btn_cancel = Button(ax_cancel, "Cancel", color="lightcoral", hovercolor="red")
637649

638-
def on_finish_click(event):
650+
def on_finish_click(event: Any) -> None:
639651
state["finished"] = True
640652
print("\n✓ Finished! (via button) Creating track graph...")
641653
plt.close(fig)
642654

643-
def on_cancel_click(event):
655+
def on_cancel_click(event: Any) -> None:
644656
state["cancelled"] = True
645657
print("\n✗ Cancelled (via button).")
646658
plt.close(fig)
@@ -652,10 +664,10 @@ def on_cancel_click(event):
652664
node_scatter = ax.scatter(
653665
[], [], c="red", s=100, zorder=10, marker="o", edgecolors="white", linewidths=2
654666
)
655-
node_labels = []
656-
edge_lines = []
667+
node_labels: list[Any] = []
668+
edge_lines: list[Any] = []
657669

658-
def update_mode_display():
670+
def update_mode_display() -> None:
659671
"""Update mode indicator text and color."""
660672
if state["mode"] == "ADD":
661673
mode_text.set_text("Mode: ADD NODE (Click anywhere)")
@@ -673,7 +685,7 @@ def update_mode_display():
673685
{"boxstyle": "round,pad=0.5", "facecolor": "salmon", "alpha": 0.7}
674686
)
675687

676-
def update_display():
688+
def update_display() -> None:
677689
"""Redraw nodes and edges."""
678690
# Update nodes
679691
if state["nodes"]:
@@ -731,7 +743,7 @@ def update_display():
731743
update_mode_display()
732744
fig.canvas.draw_idle()
733745

734-
def find_closest_node(x, y, max_distance=25):
746+
def find_closest_node(x: float, y: float, max_distance: float = 25) -> int | None:
735747
"""Find closest node to coordinates within max_distance pixels."""
736748
if not state["nodes"]:
737749
return None
@@ -743,7 +755,7 @@ def find_closest_node(x, y, max_distance=25):
743755
return closest_idx
744756
return None
745757

746-
def on_press(event):
758+
def on_press(event: Any) -> None:
747759
"""Handle mouse press."""
748760
if event.inaxes != ax:
749761
return
@@ -783,10 +795,10 @@ def on_press(event):
783795
for i, j in state["edges"]
784796
if i != closest and j != closest
785797
]
786-
state["edges"] = [tuple(sorted([i, j])) for i, j in state["edges"]]
798+
state["edges"] = [(min(i, j), max(i, j)) for i, j in state["edges"]]
787799
update_display()
788800

789-
def on_motion(event):
801+
def on_motion(event: Any) -> None:
790802
"""Handle mouse motion for edge preview."""
791803
# Note: Don't print debug here - too spammy during mouse movement
792804
if event.inaxes != ax or state["finished"] or state["cancelled"]:
@@ -809,7 +821,7 @@ def on_motion(event):
809821
state["edge_preview_line"] = line
810822
fig.canvas.draw_idle()
811823

812-
def on_release(event):
824+
def on_release(event: Any) -> None:
813825
"""Handle mouse release."""
814826
if event.inaxes != ax:
815827
return
@@ -834,7 +846,7 @@ def on_release(event):
834846
if closest is not None and closest != state["edge_start_node"]:
835847
# Create edge
836848
node1, node2 = state["edge_start_node"], closest
837-
edge = tuple(sorted([node1, node2]))
849+
edge = (min(node1, node2), max(node1, node2))
838850
if edge not in state["edges"]:
839851
state["edges"].append(edge)
840852
print(f"✓ Created edge: {node1}{node2}")
@@ -848,7 +860,7 @@ def on_release(event):
848860
state["edge_preview_line"] = None
849861
update_display()
850862

851-
def on_key(event):
863+
def on_key(event: Any) -> None:
852864
"""Handle key presses."""
853865

854866
if event.key == "f": # Finish
@@ -894,7 +906,7 @@ def on_key(event):
894906
print(f"↶ Deleted last edge {edge}")
895907
update_display()
896908

897-
def print_instructions():
909+
def print_instructions() -> None:
898910
"""Print usage instructions."""
899911
print("\n" + "=" * 70)
900912
print("INTERACTIVE TRACK BUILDER - CONTROLS")
@@ -960,7 +972,7 @@ def print_instructions():
960972
# JUPYTER MODE: Non-blocking approach
961973
# The widget is already interactive, just display it and return immediately
962974
# Store state in figure so user can retrieve it later
963-
fig._track_builder_state = state
975+
fig._track_builder_state = state # type: ignore[attr-defined]
964976

965977
plt.show()
966978
print()
@@ -1017,7 +1029,7 @@ def print_instructions():
10171029
return _build_track_from_state(state, scale)
10181030

10191031

1020-
def _build_track_from_state(state, scale=1.0):
1032+
def _build_track_from_state(state: TrackBuilderState | dict[str, Any], scale: float = 1.0) -> dict[str, Any]:
10211033
"""
10221034
Helper function to build track graph from interactive builder state.
10231035

0 commit comments

Comments
 (0)