Skip to content

Compute centroid and signed area relative to a local origin - #380

Open
binggao1230 wants to merge 3 commits into
cleder:developfrom
binggao1230:fix-centroid-precision-far-from-origin
Open

Compute centroid and signed area relative to a local origin#380
binggao1230 wants to merge 3 commits into
cleder:developfrom
binggao1230:fix-centroid-precision-far-from-origin

Conversation

@binggao1230

@binggao1230 binggao1230 commented Aug 6, 2026

Copy link
Copy Markdown

Both shoelace sums in functions.py accumulate in absolute coordinates. The per-edge terms grow with the square of the coordinate magnitude while their sum only grows with the area, so for a ring that sits far from the origin the significant digits cancel away. centroid() is hit hardest because it multiplies before it subtracts:

area = (coord[0] * next_coord[1]) - (next_coord[0] * coord[1])

A 10x10 square moved to x = y = 1e10:

>>> from pygeoif.geometry import LinearRing
>>> o = 1e10
>>> ring = LinearRing([(o, o), (o + 10, o), (o + 10, o + 10), (o, o + 10)])
>>> ring.centroid
None                                    # expected POINT (10000000005.0 10000000005.0)
>>> from pygeoif.functions import centroid
>>> centroid(ring.coords)[0]
(6646321618.666667, 6646321618.666667)  # 3.4e9 off

Two different symptoms, both from the same sum. Either the coordinates are wrong and nothing says so, or centroid()'s internal area drifts far enough from signed_area() that the math.isclose check in LinearRing.centroid rejects a perfectly valid ring. The check is not a safety net for the coordinates: at an offset of 1e8 the two areas still agree exactly at 100.0 while the centroid is already off by 0.04.

The threshold depends on how thin the ring is, not only on the offset. A 10 m long, 1 mm thick ring — a wall in a metre-based projected system — loses its centroid at an offset of 1e4:

>>> o = 10_000.0
>>> LinearRing([(o, o), (o + 10, o), (o + 10, o + 0.001), (o, o + 0.001)]).centroid
None                                    # expected POINT (10005.0 10000.0005)

Fix: subtract the first vertex from every coordinate before accumulating, so the terms stay the size of the ring, and shift the centroid back at the end. Areas are translation invariant so they need no correction. shapely, on GEOS, returns the exact value for every case above.

Extent of the problem

I checked the whole of functions.py rather than just the reported case, against an exact fractions.Fraction evaluation over the same vertices, on a grid of {square, triangle, L-shape, 1 mm sliver, 24-gon} x {shift x, shift y, shift both} x offsets 1e0..1e15:

  • centroid() — wrong in every shape/direction combination. Included.
  • signed_area() — same accumulation, but it differences the y coordinates before multiplying, so it degrades much more slowly: relative error 9e-5 on the 24-gon at an offset of 1e13, 3e-4 at 1e14. Small, but it is the same bug and the same one-line fix, so it is in. I also confirmed by exact evaluation that its formula is mathematically identical to the standard one, i.e. only the rounding was at fault. After the fix it is exact on the whole grid.
  • _orientation() / convex_hull() — already difference before multiplying and the magnitudes never cancel. Zero sign errors across the entire grid, so left alone. The only degeneracies are at 1e14+ where the 1 mm sliver is no longer representable at all, which no arithmetic can fix.
  • move_coordinate(s), Bounds — no products, unaffected.

Two claims I could not support and want to flag rather than have you find them: this is not a problem for ordinary Web-Mercator or UTM data. A 20x12 m footprint at EPSG:3857 (1489200, 6894000) comes out exact. What does break is large false eastings, millimetre-based CAD/IFC coordinates, and thin rings, which is what the tests use.

Tests

  • test_centroid_far_from_the_origin, test_centroid_is_translation_invariant, test_signed_area_far_from_the_origin — 4 rings x 13 offsets against exact rational arithmetic and against the untranslated result, to within 32 ulp of the coordinate magnitude.
  • test_centroid_valid_far_from_the_origin, test_centroid_thin_ring_far_from_the_origin — the two LinearRing.centroid symptoms above.
  • test_centroid_of_a_regular_polygon_is_its_center — a hypothesis test with a closed-form expectation. test_fuzz_centroid is the only property test that reaches centroid() and its assertion is shape-only (area is nan iff center is nan), so it cannot see this class of bug at all; I left it alone because its unbounded st.floats() draws leave nothing numeric to assert, and added the new one next to it instead. It survives --hypothesis-profile=exhaustive (10 000 examples), with a worst observed error of 4.5 ulp; before the fix 77% of that domain exceeds the 32 ulp bound and 4% comes back nan.

