Skip to content

Add inject_points for projecting external points onto the network#305

Open
jg-codes wants to merge 7 commits into
uscuni:mainfrom
jg-codes:feat/inject-points
Open

Add inject_points for projecting external points onto the network#305
jg-codes wants to merge 7 commits into
uscuni:mainfrom
jg-codes:feat/inject-points

Conversation

@jg-codes

@jg-codes jg-codes commented Jun 15, 2026

Copy link
Copy Markdown

Summary

This PR adds inject_points, a focused helper for projecting external Point features onto the nearest network LineString and splitting at the projected location.

It follows the scoped discussion in #304: this PR includes only inject_points, its public export, and the API reference entry. The separate node-attribute attachment helper is intentionally left out for a future discussion.

What Changed

  • Add neatnet.inject_points
  • Add tests for on-line and near-line projection, snap_radius filtering, CRS reprojection, empty inputs, and multiple points on one edge
  • Add inject_points to the API reference

Validation

  • git diff --check upstream/main...HEAD passes
  • Local focused pytest execution was blocked by missing libpysal in this local environment; GitHub Actions should run the project environment

AI assistance disclosure: Prepared with AI assistance; final scope, structure, and review decisions were made by JG.

@martinfleis martinfleis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A first pass without checking the tests yet.

Out of curiosity, what happens on your real use case when you use directly split with eps equal to what you use as snap_radius here? I have a feeling that you might actually get the same outcome as with inject_points.

Comment thread neatnet/nodes.py Outdated
Comment on lines +390 to +393
if streets.crs is None or points.crs is None:
raise ValueError("Both streets and points must have a CRS set.")
if points.crs != streets.crs:
points = points.to_crs(streets.crs)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would do this here as we do in neatify - check and if it does not match raise. Let users fix it, rather than implicitly re-project.

Comment thread neatnet/nodes.py Outdated
Comment on lines +401 to +402
input_idx, line_idx = streets.sindex.nearest(point_geoms, return_all=False)
distances = shapely.distance(line_geoms[line_idx], point_geoms[input_idx])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

sindex.nearest can directly return distances - see docs

@codecov

codecov Bot commented Jun 15, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.8%. Comparing base (661c164) to head (a57c0b4).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##            main    #305     +/-   ##
=======================================
- Coverage   99.1%   98.8%   -0.2%     
=======================================
  Files          7       7             
  Lines       1277    1299     +22     
=======================================
+ Hits        1265    1284     +19     
- Misses        12      15      +3     
Files with missing lines Coverage Δ
neatnet/__init__.py 100.0% <ø> (ø)
neatnet/nodes.py 99.0% <100.0%> (+0.1%) ⬆️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

- Raise on CRS mismatch instead of implicitly reprojecting, matching the
  behaviour of `neatify` and letting users fix mismatched inputs explicitly
  (review: check-and-raise rather than silent reprojection).
- Replace the separate `shapely.distance` pass with
  `sindex.nearest(..., return_distance=True)`, which returns the
  point-to-line distances directly (one geometry pass instead of two).
- Run ruff-format on the CRS test (fixes the failing pre-commit.ci check).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jg-codes

Copy link
Copy Markdown
Author

A first pass without checking the tests yet.

Out of curiosity, what happens on your real use case when you use directly split with eps equal to what you use as snap_radius here? I have a feeling that you might actually get the same outcome as with inject_points.

Thanks. Both changes are pushed.

On split(eps=snap_radius): I ran it on my actual data, the full German federal waterway network (VerkNet, ~3,000 segments) with RIS locks, ports and bridges as the points. The first figure is the offset of each feature from the nearest fairway centreline.

germany_poi_offset_ecdf

For bridges (median 2 m) and locks (median 1 m) you're right: at any sensible eps, split and inject_points give the same node. The difference is ports. They sit in harbour basins, median 120 m off the fairway, with almost none within 5 m. There split(eps) drags the fairway out to the port and leaves a large kink, while inject_points projects to the foot and keeps the line straight.

germany_split_vs_inject_geo

A perpendicular foot may not be quite right for a port either, since a port connects through its harbour entrance and logistics activities can be on the piers and elsewhere, not a straight drop onto the main line. For those you may need a connector edge, which I kept out of scope.

Does that scoping match your expectation?

…ions

Clarify that inject_points projects to the foot of the perpendicular and
adds a node *on* the existing geometry (no kink), unlike split() which
snaps the line to the point; the two coincide only when the point already
lies on the line. Narrow the stated scope to near-line features and
document the limitations raised in review: point attributes are not
carried over, the projection clamps to endpoints (which can be a no-op),
and a genuinely off-network point needs a connector edge rather than
inject_points.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@martinfleis

Copy link
Copy Markdown
Contributor

Ah, great! Thanks a lot. We should make sure this comes clear through the docs, both docstrings of both functions and in the user guide notebook. The fact that split forces line to go through the point while inject_points keeps the original linework, just subdivided.

jg-codes and others added 2 commits June 19, 2026 18:27
…docstrings

Per review: make the distinction explicit in both functions' docstrings.
`split` now states that snapping forces the line through an off-line point
(a kink) and points to `inject_points`; `inject_points` notes it keeps the
original linework, just subdivided.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Show on a small example that split snaps the line through an off-line point
(a kink) while inject_points projects the point onto the line and subdivides
it, keeping the original geometry. Addresses the review request to make the
distinction clear in the user guide notebook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jg-codes

Copy link
Copy Markdown
Author

Ah, great! Thanks a lot. We should make sure this comes clear through the docs, both docstrings of both functions and in the user guide notebook. The fact that split forces line to go through the point while inject_points keeps the original linework, just subdivided.

I've added the distinction to the docs. Let me know if anything else comes up your mind.

Comment thread neatnet/nodes.py
"""Split lines on new nodes.
"""Split lines at the given points, snapping each line through the point.

Each point is snapped onto the nearest line within ``eps`` (via

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Line is snapped onto point, not point onto line, no?

Comment thread neatnet/nodes.py
Each input point within ``snap_radius`` of the network is projected onto
its *nearest* LineString in ``streets`` -- at the foot of the
perpendicular, via linear referencing -- and that line is split at the
projected location. The new node therefore lands exactly *on* the original

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not exactly as we're snapping coordinates into a floating point precision grid but almost exactly. I would avoid using the word exactly here as the original and new lines will not overlap 100%.

Comment thread neatnet/nodes.py

# Nearest line per input point, with distance (sindex-accelerated)
(input_idx, line_idx), distances = streets.sindex.nearest(
point_geoms, return_all=False, return_distance=True

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If the snap_radius is set, we could pass it here as max_distance to speed this up.

Comment thread neatnet/nodes.py
Point features to project onto ``streets``. Must share the same CRS as
``streets`` (a mismatch raises rather than reprojecting implicitly).
Only the geometries are used; point attributes are not carried over.
snap_radius : float | None = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Not sure if snap_radius is the right name as we're technically not snapping. Maybe keep just radius?

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