Skip to content

mortie.coverage

Polygon-to-morton coverage. The MOC (multi-order coverage) set algebra over the covers it produces lives in the MOC kernel; the MOC coverer's public entry point, polygons_to_morton_mocs, lives in mortie.batch (issues #170, #187 — the scalar morton_coverage_moc retired with the plural batch names; the one-ring-set multipart form is reached through from_geometry / from_wkb / from_wkt with moc=True, or mortie.Moc).

The ring-validity checks below report whether any documented winding convention is in play for a ring before covering it — see Ring validity for the narrative.

Polygon-to-morton coverage.

Compute the set of morton indices at a given order that completely cover a polygon defined by lat/lon vertices. Supports single and multipart polygons.

Set algebra over the covers this module produces — union / intersection / difference, the canonical compaction, the densify back to a flat single order — lives in :mod:mortie._moc, split out by domain (issue #156). It is all computed in Rust; there is no Python-level MOC set algebra in either module. The batch-native MOC coverer, :func:~mortie.batch.polygons_to_morton_mocs, lives in :mod:mortie.batch (issue #170), and since issue #187 it is the operation's only public entry point: the scalar morton_coverage_moc retired to the private :func:_morton_coverage_moc kernel below, reached through :func:mortie.from_geometry / :func:mortie.from_wkb with moc=True. All three modules' names stay flat on the package (mortie.morton_coverage, mortie.moc_and, mortie.polygons_to_morton_mocs).

RingValidity = namedtuple('RingValidity', ['simple', 'identity_consistent']) module-attribute

Both ring-validity verdicts; see :func:ring_validity.

morton_coverage(lats, lons, order=18, normalize=True, *, latitude='authalic')

Compute morton indices covering a polygon defined by lat/lon vertices.

Given a polygon (as arrays of vertex latitudes and longitudes), returns the set of morton indices at the requested HEALPix order that completely cover the polygon interior. The coverage is optimally compact — it includes all boundary cells plus every cell whose centre lies inside the polygon.

For multipart polygons and holes, pass lats and lons as lists of rings. All rings are covered by a single even-odd descent: a cell is covered iff its centre is inside an odd number of rings. So disjoint outer rings are unioned (with no seam along shared interior borders), and a ring nested inside another carves a hole (a donut is [outer, hole]).

Not batch vectorized: one polygon (ring-set) per call, covered to one cover.

Parameters:

Name Type Description Default
lats array_like or list of array_like

Vertex latitudes in degrees. For a single polygon, a 1-D array with at least 3 vertices. For multipart polygons, a list of such arrays.

required
lons array_like or list of array_like

Vertex longitudes in degrees. Must match the structure of lats.

required
order int

HEALPix depth / tessellation order (1–29). Default 18.

18
normalize bool

Auto-correct ring orientation at ingest. Default True: any simple ring whose interior decisively reads as the larger region is reversed so the smaller region is the interior — S2's normalization convention (issue #144, decision (A)) — so CW and CCW spellings of a polygon give the same cover, hemisphere-plus polygons included. Pass False to trust the supplied vertex order exactly — the interior is taken as the region to the left of the directed edges with no reordering, so every ring, holes included, must be wound so its intended region lies to its left (see the Ring winding note); this is how a lone ring expresses a bigger-than-complement interior.

True
latitude str

Latitude convention of the input vertices (issue #186): "authalic" (default; geodetic latitudes are converted so cells are equal-area on the WGS84 ellipsoid) or "geodetic-spherical" (legacy: geodetic latitude fed to the spherical kernel as-is). Cell ids under the two conventions are non-corresponding partitions — never mix them.

'authalic'

Returns:

Type Description
ndarray

Sorted 1-D array of unique morton indices (dtype uint64).

Raises:

Type Description
ValueError

If fewer than 3 vertices, mismatched lengths, invalid order, or coordinates containing NaN/infinity.

Warns:

Type Description
UserWarning

If the returned flat cover exceeds ~1M cells. This is a best-effort, post-hoc signal — it fires only after the cover is materialized, so it does not prevent the blow-up: a flat cover's cell count grows as 4**order along the boundary, so a large polygon and/or a high order can materialize billions of cells and exhaust memory before the warning is reached. The hazard is the cell count, not the order alone. Treat large flat covers as a footgun and use a compact mixed-order MOC cover instead (:func:polygons_to_morton_mocs, or :func:mortie.from_geometry / :func:mortie.from_wkb with moc=True, optionally with a max_cells budget).

Notes
  • Self-intersecting polygons produce undefined results.
  • Holes are supported via the multipart form: pass [outer, hole, ...] (even-odd nesting carves the holes).
  • Ring winding. The interior is the region to the left of each directed edge. With normalize=True (default) the winding you author does not matter: any simple ring decisively enclosing the larger region is reversed at ingest, so the smaller side is taken either way (S2's convention; issue #144 decision (A)). Author per RFC 7946 §3.1.6 (CCW exteriors, CW holes) or any other way — the RFC's CW-hole spelling is what ingest delivers, not what it requires. With normalize=False nothing is reordered, so you must wind every ring so its intended region lies to its left — for a carved hole that means counter-clockwise, like its exterior, not the RFC's clockwise. A ring wound the other way selects its complement, which inverts the even-odd fill: a CW hole under normalize=False makes the "donut" larger than its own outer ring. That same complement is the only way a lone ring covers a region larger than its complement.
  • The point-in-polygon test is a single robust spherical winding-number backend (issue #22): it is correct at any polygon size, including hemisphere-plus polygons, and degeneracy-free when an edge's great circle passes through a HEALPix cell centre (issue #11).

Examples:

Single polygon:

>>> import mortie
>>> lats = [40.0, 50.0, 45.0]
>>> lons = [-120.0, -120.0, -110.0]
>>> cells = mortie.morton_coverage(lats, lons, order=6)

Multipart polygon:

>>> lats_parts = [[40.0, 50.0, 45.0], [10.0, 20.0, 15.0]]
>>> lons_parts = [[-120.0, -120.0, -110.0], [-80.0, -80.0, -70.0]]
>>> cells = mortie.morton_coverage(lats_parts, lons_parts, order=6)
Source code in mortie/coverage.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
def morton_coverage(lats, lons, order=18, normalize=True, *,
                    latitude="authalic"):
    """Compute morton indices covering a polygon defined by lat/lon vertices.

    Given a polygon (as arrays of vertex latitudes and longitudes), returns the
    set of morton indices at the requested HEALPix order that completely cover
    the polygon interior.  The coverage is optimally compact — it includes all
    boundary cells plus every cell whose centre lies inside the polygon.

    For **multipart polygons and holes**, pass *lats* and *lons* as lists of
    rings.  All rings are covered by a single even-odd descent: a cell is
    covered iff its centre is inside an *odd* number of rings.  So disjoint
    outer rings are unioned (with no seam along shared interior borders), and a
    ring nested inside another carves a **hole** (a donut is ``[outer, hole]``).

    **Not batch vectorized**: one polygon (ring-set) per call, covered to one
    cover.

    Parameters
    ----------
    lats : array_like or list of array_like
        Vertex latitudes in degrees.  For a single polygon, a 1-D array
        with at least 3 vertices.  For multipart polygons, a list of
        such arrays.
    lons : array_like or list of array_like
        Vertex longitudes in degrees.  Must match the structure of *lats*.
    order : int, optional
        HEALPix depth / tessellation order (1–29).  Default 18.
    normalize : bool, optional
        Auto-correct ring orientation at ingest.  Default ``True``: any
        *simple* ring whose interior decisively reads as the larger region is
        reversed so the smaller region is the interior — S2's normalization
        convention (issue #144, decision (A)) — so CW and CCW spellings of a
        polygon give the same cover, hemisphere-plus polygons included.  Pass
        ``False`` to **trust the supplied vertex order exactly** — the interior
        is taken as the region to the left of the directed edges with no
        reordering, so *every* ring, holes included, must be wound so its
        intended region lies to its left (see the **Ring winding** note); this
        is how a lone ring expresses a bigger-than-complement interior.
    latitude : str, optional
        Latitude convention of the input vertices (issue #186):
        ``"authalic"`` (default; geodetic latitudes are converted so cells
        are equal-area on the WGS84 ellipsoid) or ``"geodetic-spherical"``
        (legacy: geodetic latitude fed to the spherical kernel as-is).  Cell
        ids under the two conventions are non-corresponding partitions —
        never mix them.

    Returns
    -------
    numpy.ndarray
        Sorted 1-D array of unique morton indices (dtype ``uint64``).

    Raises
    ------
    ValueError
        If fewer than 3 vertices, mismatched lengths, invalid order,
        or coordinates containing NaN/infinity.

    Warns
    -----
    UserWarning
        If the returned flat cover exceeds ~1M cells.  This is a **best-effort,
        post-hoc** signal — it fires only *after* the cover is materialized, so
        it does not prevent the blow-up: a flat cover's cell count grows as
        ``4**order`` along the boundary, so a large polygon and/or a high order
        can materialize billions of cells and exhaust memory *before* the
        warning is reached.  The hazard is the *cell count*, not the order
        alone.  Treat large flat covers as a footgun and use a compact
        mixed-order MOC cover instead (:func:`polygons_to_morton_mocs`, or
        :func:`mortie.from_geometry` / :func:`mortie.from_wkb` with
        ``moc=True``, optionally with a ``max_cells`` budget).

    Notes
    -----
    - Self-intersecting polygons produce undefined results.
    - Holes are supported via the multipart form: pass ``[outer, hole, ...]``
      (even-odd nesting carves the holes).
    - **Ring winding.**  The interior is the region to the **left** of each
      directed edge.  With ``normalize=True`` (default) the winding you author
      does not matter: any *simple* ring decisively enclosing the larger region
      is reversed at ingest, so the smaller side is taken either way (S2's
      convention; issue #144 decision (A)).  Author per RFC 7946 §3.1.6 (CCW
      exteriors, CW holes) or any other way — the RFC's CW-hole spelling is
      what ingest *delivers*, not what it requires.  With ``normalize=False``
      nothing is reordered, so you must wind **every** ring so its intended
      region lies to its left — for a carved hole that means
      counter-clockwise, like its exterior, *not* the RFC's clockwise.  A ring
      wound the other way selects its complement, which inverts the even-odd
      fill: a CW hole under ``normalize=False`` makes the "donut" larger than
      its own outer ring.  That same complement is the *only* way a lone ring
      covers a region larger than its complement.
    - The point-in-polygon test is a single robust spherical winding-number
      backend (issue #22): it is correct at any polygon size, including
      hemisphere-plus polygons, and degeneracy-free when an edge's great circle
      passes through a HEALPix cell centre (issue #11).

    Examples
    --------
    Single polygon:

    >>> import mortie
    >>> lats = [40.0, 50.0, 45.0]
    >>> lons = [-120.0, -120.0, -110.0]
    >>> cells = mortie.morton_coverage(lats, lons, order=6)

    Multipart polygon:

    >>> lats_parts = [[40.0, 50.0, 45.0], [10.0, 20.0, 15.0]]
    >>> lons_parts = [[-120.0, -120.0, -110.0], [-80.0, -80.0, -70.0]]
    >>> cells = mortie.morton_coverage(lats_parts, lons_parts, order=6)
    """
    if not 1 <= order <= 29:
        raise ValueError("Order must be between 1 and 29")

    if _is_multipart(lats):
        la, lo = _prep_rings(lats, lons)
        result = np.asarray(
            _rustie.rust_multipolygon_coverage(la, lo, order, normalize, latitude)
        )
    else:
        result = _single_coverage(lats, lons, order, normalize, latitude)

    _warn_large_flat(result.size, order)
    return result

ring_validity(lats, lons, *, latitude='authalic')

Both validity verdicts for a ring -- whether any convention is in play.

simple is :func:ring_is_simple's verdict (no transversal self-intersection); identity_consistent is True when no vertex coordinate recurs at non-adjacent positions (a bit-exact pinch or a revisited vertex makes the symbolic tie-break depend on vertex numbering, so verdicts at the pinch can legitimately change under rotation). Both True means every winding rule documented for coverage is exact for this ring, with no convention in play.

Rings failing either check are still accepted by every coverage function -- the verdicts say which documented convention resolves the ambiguity, not that the cover is wrong.

Not batch vectorized: one ring per call.

Parameters:

Name Type Description Default
lats array_like

Vertex latitudes / longitudes in degrees (single ring, open or closed).

required
lons array_like

Vertex latitudes / longitudes in degrees (single ring, open or closed).

required
latitude str

Latitude convention of the input vertices; see :func:morton_coverage (issue #186). The verdicts are evaluated on the same kernel-frame geometry coverage uses under this convention.

'authalic'

Returns:

Type Description
RingValidity

Named tuple (simple, identity_consistent) of booleans.

Raises:

Type Description
ValueError

If fewer than 3 vertices, mismatched lengths, or coordinates containing NaN/infinity.

Examples:

>>> import mortie
>>> mortie.ring_validity([0, 10, 10, 0], [0, 0, 10, 10])
RingValidity(simple=True, identity_consistent=True)
>>> mortie.ring_validity([0, 10, 0, 10], [0, 0, 10, 10]).simple
False
Source code in mortie/coverage.py
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
def ring_validity(lats, lons, *, latitude="authalic"):
    """Both validity verdicts for a ring -- whether any convention is in play.

    ``simple`` is :func:`ring_is_simple`'s verdict (no transversal
    self-intersection); ``identity_consistent`` is ``True`` when no vertex
    coordinate recurs at non-adjacent positions (a bit-exact pinch or a
    revisited vertex makes the symbolic tie-break depend on vertex
    *numbering*, so verdicts at the pinch can legitimately change under
    rotation).  Both ``True`` means every winding rule documented for
    coverage is exact for this ring, with no convention in play.

    Rings failing either check are still accepted by every coverage
    function -- the verdicts say which documented convention resolves the
    ambiguity, not that the cover is wrong.

    **Not batch vectorized**: one ring per call.

    Parameters
    ----------
    lats, lons : array_like
        Vertex latitudes / longitudes in degrees (single ring, open or
        closed).
    latitude : str, optional
        Latitude convention of the input vertices; see
        :func:`morton_coverage` (issue #186).  The verdicts are evaluated on
        the same kernel-frame geometry coverage uses under this convention.

    Returns
    -------
    RingValidity
        Named tuple ``(simple, identity_consistent)`` of booleans.

    Raises
    ------
    ValueError
        If fewer than 3 vertices, mismatched lengths, or coordinates
        containing NaN/infinity.

    Examples
    --------
    >>> import mortie
    >>> mortie.ring_validity([0, 10, 10, 0], [0, 0, 10, 10])
    RingValidity(simple=True, identity_consistent=True)
    >>> mortie.ring_validity([0, 10, 0, 10], [0, 0, 10, 10]).simple
    False
    """
    lats = np.asarray(lats, dtype=np.float64).ravel()
    lons = np.asarray(lons, dtype=np.float64).ravel()
    if lats.shape != lons.shape:
        raise ValueError("lats and lons must have the same length")
    if lats.size < 3:
        raise ValueError("Need at least 3 vertices for a polygon")
    if not np.all(np.isfinite(lats)) or not np.all(np.isfinite(lons)):
        raise ValueError("lats and lons must not contain NaN or infinity")
    flags = np.asarray(_rustie.rust_ring_validity(lats, lons, latitude))
    return RingValidity(
        simple=not bool(flags[0]), identity_consistent=not bool(flags[1])
    )

ring_is_simple(lats, lons, *, latitude='authalic')

Whether this polygon ring is free of transversal self-intersections.

A ring whose edges cross has no single right-hand-rule interior, so mortie falls back to a documented convention (the positively wound region) and ingest normalization declines to trust the Gauss-Bonnet turning sign fully. This check tells you before covering whether that convention is in play: True means no two non-adjacent edges cross transversally, and the winding rules read exactly on the geometry as given.

It answers the crossing half of the question only. A bit-exact pinch -- one coordinate repeated at non-adjacent positions -- is resolved by the symbolic identity convention instead, whose verdict can flip with where the vertex list starts (of 400 randomly pinched rings, 17 flip under a rotation of their vertices). The full "is any convention in play?" question therefore needs the second verdict too: sphere::ring_set_identity_conflict, returned alongside this one by the Rust sphere::ring_set_validity — or, from Python, the combined :func:ring_validity, which returns both verdicts.

The check is the S2-architecture bucketed transversal-crossing test (issue #145): edges are indexed into adaptively refined HEALPix cells and only co-located, non-adjacent pairs are tested with the exact crossing predicate -- no sweep line, O(V log V)-ish on realistic rings. Consecutive duplicate vertices and a duplicated closing vertex are handled exactly as coverage ingest handles them.

Not batch vectorized: one ring per call.

Parameters:

Name Type Description Default
lats array_like

Vertex latitudes / longitudes in degrees (single ring, open or closed).

required
lons array_like

Vertex latitudes / longitudes in degrees (single ring, open or closed).

required
latitude str

Latitude convention of the input vertices; see :func:morton_coverage (issue #186). The verdict is evaluated on the same kernel-frame geometry coverage uses under this convention.

'authalic'

Returns:

Type Description
bool

True when no two non-adjacent edges cross.

Raises:

Type Description
ValueError

If lats and lons have different lengths, there are fewer than 3 vertices, or a coordinate is NaN/infinity.

Examples:

>>> import mortie
>>> mortie.ring_is_simple([0, 10, 0, 10], [0, 0, 10, 10])   # a bowtie
False
>>> mortie.ring_is_simple([0, 10, 10, 0], [0, 0, 10, 10])   # untangled
True
Source code in mortie/coverage.py
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
def ring_is_simple(lats, lons, *, latitude="authalic"):
    """Whether this polygon ring is free of transversal self-intersections.

    A ring whose edges cross has no single right-hand-rule interior, so
    mortie falls back to a documented convention (the positively wound
    region) and ingest normalization declines to trust the Gauss-Bonnet
    turning sign fully.  This check tells you *before* covering whether
    that convention is in play: ``True`` means no two non-adjacent edges
    cross transversally, and the winding rules read exactly on the
    geometry as given.

    It answers the crossing half of the question only.  A *bit-exact
    pinch* -- one coordinate repeated at non-adjacent positions -- is
    resolved by the symbolic identity convention instead, whose verdict
    can flip with where the vertex list starts (of 400 randomly pinched
    rings, 17 flip under a rotation of their vertices).  The full "is any
    convention in play?" question therefore needs the second verdict too:
    ``sphere::ring_set_identity_conflict``, returned alongside this one by
    the Rust ``sphere::ring_set_validity`` — or, from Python, the combined
    :func:`ring_validity`, which returns both verdicts.

    The check is the S2-architecture bucketed transversal-crossing test
    (issue #145): edges are indexed into adaptively refined HEALPix cells
    and only co-located, non-adjacent pairs are tested with the exact
    crossing predicate -- no sweep line, ``O(V log V)``-ish on realistic
    rings.  Consecutive duplicate vertices and a duplicated closing vertex
    are handled exactly as coverage ingest handles them.

    **Not batch vectorized**: one ring per call.

    Parameters
    ----------
    lats, lons : array_like
        Vertex latitudes / longitudes in degrees (single ring, open or
        closed).
    latitude : str, optional
        Latitude convention of the input vertices; see
        :func:`morton_coverage` (issue #186).  The verdict is evaluated on
        the same kernel-frame geometry coverage uses under this convention.

    Returns
    -------
    bool
        ``True`` when no two non-adjacent edges cross.

    Raises
    ------
    ValueError
        If lats and lons have different lengths, there are fewer than 3
        vertices, or a coordinate is NaN/infinity.

    Examples
    --------
    >>> import mortie
    >>> mortie.ring_is_simple([0, 10, 0, 10], [0, 0, 10, 10])   # a bowtie
    False
    >>> mortie.ring_is_simple([0, 10, 10, 0], [0, 0, 10, 10])   # untangled
    True
    """
    lats = np.asarray(lats, dtype=np.float64).ravel()
    lons = np.asarray(lons, dtype=np.float64).ravel()
    if lats.shape != lons.shape:
        raise ValueError("lats and lons must have the same length")
    if lats.size < 3:
        raise ValueError("Need at least 3 vertices for a polygon")
    if not np.all(np.isfinite(lats)) or not np.all(np.isfinite(lons)):
        raise ValueError("lats and lons must not contain NaN or infinity")
    return np.asarray(_rustie.rust_ring_is_simple(lats, lons, latitude)).size == 0