I mutated the fix in six ways (no translation at all, no shift back, wrong sign, shifting back twice, translating only x, leaving signed_area alone) — each reddens. Taking the last vertex as the origin instead of the first stays green, so the tests pin the property and not my particular choice.

pytest tests --hypothesis-profile=ci 437 pass, coverage stays at 100%, mypy, ruff format --check, complexipy, radon, lizard clean. ruff check reports the same 28 pre-existing CPY001 findings as develop does.

This was prepared with the assistance of an AI coding agent working under my direction; I have reviewed the change and the reasoning above and I am accountable for it.

Summary by Sourcery

Improve numerical robustness of centroid and signed area computations for rings, especially far from the origin, and add comprehensive tests for these behaviors.

Bug Fixes:

  • Fix centroid computation so it remains accurate for rings located far from the coordinate origin and for thin rings with large offsets.
  • Ensure signed_area remains accurate under large coordinate translations by basing calculations on coordinates relative to a local origin.
  • Define centroid behavior for empty coordinate sequences by returning NaN coordinates and zero area.

Enhancements:

  • Introduce a helper to compute ring coordinates relative to a local origin and reuse it in area and centroid calculations to reduce floating-point cancellation.
  • Add a property-based test asserting that the centroid of a regular polygon matches its geometric center across a range of sizes and offsets.

Tests:

  • Add exact rational-arithmetic oracle and offset-based tests to validate centroid and signed_area accuracy and translation invariance over multiple ring shapes and offset magnitudes.
  • Add LinearRing tests to ensure valid rings retain correct centroids even when far from the origin or very thin with large offsets.

Summary by CodeRabbit

  • Bug Fixes

    • Improved centroid and signed-area accuracy for shapes located far from the coordinate origin.
    • Fixed inaccurate or missing LinearRing centroid results for projected coordinates with large offsets.
    • Added consistent handling for empty geometry input.
  • Tests

    • Added coverage for large offsets, thin rings, translated geometries, and regular polygons.
    • Added precise arithmetic and tolerance-based validation.
  • Documentation

    • Documented the upcoming precision improvements in the 1.7.0 changelog.

Both shoelace sums in functions.py accumulate in absolute coordinates. The
per-edge terms then grow with the square of the coordinate magnitude while
their sum only grows with the area, so the significant digits cancel away for
rings that sit far from the origin. centroid() is affected worst because it
multiplies before it subtracts:

    area = (coord[0] * next_coord[1]) - (next_coord[0] * coord[1])

Taking a 10x10 square and moving it to x = y = 1e10 gives a centroid of
(6646321618.7, 6646321618.7) instead of (10000000005.0, 10000000005.0), and
LinearRing.centroid returns None for the same valid ring because the two area
computations no longer agree. A 10 m long, 1 mm thick ring already loses its
centroid at an offset of 1e4.

Subtracting the first vertex before accumulating keeps every term the size of
the ring itself; the centroid is shifted back at the end. Against an exact
rational evaluation over the same vertices the error is now within a few ulp of
the coordinate magnitude, which is the representation limit.

signed_area() differences the y coordinates before multiplying and so degrades
much more slowly, but it is the same accumulation and shares the fix: its
relative error on a 24-gon at an offset of 1e13 goes from 9e-5 to 0.

The cast in signed_area is no longer needed now that the helper is typed.
@semanticdiff-com

semanticdiff-com Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  pygeoif/functions.py  19% smaller
  docs/HISTORY.rst Unsupported file format
  tests/hypothesis/test_functions.py  0% smaller
  tests/test_functions.py  0% smaller
  tests/test_linear_ring.py  0% smaller

@sourcery-ai

sourcery-ai Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This PR refactors centroid and signed area computations to use a local coordinate system relative to the first vertex, improving numerical stability for rings far from the origin, and adds precise regression and property tests (including an exact rational oracle) to validate correctness and translation invariance of these operations, especially for thin rings and large offsets.

File-Level Changes

Change Details Files
Make shoelace-based signed area computation translation-stable by using coordinates relative to the first vertex.
  • Introduce a helper that converts a ring to x/y arrays expressed as offsets from the first vertex and returns that origin.
  • Change signed_area to use the relative x/y arrays instead of raw coordinates while preserving the existing discrete derivative formula.
  • Keep the function’s early return for rings with fewer than three points and simplify the final sum/divide expression without casting.
pygeoif/functions.py
Rework centroid computation to operate in a local coordinate system and handle empty coordinate sequences explicitly.
  • Add an early exit from centroid for empty coord sequences, returning (nan, nan) and area 0.0.
  • Use the new relative-coordinate helper so the centroid shoelace sums are accumulated in a local frame around the first vertex.
  • Rewrite the centroid loop to operate over index pairs (i, j) and compute cross products and accumulators using the relative x/y arrays.
  • After computing the centroid in local coordinates, shift it back to the original coordinate system using the stored origin.
  • Keep the existing behavior of returning NaN centroid when signed area is zero or NaN, and divide the accumulated signed area by 2.0 for the returned area.
