Shapely's spatial predicates can yield surprising outcomes at polygon boundaries. For example, a point lying exactly on the common edge of two adjacent polygons can be claimed by neither polygon under contains, or by both under covers. In other words, a boundary point can belong to zero polygons or two, depending on the predicate. This article shows how to declare and enforce a consistent point-to-zone assignment policy. The supplied shapely_lab.py fixture records a passing run under Python 3.13.5, Shapely 2.1.2, and GEOS 3.13.1. Its output is compared with the behavior described in the Shapely contains documentation and the Shapely covers documentation. Every original point ID stays in the ledger, geometry validity remains separate from membership, and multi-candidate cases are not resolved silently. The result is an acceptance and repair playbook: accept a declared contract, repair a predicate or policy mismatch, hold invalid or unresolved data, and reassign affected records from retained originals. The test also keeps coordinate assumptions explicit, distinguishes hole interiors from hole rings, and proves that ordering or buffering cannot authorize an ownership decision.
Declare the boundary and multiplicity policy before assigning zones
Before any spatial computation, define the rules. State which inclusion predicate is authorized and whether a point may have zero, one, or several candidate zones. This is a contract between data processing and the named data owner. Use UNASSIGNED for no candidate, SINGLE_OWNER for one candidate, AMBIGUOUS for several candidates, and INVALID for an input that cannot be evaluated. An ambiguous result must not be collapsed to one owner by iteration order. Only an explicitly authorized ownership rule may settle it. If the policy excludes boundary points, the contains predicate can be declared, but it does not guarantee a unique owner where polygon interiors overlap. If the policy includes boundaries, the covers predicate can be declared, and shared-edge or shared-vertex cases must still be held when unique ownership is required. Predicate selection answers geometric inclusion; the multiplicity policy answers ownership.
This upfront policy aligns with remote-sensing processing and quality foundations, where validation precedes analysis. Retaining full candidate lists is safer than making hidden assumptions. Every original point ID should remain in a status ledger with one of four outcomes: SINGLE_OWNER, UNASSIGNED, AMBIGUOUS, or INVALID. The geometry test and the ownership decision remain separate, and the evidence must identify who is allowed to settle unresolved assignments.
Pin a planar fixture with adjacent zones and a hole
We work in a declared Cartesian plane; no CRS labeling or reprojection is involved. The Shapely User Manual describes Shapely's planar geometry model, so every feature in this fixture is interpreted in the same coordinate space. Zone A is a 10 x 10 square from (0, 0) to (10, 10) with a rectangular hole from (3, 3) to (7, 7). Zone B is the adjacent square from (10, 0) to (20, 10). The zones share the vertical edge at x = 10 and the vertex at (10, 10). The fixture uses the coordinates exactly as declared and does not reproject, snap, or buffer them.
The ten named points are:
a_inside: Point(1,1), lies strictly inside A’s outer ring.
b_inside: Point(11,1), inside B.
outside: Point(-1,5), outside both polygons.
outer_edge: Point(0,5), on the left boundary of A.
shared_edge: Point(10,5), on the common edge between A and B.
shared_vertex: Point(10,10), on the corner shared by A and B.
hole_interior: Point(5,5), inside A’s hole (the missing-center).
hole_boundary: Point(3,5), on the boundary ring of the hole in A.
missing: None (no geometry).
empty: Point() (an empty geometry with no coordinates).
Each ID is included in the input manifest. The provided shapely_lab.py fixture constructs the objects with Shapely 2.1.2 and GEOS 3.13.1, classifies them with both predicates, and prints the Python and library versions for the evidence record. Its recorded JSON output identifies the target build and fixture revision. Keeping the example synthetic isolates predicate behavior from the broader geospatial programming and analysis skills used in production workflows. No real geographic or administrative boundary is represented.
Use one declared Cartesian plane
All operations use the same coordinate space, so the fixture assigns no EPSG code and performs no transformation. The Shapely User Manual frames these operations in a planar model; the code therefore treats the declared coordinate values as its complete input. There is no geodesic interpretation, axis swap, or hidden conversion. The test asks only which zones satisfy the selected predicate for each exact point.
Establish independent membership controls
Before executing code, we enumerate the expected membership for each point with each predicate. Based on topology (A’s hole, A/B adjacency, and boundary definitions), we predict:
Point ID | Expected contains Zones | Expected covers Zones |
a_inside | A | A |
b_inside | B | B |
outside | (none) | (none) |
outer_edge | (none, boundary) | A |
shared_edge | (none, shared boundary) | A, B |
shared_vertex | (none, shared vertex) | A, B |
hole_interior | (none, exterior) | (none) |
hole_boundary | (none, boundary) | A |
missing | (invalid) | (invalid) |
empty | (invalid) | (invalid) |
These expectations follow from Shapely's spatial model and the documented predicate definitions. The contains predicate returns true only when no point of the second geometry lies outside the first and at least one point lies in the first geometry's interior. For a point tested against a polygon, that excludes a point that lies only on the polygon boundary. The covers predicate returns true when no point of the second geometry lies outside the first, so a polygon can cover a point on its boundary. This distinction predicts that the outer edge, shared edge, shared vertex, and hole boundary will be excluded by contains and admitted by covers.
The Shapely User Manual's polygon model treats the interior of a hole as part of the polygon's exterior. Therefore, hole_interior at (5, 5) should have no candidate under either predicate. The hole ring itself is part of the polygon boundary, so hole_boundary at (3, 5) should be excluded by contains but included by covers. The missing record and the empty Point() remain in the manifest and are handled explicitly rather than disappearing from the population.
Coordinate provenance should be checked before any overlay. The related guide to coordinate validation before spatial predicates addresses that upstream contract. In this controlled fixture, the coordinates are already trusted, so the test isolates vector topology and assignment multiplicity.
Run contains and account for the missing boundary matches
We first apply shapely.contains(z, p) for each point p against each zone z. The observed results match our expectations. The points strictly inside a polygon’s interior are identified: 'a_inside' belongs to A, 'b_inside' belongs to B. All points lying on any boundary (outer, shared edge, shared vertex, or hole boundary) produce no candidate under contains (status UNASSIGNED). The point inside the hole also has no match. The missing (None) and empty (Point()) entries are classified as INVALID by our code (we do not attempt contains on them).
In summary for contains:
SINGLE_OWNER: a_inside (['A']), b_inside (['B'])
UNASSIGNED: all others except missing/empty (no zones)
INVALID: missing, empty (no geometry)
This yields 2 SINGLE_OWNER, 6 UNASSIGNED, and 2 INVALID statuses across 10 input records. The Shapely contains documentation explains the point-specific boundary behavior: a point on A's outer edge is not contained by A. Accordingly, outer_edge, shared_edge, shared_vertex, and hole_boundary remain unassigned under this predicate. The result is expected predicate behavior, but a pipeline that deletes those records would still be defective.
A missing candidate is not permission to discard the point
Even when contains finds no zone, the point remains in the output with status UNASSIGNED. The pipeline must not drop or merge away the ID. An unmatched point is a valid outcome of the chosen rule, not a lost record.
We also confirm the fixture’s internal summary (printed JSON): it shows exactly 2 SINGLE_OWNER, 6 UNASSIGNED, and 2 INVALID, with a total of 2 candidate pairs (two single-owner matches) for contains. These match our hand-counts and the assertion in the code. No ambiguity occurred under contains, because no point touches more than one interior.
Switch to covers and expose shared-boundary ambiguity
Next we rerun the classification using shapely.covers(z, p). This predicate includes boundary points, so we see some points gain candidates that were previously unassigned. Specifically:
outer_edge is now in A (SINGLE_OWNER).
hole_boundary is in A (SINGLE_OWNER).
shared_edge yields candidates [A, B] (AMBIGUOUS).
shared_vertex yields [A, B] (AMBIGUOUS).
The interior points (a_inside, b_inside) remain in A and B respectively. Points outside and inside the hole still have none. The missing/empty points remain INVALID.
In summary for covers:
SINGLE_OWNER: a_inside (A), b_inside (B), outer_edge (A), hole_boundary (A) (4 total)
AMBIGUOUS: shared_edge (A,B), shared_vertex (A,B) (2 total)
UNASSIGNED: outside, hole_interior (2 total)
INVALID: missing, empty (2 total)
These results agree with the independent controls. The fixture records 4 SINGLE_OWNER, 2 AMBIGUOUS, 2 UNASSIGNED, 2 INVALID, and 8 candidate pairs. The Shapely covers documentation illustrates the same boundary-inclusive relationship for a polygon with a hole. In this fixture, hole_boundary is covered only by A, while shared_edge and shared_vertex are covered by both A and B.
This exposes the key ambiguity: two different zones include those boundary points. Since we declared unique assignment in our policy, having two candidates means we cannot automatically pick one. We mark these points as AMBIGUOUS rather than invent a tie-break. This is correct under the “covers” semantics and the unique-owner policy.
Validate the hole interior and its boundary separately
It is important to distinguish a hole's interior from its ring. hole_interior at (5, 5) lies inside A's hole, not inside A's filled polygon area. Under the Shapely polygon model, the hole interior belongs to the polygon's exterior, so both predicates correctly return no candidate. hole_boundary at (3, 5), by contrast, lies on A's inner boundary ring. The contains documentation excludes this point-specific boundary case, while covers admits it. The fixture therefore leaves (5, 5) UNASSIGNED and assigns (3, 5) to A under covers.
Do not confuse a hole with its ring
hole_interior is never in the polygon's interior, so it remains UNASSIGNED. hole_boundary is on the polygon boundary, which means covers includes it as a single-owner candidate for A. The literal expected sets and the recorded fixture output must agree for both points. The Shapely User Manual supplies the topology; the local fixture supplies the observed classification.
Keep invalid, missing and empty input distinguishable
The routine validates the complete zone population before generating candidates. An invalid self-intersecting bow-tie polygon returns false under Shapely's is_valid predicate, and the fixture responds with a HOLD error instead of continuing. It also rejects a zone that is empty, has a Z coordinate, or has nonfinite bounds. No polygon repair algorithm is applied silently.
The point gate treats None and an empty Point() separately from populated points. Shapely's validity documentation reports missing input such as None as invalid, while an empty point may still be topologically valid. The assignment contract nevertheless marks both records INVALID because neither supplies a usable two-dimensional location. Their IDs remain in the ledger with empty candidate lists.
By the end of preprocessing, all zone geometries were valid (no .has_z, no infinities), and all 10 point IDs remained in the ledger (2 marked INVALID, 8 valid). This fulfills our policy to never drop original records, even empty/missing ones.
Reject first-match selection as an undeclared ownership rule
One might be tempted to break ties by simply taking the first matching zone. To test this, we reversed the order of the zones in our dictionary. The point 'shared_edge' (10,5) is covered by both zones, so our code finds A first in one order and B first in the reverse order. The fact that these differ confirms that “first-match” is not a stable rule. (The fixture even asserts first != reverse_first.)
Two valid matches still require a multiplicity decision
Do not implement a hidden zone priority. Reversing iteration order changed the naive first selection, while the full candidate set stayed [A, B]. Sorting the zones would make first-match output reproducible, but it would not make that choice authorized. Under a unique-owner contract, the point remains AMBIGUOUS until the named data owner or an approved rule resolves it.
Reconcile points, candidate pairs and assignment statuses
After classification, we summarize the results in two ways: by counting statuses and by counting point-zone pairs. For the contains run, we had:
Point count = 10 (all original IDs).
Status counts = {SINGLE_OWNER: 2, UNASSIGNED: 6, INVALID: 2}.
Candidate pairs = 2 (one for each single-owner point).
For covers, we had:
Point count = 10.
Status counts = {SINGLE_OWNER: 4, UNASSIGNED: 2, AMBIGUOUS: 2, INVALID: 2}.
Candidate pairs = 8 (4 single-owner + 2×2 ambiguous).
We can show this in a simple table:
Predicate | Single Owner | Unassigned | Ambiguous | Invalid | Candidate Pairs |
contains | 2 | 6 | 0 | 2 | 2 |
covers | 4 | 2 | 2 | 2 | 8 |
The status total is 10 for both predicates, matching the input population. Eight candidate pairs under covers do not represent eight distinct points because two points each contribute two pairs. The point ledger and the point-zone pair table are therefore separate controls. This is analogous to why raster validity is a different data contract: checking one representation does not prove that a related population or mask is complete. Here, the gate reconciles input IDs, candidate pairs, and assignment statuses independently.
We also checked that reversing the input order of zones or points gave identical results, satisfying our regression tests.
Repair the declared policy rather than moving the geometry
At this stage, the fixture exposes a policy mismatch rather than a coordinate error. The points and zones are unchanged, and Shapely returns the declared predicate results. Repair therefore means selecting and documenting both an inclusion predicate and a multiplicity policy. If boundaries must be included, switching from contains to covers repairs the inclusion rule, but it does not create unique ownership on shared edges. If boundaries must be excluded, contains may be accepted, provided the resulting UNASSIGNED population is allowed.
Importantly, we do not apply any geometric workaround. We avoid adding buffers, snapping, or moving points to “fix” boundary inclusion. Doing so would change the input data in a way not specified by our contract, and could introduce new errors. For example, expanding polygon A by a tiny epsilon would alter all calculations and is outside the scope of “predicate adjustment.” The fixture kept all coordinates exact.
Repair means realigning computation with the declared contract. A policy that includes shared boundaries can use covers and hold multi-candidate points for an authorized decision. A strict-interior policy can use contains and retain boundary points as explicit UNASSIGNED exceptions. Any tolerance, snapping, buffering, or geometry repair would require a separate evidence-backed contract. By default, policy, not altered geometry, determines the fix.
Do not hide a policy problem with a tolerance buffer
Changing coordinates or zone boundaries answers a different question. Keep the input geometry exactly as provided and apply the declared inclusion semantics. Any buffer, snap, or new tolerance must be justified by a separate business rule and validated as a new contract.
Reassign affected records from retained originals
Since we retained all point IDs, we can now reconcile old versus new assignments under the repaired policy. For example, if we switch to covers to include boundaries, the previously UNASSIGNED points outer_edge and hole_boundary now have single candidates. We would update their status to SINGLE_OWNER (A). Conversely, if we had originally (incorrectly) assigned 'shared_edge' to only A by first-match, we revert to the full candidate list [A, B] with status AMBIGUOUS. In practice, we produce a mapping from each point ID to its final assignment list.
Any record omitted by an earlier filter must be reintroduced from the retained original manifest. The fixture itself drops none; this recovery branch applies to outputs produced by the old logic. REASSIGN AND REVALIDATE reruns the selected predicate against the original point geometries, compares old and new candidates by point ID, and records every change. For example, outer_edge can move from UNASSIGNED under contains to SINGLE_OWNER for A under covers, while a first-match assignment for shared_edge must be replaced with candidates [A, B] and status AMBIGUOUS. Unresolved points are handed to the named data owner.
Overall, we ensure that every original ID is present exactly once, and that its new status/candidates reflect the chosen predicate. Unresolved cases (AMBIGUOUS or previously INVALID) remain for human or domain resolution.
Rerun the boundary and ordering regression suite
After any predicate or policy change, rerun the full regression suite in the deployed environment. The assertions require that reversing zone order or point order preserve complete candidate sets and statuses. The commissioning evidence records a passing run under Python 3.13.5, Shapely 2.1.2, and GEOS 3.13.1, with fixture tag shapely-boundary-r1. A new runtime build is a new observation and must record its own versions and output.
A green fixture does not settle unresolved real assignments
A passing fixture confirms that the code matches the declared controls in the tested environment. It does not decide who owns shared_edge or shared_vertex, and it does not convert an invalid source geometry into an acceptable input. Predicate behavior, geometry validity, and final ownership are separate gates.
In summary, we have a repeatable test suite: each point ID always maps to the same candidate list and status regardless of order or input quirks. The only differences are between the two predicates (contains vs covers), exactly as documented.
Choose ACCEPT, REPAIR, HOLD or REASSIGN AND REVALIDATE
Finally, we apply a decision matrix. Based on the results and our policies, we label the outcome as:
ACCEPT: If the chosen predicate and current assignments fully meet the data contract (one owner per point if that was required, no illegal geometry, etc.). For example, using contains could be accepted if the policy was “ignore boundary points” and if having UNASSIGNED points is acceptable.
REPAIR: If the current configuration violates the contract in a fixable way. In our example, if the policy was to include boundaries, then seeing UNASSIGNED boundary points under contains means “repair by switching to covers.” Or if unique ownership is mandated, seeing AMBIGUOUS means “repair needed to clarify multiplicity.”
HOLD: If there are unresolved issues that block publication. This includes invalid geometry, missing data, or unresolved ambiguous assignments. (Even a perfectly consistent dataset might be held if policy disallows any ambiguity.)
REASSIGN AND REVALIDATE: If we are fixing outputs from a previous run, we must reprocess with the updated rules and then go through the accept/repair/hold logic anew.
A data file can be technically correct under covers and still remain HOLD because two-owner points violate a unique-owner contract. The ambiguity report is evidence of correct classification, not proof that publication is allowed. Applying this gate before downstream Earth-observation analysis prevents an arbitrary assignment from becoming an invisible analytical assumption.
Overall, we document for each dataset which category it falls into and why, along with the test log. Ambiguities or invalid entries will have to be resolved or explained before data is considered “clean.”
Build spatial-quality review into remote-sensing practice
In this exercise we have tied together documented spatial semantics, automated tests, and clear policies. We kept evidence at each step: the exact zone and point definitions, the Shapely/GEOS versions, the pre-known expectations, and the actual results. Every fix was a business decision, not a “magic bullet.” This kind of rigorous spatial quality validation should become a habit in any geospatial data pipeline. By retaining all input IDs and statuses, we ensure traceability and defensibility.
Practitioners strengthening related foundations in sensor and data processing, Python-oriented geospatial work, machine learning, and cloud analysis can review Refonte Learning's Remote Sensing Scientist/Engineer program. The program page lists three months at 10-12 hours per week and associates successful completion with a Training Certificate and a Certificate of Internship. This article does not assume that the program teaches this exact Shapely laboratory.
Overall, this playbook combines the official contains definition and covers definition with independent expected sets, exact record reconciliation, and order-invariance checks. Every input ID remains traceable, boundary cases stay visible, and unresolved ownership remains with the authorized decision-maker.