pygeoif/functions.py
Add exact rational-oracle utilities and regression tests to verify centroid and signed area accuracy across shapes and large coordinate offsets.
  • Introduce exact_centroid_and_area using fractions.Fraction to compute centroid and area without rounding, used as an oracle in tests.
  • Add small helper functions offset_ring and ulp_tolerance plus canonical ring definitions (square, triangle, L-shape, thin wall) and a list of power-of-ten offsets.
  • Add tests that compare centroid output (both center and area) to the exact oracle within a 32-ULP tolerance over multiple shapes and offsets.
  • Add a test that signed_area matches the oracle area to tight relative tolerance across offsets.
  • Add a test that centroid on an empty coordinate list returns (nan, nan) and area 0.0, documenting the new behavior.
tests/test_functions.py
Introduce a Hypothesis-based property test asserting that the centroid of a regular polygon equals its geometric center, even far from the origin.
  • Add a Hypothesis test that generates regular polygons with varying vertex counts, radii, centers, and large offsets, then asserts the centroid matches the analytical center.
  • Use a 32-ULP tolerance based on the maximum coordinate magnitude to bound acceptable floating-point error.
  • Keep the pre-existing fuzz test unchanged while adding this stronger, numerically-sensitive property test alongside it.
tests/hypothesis/test_functions.py
Add LinearRing regression tests to ensure centroid remains valid for large offsets and thin rings.
  • Add a test that a simple rectangular LinearRing maintains an exact centroid across a range of powers-of-ten offsets (false eastings/northings).
  • Add a test verifying that a very thin rectangular ring (wall-like geometry) at a moderate large offset still yields the correct centroid instead of None.
  • Leave existing LinearRing centroid tests intact to ensure compatibility with prior behavior.
tests/test_linear_ring.py
Document the change in project history (not fully shown in the diff).
  • Update HISTORY.rst to record the centroid and signed area numerical stability fix and associated tests.
docs/HISTORY.rst

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 68e3aeac-f324-4f26-87f2-24f7cbf0bf9a

📥 Commits

Reviewing files that changed from the base of the PR and between 54abeaa and bb3a6c8.

📒 Files selected for processing (1)
  • tests/test_functions.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_functions.py

📝 Walkthrough

Walkthrough

The change improves centroid and signed_area precision for coordinates far from the origin. It adds relative-coordinate calculations, empty-input handling, exact arithmetic tests, property-based tests, and LinearRing regression coverage.

Changes

Geometry precision fix

Layer / File(s) Summary
Relative coordinate calculations
pygeoif/functions.py
The calculations translate coordinates relative to the first vertex. centroid restores the original coordinate system and handles empty input.
Function precision validation
tests/test_functions.py, tests/hypothesis/test_functions.py
Tests compare results with exact rational calculations, verify translation invariance, cover large offsets, and validate regular polygon centroids.
LinearRing regression coverage
tests/test_linear_ring.py, docs/HISTORY.rst
Tests cover large offsets and thin rings. The history records the precision fix.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Poem

A rabbit checks each distant ring,
Where floating numbers wobble and sing.
Shift the points, then shift them back,
Exact tests keep the figures on track.
Thin walls hold their center true—
Hop, precision, hop anew!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main numerical robustness change to centroid and signed area calculations.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@what-the-diff

what-the-diff Bot commented Aug 6, 2026

Copy link
Copy Markdown

PR Summary

  • Updated Change-log with Precision Fixes
    The log now accounts for improved precision in the calculation of 'centroid' and 'signed_area', especially for coordinates located far from the origin.

  • New Supportive Function Added
    A new function, _relative_coordinates, has been introduced which calculates relative coordinates as offsets from the first vertex to enhance precision.

  • Refactor of Key Functions
    Both 'signed_area' and 'centroid' functions have been fine-tuned to use relative coordinates for their calculations to prevent loss of precision.

  • Enhanced Centroid Function
    The centroid function has been upgraded to handle empty coordinate lists by returning (nan, nan) and an area of 0.0.

  • Comprehensive Function Tests Added
    Tests have been added to verify the correctness of the centroid function in various configurations of regular polygons, including those located far from the origin.

  • Validation Test for LinearRing Objects
    We've added validation tests for centroid when applied to LinearRing objects, ensuring precise calculations even when subjected to large offsets.

  • Test Coverage Improvement for Signed Area calculations
    The overall testing for signed area calculations is now improved to especially address edge cases that may result in the loss of significant digits.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Improve centroid/signed_area numeric stability using local-origin shoelace sums

🐞 Bug fix 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Compute shoelace sums in a local coordinate frame to avoid catastrophic cancellation.
• Make centroid robust for rings far from (0,0) and for empty coordinate input.
• Add regression and property-based tests covering large offsets and thin rings.
Diagram

graph TD
  LR["LinearRing.centroid"] --> C["functions.centroid()"] --> R["_relative_coordinates()"]
  LR --> SA["functions.signed_area()"] --> R
  T["Tests"] --> LR
  T --> C
  H["HISTORY.rst"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Compensated summation (Kahan/Neumaier) for shoelace terms
  • ➕ Improves precision without changing coordinate frame
  • ➕ Can be applied uniformly to many accumulations
  • ➖ More code/complexity than a simple translation
  • ➖ Still leaves very large intermediate products in centroid unless combined with refactoring
2. Use higher-precision arithmetic (Decimal/Fraction) in production path
  • ➕ Maximizes numerical robustness
  • ➕ Matches the exact-oracle approach used in tests
  • ➖ Significant performance cost
  • ➖ Requires API/typing decisions around numeric types
3. Delegate to an external robust geometry kernel (e.g., GEOS via shapely)
  • ➕ Battle-tested numeric robustness across many operations
  • ➕ Reduces maintenance burden for tricky numeric edge cases
  • ➖ Adds heavy dependency and platform constraints
  • ➖ Not aligned with lightweight pure-Python goals (if applicable)

Recommendation: Keep the PR’s approach (translate coordinates to a local origin before accumulation). It directly attacks the root cause (catastrophic cancellation from large absolute coordinates) with minimal complexity, preserves translation invariance, and is easy to validate. Compensated summation can be considered later for additional safety, but it’s not necessary to fix the demonstrated failures.

Files changed (5) +208 / -12

Bug fix (1) +35 / -12
functions.pyAccumulate centroid and signed area in a local coordinate frame +35/-12

Accumulate centroid and signed area in a local coordinate frame

• Introduces _relative_coordinates() to shift coordinates relative to the first vertex before computing shoelace-style sums. Updates signed_area() to use relative coordinates (and removes an unnecessary cast), and updates centroid() to compute in the local frame then shift the result back; also adds explicit handling for empty input.

pygeoif/functions.py

Tests (3) +169 / -0
test_functions.pyProperty test centroid of regular polygon equals its center at large offsets +39/-0

Property test centroid of regular polygon equals its center at large offsets

• Adds a Hypothesis test with a closed-form centroid expectation for regular polygons, including large coordinate offsets, to catch silent precision loss rather than only shape-validity issues.

tests/hypothesis/test_functions.py

test_functions.pyAdd exact-rational oracle and regression tests for large-offset rings +99/-0

Add exact-rational oracle and regression tests for large-offset rings

• Adds Fraction-based exact centroid/area computation as an independent oracle, plus regression tests verifying centroid accuracy, translation invariance, and centroid-area agreement across multiple shapes and offsets. Also adds a high-precision signed_area regression for a 24-gon and an explicit empty-input centroid test.

tests/test_functions.py

test_linear_ring.pyRegression tests: LinearRing.centroid remains valid far from origin +31/-0

Regression tests: LinearRing.centroid remains valid far from origin

• Adds tests ensuring LinearRing.centroid returns the expected centroid for rings with large false easting/northing offsets, including a thin 10m-by-1mm ring that previously failed.

tests/test_linear_ring.py

Documentation (1) +4 / -0
HISTORY.rstDocument centroid/signed_area precision fix for large offsets +4/-0

Document centroid/signed_area precision fix for large offsets

• Adds a changelog entry describing the loss-of-precision bug for rings far from the origin and the resulting LinearRing.centroid failures in projected coordinate systems.

docs/HISTORY.rst

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 complexity · 0 duplication

Metric Results
Complexity 0
Duplication 0

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

codescene-access[bot]

This comment was marked as outdated.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/test_functions.py`:
- Around line 111-135: Update exact_centroid_and_area, offset_ring, and
ulp_tolerance with explicit strict-compatible annotations for coordinate
sequences, scalar offsets, and their tuple/list or numeric return values. Use
the project’s existing coordinate and numeric type conventions where available,
and ensure local variables and inferred returns satisfy strict mypy without
leaving untyped function definitions.
- Around line 156-208: Replace the deterministic loops in
tests/test_functions.py lines 156-208 with custom Hypothesis strategies for
translated rings and offsets, applying `@given` while preserving the exact-oracle,
translation-invariance, signed-area, and empty-centroid assertions; update
tests/test_linear_ring.py lines 194-222 to generate far-origin and thin-ring
inputs through custom strategies while retaining the existing
LinearRing.centroid assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2033e3d9-32ed-40fd-9be2-a1c7a46b2da9

📥 Commits

Reviewing files that changed from the base of the PR and between 2ae7ce7 and 1eb0045.

📒 Files selected for processing (5)
  • docs/HISTORY.rst
  • pygeoif/functions.py
  • tests/hypothesis/test_functions.py
  • tests/test_functions.py
  • tests/test_linear_ring.py

Comment thread tests/test_functions.py
Comment on lines +111 to +135
def exact_centroid_and_area(coords):
"""
Centroid and area of a ring in exact rational arithmetic.

Independent oracle for the floating point implementation: the vertices are
the same but no rounding takes place anywhere in the computation.
"""
points = [(Fraction(coord[0]), Fraction(coord[1])) for coord in coords]
double_area = Fraction(0)
acc_x = Fraction(0)
acc_y = Fraction(0)
for (x0, y0), (x1, y1) in zip(points, points[1:] + points[:1], strict=True):
cross = x0 * y1 - x1 * y0
double_area += cross
acc_x += (x0 + x1) * cross
acc_y += (y0 + y1) * cross
return acc_x / (3 * double_area), acc_y / (3 * double_area), double_area / 2


def offset_ring(ring, offset):
return [(x + offset, y + offset) for x, y in ring]


def ulp_tolerance(ring, ulps=32):
return ulps * math.ulp(max(abs(value) for point in ring for value in point))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Add strict type annotations to the test helpers.

exact_centroid_and_area, offset_ring, and ulp_tolerance have untyped parameters and return values. Strict mypy rejects untyped function definitions. Add explicit coordinate, scalar, and return types.

As per coding guidelines, **/*.py: “All code must pass mypy strict type checking.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_functions.py` around lines 111 - 135, Update
exact_centroid_and_area, offset_ring, and ulp_tolerance with explicit
strict-compatible annotations for coordinate sequences, scalar offsets, and
their tuple/list or numeric return values. Use the project’s existing coordinate
and numeric type conventions where available, and ensure local variables and
inferred returns satisfy strict mypy without leaving untyped function
definitions.

Source: Coding guidelines

Comment thread tests/test_functions.py
Comment on lines +156 to +208
def test_centroid_far_from_the_origin() -> None:
for ring in RINGS:
for offset in OFFSETS:
moved = offset_ring(ring, offset)

center, area = centroid(moved)
expected_x, expected_y, expected_area = exact_centroid_and_area(moved)

tolerance = ulp_tolerance(moved)
assert abs(center[0] - float(expected_x)) <= tolerance
assert abs(center[1] - float(expected_y)) <= tolerance
assert math.isclose(area, float(expected_area))


def test_centroid_is_translation_invariant() -> None:
for ring in RINGS:
base, _ = centroid(ring)
for offset in OFFSETS:
moved = offset_ring(ring, offset)

center, _ = centroid(moved)

tolerance = ulp_tolerance(moved)
assert abs(center[0] - (base[0] + offset)) <= tolerance
assert abs(center[1] - (base[1] + offset)) <= tolerance


def test_centroid_area_agrees_with_signed_area_far_from_the_origin() -> None:
for ring in RINGS:
for offset in OFFSETS:
moved = offset_ring(ring, offset)

assert math.isclose(centroid(moved)[1], signed_area(moved))


def test_signed_area_far_from_the_origin() -> None:
ring = circle_ish(0, 0, 10, 24)
for offset in OFFSETS:
moved = offset_ring(ring, offset)

*_, expected_area = exact_centroid_and_area(moved)

assert math.isclose(signed_area(moved), float(expected_area), rel_tol=1e-15)


def test_centroid_empty() -> None:
center, area = centroid([])

assert math.isnan(center[0])
assert math.isnan(center[1])
assert area == 0


Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use custom Hypothesis strategies for the new regression cases.

The new tests use deterministic loops and fixed fixtures. They do not use @given with custom strategies.

  • tests/test_functions.py#L156-L208: Define custom strategies for translated rings and offsets. Use them with @given while keeping the exact-oracle assertions.
  • tests/test_linear_ring.py#L194-L222: Generate far-origin and thin-ring inputs from custom strategies. Keep the LinearRing.centroid assertions.

As per coding guidelines, tests/test_*.py: “Tests must use pytest framework with @given decorators using custom strategies and aim for 100% branch coverage.”

📍 Affects 2 files
  • tests/test_functions.py#L156-L208 (this comment)
  • tests/test_linear_ring.py#L194-L222
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_functions.py` around lines 156 - 208, Replace the deterministic
loops in tests/test_functions.py lines 156-208 with custom Hypothesis strategies
for translated rings and offsets, applying `@given` while preserving the
exact-oracle, translation-invariance, signed-area, and empty-centroid
assertions; update tests/test_linear_ring.py lines 194-222 to generate
far-origin and thin-ring inputs through custom strategies while retaining the
existing LinearRing.centroid assertions.

Source: Coding guidelines

@sourcery-ai sourcery-ai Bot 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.

Hey - I've found 1 issue, and left some high level feedback:

  • The new _relative_coordinates helper assumes coords is non-empty; since this is now a core precondition for both signed_area and centroid, consider making this explicit (e.g. via an assertion or docstring note) to avoid accidental misuse from future call sites.
  • The ULP-based tolerance logic in test_centroid_far_from_the_origin and test_centroid_of_a_regular_polygon_is_its_center is nearly identical; you could reduce duplication and make future adjustments easier by reusing the ulp_tolerance helper in the Hypothesis test as well.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new `_relative_coordinates` helper assumes `coords` is non-empty; since this is now a core precondition for both `signed_area` and `centroid`, consider making this explicit (e.g. via an assertion or docstring note) to avoid accidental misuse from future call sites.
- The ULP-based tolerance logic in `test_centroid_far_from_the_origin` and `test_centroid_of_a_regular_polygon_is_its_center` is nearly identical; you could reduce duplication and make future adjustments easier by reusing the `ulp_tolerance` helper in the Hypothesis test as well.

## Individual Comments

### Comment 1
<location path="tests/test_functions.py" line_range="201-206" />
<code_context>
+        assert math.isclose(signed_area(moved), float(expected_area), rel_tol=1e-15)
+
+
+def test_centroid_empty() -> None:
+    center, area = centroid([])
+
+    assert math.isnan(center[0])
+    assert math.isnan(center[1])
+    assert area == 0
+
+
</code_context>
<issue_to_address>
**suggestion (testing):** Extend centroid empty/degenerate tests to cover very small rings and collinear vertices explicitly.

The empty case looks good, but we should also cover “almost empty” inputs that hit the `signed_area == 0 or math.isnan(signed_area)` path. Please add tests for: (1) a two-point line, (2) a three-point collinear ring, and (3) a closed ring whose area is numerically ~0, to verify this branch behaves as expected.

Suggested implementation:

```python
def test_centroid_empty() -> None:
    center, area = centroid([])

    assert math.isnan(center[0])
    assert math.isnan(center[1])
    assert area == 0


def test_centroid_two_point_line_is_degenerate() -> None:
    line = [(0.0, 0.0), (1.0, 0.0)]

    center, area = centroid(line)

    assert math.isnan(center[0])
    assert math.isnan(center[1])
    assert area == 0


def test_centroid_three_point_collinear_ring_is_degenerate() -> None:
    # Three collinear vertices; centroid should follow the degenerate branch
    ring = [(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)]

    center, area = centroid(ring)

    assert math.isnan(center[0])
    assert math.isnan(center[1])
    assert area == 0


def test_centroid_near_zero_area_ring_is_degenerate() -> None:
    # Very thin rectangle whose area underflows to ~0 in floating point
    # This is intended to exercise the `signed_area == 0` branch.
    h = 1e-308
    ring = [
        (0.0, 0.0),
        (1.0, 0.0),
        (1.0, h),
        (0.0, h),
        (0.0, 0.0),
    ]

    center, area = centroid(ring)

    assert math.isnan(center[0])
    assert math.isnan(center[1])
    assert area == 0

```

These tests assume that `centroid` returns `(nan, nan)` and an area of `0` whenever the underlying `signed_area` is exactly zero or `nan`, for non-empty/degenerate inputs as well as the empty case. If the actual intended behavior is different (e.g. returning some other sentinel or raising), you should adjust the assertions accordingly. No additional imports should be necessary if `math` and `centroid` are already imported in this module.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread tests/test_functions.py
Comment on lines +201 to +206
def test_centroid_empty() -> None:
center, area = centroid([])

assert math.isnan(center[0])
assert math.isnan(center[1])
assert area == 0

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.

suggestion (testing): Extend centroid empty/degenerate tests to cover very small rings and collinear vertices explicitly.

The empty case looks good, but we should also cover “almost empty” inputs that hit the signed_area == 0 or math.isnan(signed_area) path. Please add tests for: (1) a two-point line, (2) a three-point collinear ring, and (3) a closed ring whose area is numerically ~0, to verify this branch behaves as expected.

Suggested implementation:

def test_centroid_empty() -> None:
    center, area = centroid([])

    assert math.isnan(center[0])
    assert math.isnan(center[1])
    assert area == 0


def test_centroid_two_point_line_is_degenerate() -> None:
    line = [(0.0, 0.0), (1.0, 0.0)]

    center, area = centroid(line)

    assert math.isnan(center[0])
    assert math.isnan(center[1])
    assert area == 0


def test_centroid_three_point_collinear_ring_is_degenerate() -> None:
    # Three collinear vertices; centroid should follow the degenerate branch
    ring = [(0.0, 0.0), (1.0, 0.0), (2.0, 0.0)]

    center, area = centroid(ring)

    assert math.isnan(center[0])
    assert math.isnan(center[1])
    assert area == 0


def test_centroid_near_zero_area_ring_is_degenerate() -> None:
    # Very thin rectangle whose area underflows to ~0 in floating point
    # This is intended to exercise the `signed_area == 0` branch.
    h = 1e-308
    ring = [
        (0.0, 0.0),
        (1.0, 0.0),
        (1.0, h),
        (0.0, h),
        (0.0, 0.0),
    ]

    center, area = centroid(ring)

    assert math.isnan(center[0])
    assert math.isnan(center[1])
    assert area == 0

These tests assume that centroid returns (nan, nan) and an area of 0 whenever the underlying signed_area is exactly zero or nan, for non-empty/degenerate inputs as well as the empty case. If the actual intended behavior is different (e.g. returning some other sentinel or raising), you should adjust the assertions accordingly. No additional imports should be necessary if math and centroid are already imported in this module.

@binggao1230

Copy link
Copy Markdown
Author

Both pre-commit.ci failures are pre-existing on develop and untouched by this branch:

  • pretty-format-json wants context7.json's keys sorted. That file is byte identical here to develop (same md5), and the hook fails the same way when run against develop's copy directly.
  • ruff check reports 28 CPY001 findings, all in files this PR does not touch (docs/conf.py, mutmut_config.py, pygeoif/about.py, the test modules …). Running the pinned ruff 0.16.1 against a clean develop checkout gives the same 28.

Nothing in the diff adds a lint, format or type finding: mypy pygeoif tests, ruff format --check, complexipy, radon and lizard are all clean, and coverage stays at 100%.

@greptile-apps

greptile-apps Bot commented Aug 6, 2026

Copy link
Copy Markdown

Greptile Summary

The PR improves numerical stability by calculating polygon centroid and signed area relative to the first vertex, then translating the centroid back to absolute coordinates.

  • Adds explicit empty-input handling for centroid calculations.
  • Adds exact-arithmetic, translation, large-offset, thin-ring, and property-based regression coverage.
  • Documents the precision fix in the 1.7.0 changelog.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
pygeoif/functions.py Introduces local-origin coordinate normalization for centroid and signed-area calculations while preserving supported closed-ring behavior.
tests/test_functions.py Adds exact rational oracles and broad regression coverage for translated and thin rings.
tests/hypothesis/test_functions.py Adds property-based validation that translated regular polygons retain their expected centroid.
tests/test_linear_ring.py Verifies that valid large-offset and thin rings continue to expose the correct centroid.
docs/HISTORY.rst Records the centroid and signed-area precision correction.

Reviews (3): Last reviewed commit: "Exclude unreachable sequence guards from..." | Re-trigger Greptile

@qodo-code-review

qodo-code-review Bot commented Aug 6, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Ambiguous coords truthiness ✓ Resolved 🐞 Bug ☼ Reliability
Description
centroid() now uses if not coords: which relies on the input’s boolean semantics and can raise
at runtime for some sequence-like containers with ambiguous/unsupported truthiness. Since LineType
is a Sequence, the empty check should be done via len(coords) == 0 before indexing in
_relative_coordinates().
Code

pygeoif/functions.py[R73-76]

+    if not coords:
+        return ((math.nan, math.nan), 0.0)
+
+    xs, ys, (origin_x, origin_y) = _relative_coordinates(coords)
Evidence
The new truthiness guard was introduced in centroid() and is unnecessary for LineType because it
is a Sequence (so len() is supported). The file already uses len(coords) for empty/short
handling in signed_area(), showing the intended pattern is a length-based check rather than
container truthiness.

pygeoif/functions.py[71-77]
pygeoif/functions.py[56-68]
pygeoif/types.py[19-34]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`centroid()` guards empty input with `if not coords:`. This can invoke container-specific `__bool__` behavior and may raise for some sequence-like inputs; the function only needs to know whether the sequence is empty before indexing `coords[0]`.

### Issue Context
`LineType` is a `collections.abc.Sequence`, so `len(coords)` is always available; `signed_area()` already uses a `len(coords)` guard.

### Fix Focus Areas
- pygeoif/functions.py[71-77]
- pygeoif/functions.py[56-68]
- pygeoif/types.py[19-34]

### Proposed change
Replace:
```python
if not coords:
   return ((math.nan, math.nan), 0.0)
```
with:
```python
if len(coords) == 0:
   return ((math.nan, math.nan), 0.0)
```
(or an equivalent `len(coords)`-based check).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread pygeoif/functions.py Outdated

@llamapreview llamapreview Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LlamaPReview — No blocking issues found

Safe to merge: the change is mathematically equivalent for closed rings, strictly reduces floating-point cancellation far from the origin, and fixes the reported None centroid symptom without regressing ordinary inputs.

1 non-blocking finding retained — highest: signed_area result changes for open coordinate sequences with nonzero first x.

Change flow

The rebased shoelace pipeline shows how the PR change fixes the None guard by keeping both compared values in the same local frame.

sequenceDiagram
participant C as Client
participant L as LinearRing.centroid
participant F as functions.centroid
participant A as signed_area
participant R as Result
C->>L: centroid()
L->>F: centroid(coords)
F->>F: _relative_coordinates(coords)
note over F: PR change — rebase by first vertex<br/>then shift back
F-->>L: (center, area)
L->>A: signed_area(coords)
note over A: PR change — same rebased frame<br/>as centroid
A-->>L: area
alt Math.isclose(area, signed_area)
L->>R: Return Point
else
L->>R: Return None (was spurious for far-origin rings)
end
Loading
Review details and evidence
Priority File Finding Evidence
P2 pygeoif/functions.py signed_area result changes for open coordinate sequences with nonzero first x confirmed

Finding details

P2 · signed_area result changes for open coordinate sequences with nonzero first x

pygeoif/functions.py

Rebasing the trapezoid sum by the first vertex introduces the constant term -origin_x · (y_last − y_first) relative to the old absolute-frame result. The term vanishes for closed inputs (zero delta) but not for open sequences. signed_area is a public documented API typed over LineType; nothing in this PR validates or closes inputs, so an external caller passing an open LineString's coords with a nonzero first-x receives the constant-shifted value. In practice every in-repo caller passes closed rings (LinearRing.__init__ auto-closes, LinearRing.centroid/is_ccw pass self.coords), and the docstring states 'area enclosed by a ring'. So for out-of-contract data this is an unannounced fix, not a corruption.

Owner action: Nonblocking optional: add one sentence to docs/codedocs/api-reference/functions.md noting inputs are treated as closed rings.

Verification boundary: confirmed; scope: changed region.

Conceptual guidance (not a committable GitHub suggestion):

Document in the docstring or API reference that input sequences are treated as closed rings for area computation.

Material unknowns

  • Cause of the pre-commit.ci - pr failure — the status carries no annotation or mechanism. If a rerun or diagnostic shows a PR-caused lint/test failure, confidence (and possibly posture) would change; a green rerun would raise decision confidence to High.
    • Check: Check the pre-commit.ci - pr status for a diagnostic or rerun it to confirm cleanliness.

LlamaPReview checks

  • Read bounded PR-head context from pygeoif/functions.py.
  • Read bounded PR-head context from pygeoif/geometry.py.
  • Inspected matching PR-head repository snippets in pygeoif/functions.py, docs/codedocs/architecture.md, docs/HISTORY.rst and 2 more path(s).
  • Inspected matching PR-head repository snippets in .github/workflows/codesee-arch-diagram.yml.

Automated review by LlamaPReview · Free for public open-source projects.

@binggao1230

Copy link
Copy Markdown
Author

Note on the red pre-commit.ci: it is not from this PR's diff. The 08-04 autoupdate (2ae7ce7, ruff-pre-commit → v0.16.1) turned on CPY001 via select = ["ALL"], and ruff now flags "Missing copyright notice at top of file" repo-wide — including files this PR never touches (docs/conf.py, mutmut_config.py, …). Cross-check: every open PR based after the autoupdate is red (#381 too), while #337 from before it is green. So the fix (either CPY001 in the ignore list or repo-wide notices) belongs on develop, not in this PR. Happy to send that as a separate tiny PR if you want it.

codescene-access[bot]

This comment was marked as outdated.

@codecov

codecov Bot commented Aug 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (2ae7ce7) to head (bb3a6c8).

Additional details and impacted files
@@            Coverage Diff            @@
##           develop      #380   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           32        32           
  Lines         2674      2766   +92     
  Branches        91       101   +10     
=========================================
+ Hits          2674      2766   +92     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

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

@codescene-access codescene-access Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Our agent can fix these. Install it.

Gates Passed
6 Quality Gates Passed

Quality Gate Profile: Customizable Safeguards
Install CodeScene MCP: safeguard and uplift AI-generated code. Catch issues early with our IDE extension and CLI tool.

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.

1 participant