Skip to content

mortie.coverage

Polygon-to-morton coverage and the MOC (multi-order coverage) set algebra.

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 covers (union / intersection / difference) is done in Rust via :func:moc_or, :func:moc_and, and :func:moc_minus (healpix-crate BMOC), with :func:compress_moc for the canonical compaction — there is no Python-level MOC set algebra here.

moc_min = common_ancestor module-attribute

morton_coverage(lats, lons, order=18, normalize=True)

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]).

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

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 :func:morton_coverage_moc (optionally with its max_cells budget) for a compact mixed-order cover instead.

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
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
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
def morton_coverage(lats, lons, order=18, normalize=True):
    """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]``).

    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.

    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
        :func:`morton_coverage_moc` (optionally with its ``max_cells`` budget)
        for a compact mixed-order cover instead.

    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)
        )
    else:
        result = _single_coverage(lats, lons, order, normalize)

    _warn_large_flat(result.size, order)
    return result

morton_coverage_moc(lats, lons, order=18, tolerance=None, max_cells=None, normalize=True)

Compute polygon coverage as a compact Multi-Order Coverage (MOC) map.

Unlike :func:morton_coverage, which returns a flat list of cells all at order, this returns a mixed-order set: coarse cells for the interior and fine cells (down to order) along the boundary. Because a mortie morton index self-encodes its order, the result is still a 1-D uint64 array — typically far smaller than the flat cover.

Optional adaptive stop criteria (mutually exclusive) trade boundary precision for fewer cells and faster runtime — the tolerance and max_cells parameters below.

For multipart / holes (lists of rings), all rings are covered by one even-odd descent — disjoint parts union with no internal seam, and nested rings carve holes (a donut is [outer, hole]).

Parameters:

Name Type Description Default
lats array_like

Vertex latitudes / longitudes in degrees (single polygon ring), or a list of such arrays for the multipart form.

required
lons array_like

Vertex latitudes / longitudes in degrees (single polygon ring), or a list of such arrays for the multipart form.

required
order int

Finest HEALPix order (1–29). Default 18.

18
tolerance float

Stop refining a boundary cell once its angular radius (in degrees) drops to this value, even if coarser than order. Approximate, coarser boundary.

None
max_cells int

Best-first budget: refine the largest boundary cells until about this many cells, giving an adaptive mixed-order boundary (fine where it wiggles, coarse where it is straight). Soft target.

None
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 covered, matching S2's normalization (issue

144, decision (A)). Pass False to trust the supplied winding

exactly — the escape hatch for covering a big-side interior with a lone ring.

True

Returns:

Type Description
ndarray

Sorted 1-D array of mixed-order morton indices (uint64).

Raises:

Type Description
ValueError

If order lies outside 1-29, both tolerance and max_cells are given, the multipart form's lats and lons hold different numbers of rings, a ring's lats and lons have different lengths, a ring has fewer than 3 vertices, or a coordinate is NaN/infinity.

See Also

morton_coverage : flat single-order cover. compress_moc : merge 4-sibling groups in an existing morton set.

Source code in mortie/coverage.py
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
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
def morton_coverage_moc(lats, lons, order=18, tolerance=None, max_cells=None,
                        normalize=True):
    """Compute polygon coverage as a compact Multi-Order Coverage (MOC) map.

    Unlike :func:`morton_coverage`, which returns a flat list of cells all at
    ``order``, this returns a *mixed-order* set: coarse cells for the interior
    and fine cells (down to ``order``) along the boundary.  Because a mortie
    morton index self-encodes its order, the result is still a 1-D ``uint64``
    array — typically far smaller than the flat cover.

    Optional **adaptive stop criteria** (mutually exclusive) trade boundary
    precision for fewer cells and faster runtime — the ``tolerance`` and
    ``max_cells`` parameters below.

    For **multipart / holes** (lists of rings), all rings are covered by one
    even-odd descent — disjoint parts union with no internal seam, and nested
    rings carve holes (a donut is ``[outer, hole]``).

    Parameters
    ----------
    lats, lons : array_like
        Vertex latitudes / longitudes in degrees (single polygon ring), or a
        list of such arrays for the multipart form.
    order : int, optional
        Finest HEALPix order (1–29).  Default 18.
    tolerance : float, optional
        Stop refining a boundary cell once its angular radius (in **degrees**)
        drops to this value, even if coarser than ``order``.  Approximate,
        coarser boundary.
    max_cells : int, optional
        Best-first budget: refine the largest boundary cells until about this
        many cells, giving an adaptive mixed-order boundary (fine where it
        wiggles, coarse where it is straight).  Soft target.
    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 covered, matching S2's normalization (issue
        #144, decision (A)).  Pass False to trust the supplied winding
        exactly — the escape hatch for covering a big-side interior with a
        lone ring.

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

    Raises
    ------
    ValueError
        If ``order`` lies outside 1-29, both ``tolerance`` and ``max_cells``
        are given, the multipart form's lats and lons hold different numbers
        of rings, a ring's lats and lons have different lengths, a ring has
        fewer than 3 vertices, or a coordinate is NaN/infinity.

    See Also
    --------
    morton_coverage : flat single-order cover.
    compress_moc : merge 4-sibling groups in an existing morton set.
    """
    if not 1 <= order <= 29:
        raise ValueError("Order must be between 1 and 29")
    if tolerance is not None and max_cells is not None:
        raise ValueError("pass at most one of tolerance / max_cells")

    if _is_multipart(lats):
        la, lo = _prep_rings(lats, lons)
        tol_rad = None if tolerance is None else np.radians(float(tolerance))
        return np.asarray(
            _rustie.rust_multipolygon_coverage_moc(
                la, lo, order, tol_rad, max_cells, normalize
            )
        )

    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")
    if lats[0] == lats[-1] and lons[0] == lons[-1] and lats.size > 3:
        lats = lats[:-1].copy()
        lons = lons[:-1].copy()

    tol_rad = None if tolerance is None else np.radians(float(tolerance))

    return np.asarray(
        _rustie.rust_polygon_coverage_moc(
            lats, lons, order, tol_rad, max_cells, normalize
        )
    )

compress_moc(morton)

Compress a morton set into its canonical compact MOC.

Merges any 4 complete sibling cells into their parent (repeatedly) and drops any cell already contained in a coarser one. Use after unioning covers from several polygons / parts so that sibling groups spanning the seams collapse.

Parameters:

Name Type Description Default
morton array_like

Morton indices (mixed order allowed).

required

Returns:

Type Description
ndarray

Sorted, compacted morton indices (uint64).

Source code in mortie/coverage.py
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
def compress_moc(morton):
    """Compress a morton set into its canonical compact MOC.

    Merges any 4 complete sibling cells into their parent (repeatedly) and drops
    any cell already contained in a coarser one.  Use after unioning covers from
    several polygons / parts so that sibling groups spanning the seams collapse.

    Parameters
    ----------
    morton : array_like
        Morton indices (mixed order allowed).

    Returns
    -------
    numpy.ndarray
        Sorted, compacted morton indices (``uint64``).
    """
    morton = np.asarray(morton, dtype=np.uint64).ravel()
    return np.asarray(_rustie.rust_moc_normalize(morton))

moc_to_order(morton, order, max_cells=_FLAT_COVER_WARN_THRESHOLD)

Densify a (mixed-order) morton set to a flat list at order.

Unlike :func:morton_coverage's post-hoc warning, the densify path can over-allocate to the point of OOM before any warning is reachable — a tiny compact MOC densifies to Σ 4**(order - depth) flat cells (issue #80). So this guards pre-emptively: an upper bound on the densified count is computed from the input set alone (an O(n) pass, no flat allocation) and, when it exceeds max_cells, a :class:ValueError is raised before materializing. The bound is exact unless morton holds cells finer than order (which coarsen and dedup on densify), where it is a safe over-count — so the guard never lets more than max_cells cells through.

Parameters:

Name Type Description Default
morton array_like

Morton indices (mixed order allowed).

required
order int

Target HEALPix order to densify to.

required
max_cells int or None

Pre-emptive budget on the densified flat cell count. Raises :class:ValueError if the estimate exceeds it (default 1 << 20 — the same ~1M-cell line as the flat-cover warning). Pass None to opt out and densify unconditionally.

_FLAT_COVER_WARN_THRESHOLD

Returns:

Type Description
ndarray

Sorted 1-D array of flat morton indices at order (uint64).

Raises:

Type Description
ValueError

If the estimated densified count exceeds max_cells.

See Also

morton_coverage : flat single-order cover (post-hoc large-cover warning).

Source code in mortie/coverage.py
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
546
547
548
549
550
551
552
553
554
555
556
557
558
559
def moc_to_order(morton, order, max_cells=_FLAT_COVER_WARN_THRESHOLD):
    """Densify a (mixed-order) morton set to a flat list at ``order``.

    Unlike :func:`morton_coverage`'s post-hoc warning, the densify path can
    over-allocate to the point of OOM before any warning is reachable — a tiny
    compact MOC densifies to ``Σ 4**(order - depth)`` flat cells (issue #80).
    So this guards **pre-emptively**: an upper bound on the densified count is
    computed from the input set alone (an O(n) pass, no flat allocation) and,
    when it exceeds ``max_cells``, a :class:`ValueError` is raised *before*
    materializing.  The bound is exact unless ``morton`` holds cells finer than
    ``order`` (which coarsen and dedup on densify), where it is a safe over-count
    — so the guard never lets more than ``max_cells`` cells through.

    Parameters
    ----------
    morton : array_like
        Morton indices (mixed order allowed).
    order : int
        Target HEALPix order to densify to.
    max_cells : int or None, optional
        Pre-emptive budget on the densified flat cell count.  Raises
        :class:`ValueError` if the estimate exceeds it (default
        ``1 << 20`` — the same ~1M-cell line as the flat-cover warning).  Pass
        ``None`` to opt out and densify unconditionally.

    Returns
    -------
    numpy.ndarray
        Sorted 1-D array of flat morton indices at ``order`` (``uint64``).

    Raises
    ------
    ValueError
        If the estimated densified count exceeds ``max_cells``.

    See Also
    --------
    morton_coverage : flat single-order cover (post-hoc large-cover warning).
    """
    morton = np.asarray(morton, dtype=np.uint64).ravel()
    if max_cells is not None:
        estimated = int(_rustie.rust_moc_to_order_count(morton, order))
        if estimated > max_cells:
            raise ValueError(
                f"moc_to_order would densify to ~{estimated} cells at order "
                f"{order}, exceeding max_cells={max_cells}. Pass a larger "
                f"max_cells, or max_cells=None to proceed (risking OOM), or "
                f"densify to a coarser order."
            )
    return np.asarray(_rustie.rust_moc_to_order(morton, order))

moc_or(a, b)

Union of two morton covers (the cells in a or b).

Equivalent to compress_moc(concatenate([a, b])), but computed by the healpix-crate BMOC or rather than a concatenate-then-compress pass.

Parameters:

Name Type Description Default
a array_like

Morton covers (mixed order allowed).

required
b array_like

Morton covers (mixed order allowed).

required

Returns:

Type Description
ndarray

Sorted, compacted union (uint64).

See Also

moc_and : intersection of two covers. moc_minus : difference a \ b. compress_moc : moc_or(a, b) == compress_moc(concatenate([a, b])).

Source code in mortie/coverage.py
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
def moc_or(a, b):
    r"""Union of two morton covers (the cells in ``a`` or ``b``).

    Equivalent to ``compress_moc(concatenate([a, b]))``, but computed by the
    healpix-crate BMOC ``or`` rather than a concatenate-then-compress pass.

    Parameters
    ----------
    a, b : array_like
        Morton covers (mixed order allowed).

    Returns
    -------
    numpy.ndarray
        Sorted, compacted union (``uint64``).

    See Also
    --------
    moc_and : intersection of two covers.
    moc_minus : difference ``a \ b``.
    compress_moc : ``moc_or(a, b) == compress_moc(concatenate([a, b]))``.
    """
    a = np.asarray(a, dtype=np.uint64).ravel()
    b = np.asarray(b, dtype=np.uint64).ravel()
    return np.asarray(_rustie.rust_moc_or(a, b))

moc_and(a, b)

Intersection of two morton covers (the cells in both a and b).

Parameters:

Name Type Description Default
a array_like

Morton covers (mixed order allowed).

required
b array_like

Morton covers (mixed order allowed).

required

Returns:

Type Description
ndarray

Sorted, compacted intersection (uint64).

See Also

moc_or : union of two covers. moc_minus : difference a \ b.

Source code in mortie/coverage.py
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
def moc_and(a, b):
    r"""Intersection of two morton covers (the cells in both ``a`` and ``b``).

    Parameters
    ----------
    a, b : array_like
        Morton covers (mixed order allowed).

    Returns
    -------
    numpy.ndarray
        Sorted, compacted intersection (``uint64``).

    See Also
    --------
    moc_or : union of two covers.
    moc_minus : difference ``a \ b``.
    """
    a = np.asarray(a, dtype=np.uint64).ravel()
    b = np.asarray(b, dtype=np.uint64).ravel()
    return np.asarray(_rustie.rust_moc_and(a, b))

moc_minus(a, b)

Difference of two morton covers (the cells in a but not b).

Computes a \ b.

Parameters:

Name Type Description Default
a array_like

Morton covers (mixed order allowed).

required
b array_like

Morton covers (mixed order allowed).

required

Returns:

Type Description
ndarray

Sorted, compacted difference (uint64).

See Also

moc_or : union of two covers. moc_and : intersection of two covers.

Source code in mortie/coverage.py
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
def moc_minus(a, b):
    r"""Difference of two morton covers (the cells in ``a`` but not ``b``).

    Computes ``a \ b``.

    Parameters
    ----------
    a, b : array_like
        Morton covers (mixed order allowed).

    Returns
    -------
    numpy.ndarray
        Sorted, compacted difference (``uint64``).

    See Also
    --------
    moc_or : union of two covers.
    moc_and : intersection of two covers.
    """
    a = np.asarray(a, dtype=np.uint64).ravel()
    b = np.asarray(b, dtype=np.uint64).ravel()
    return np.asarray(_rustie.rust_moc_minus(a, b))

moc_xor(a, b)

Symmetric difference of two morton covers (cells in exactly one).

Computes a △ b — the cells in a or b but not both, i.e. moc_minus(moc_or(a, b), moc_and(a, b)). Useful for "what changed" between two coverages: against an earlier cover a and a later cover b, moc_xor is exactly the cells that gained or lost coverage.

Parameters:

Name Type Description Default
a array_like

Morton covers (mixed order allowed).

required
b array_like

Morton covers (mixed order allowed).

required

Returns:

Type Description
ndarray

Sorted, compacted symmetric difference (uint64).

See Also

moc_or : union of two covers. moc_and : intersection of two covers. moc_minus : difference a \ b (the directional half of xor).

Source code in mortie/coverage.py
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
def moc_xor(a, b):
    r"""Symmetric difference of two morton covers (cells in exactly one).

    Computes ``a △ b`` — the cells in ``a`` or ``b`` but not both, i.e.
    ``moc_minus(moc_or(a, b), moc_and(a, b))``.  Useful for "what changed"
    between two coverages: against an earlier cover ``a`` and a later cover
    ``b``, ``moc_xor`` is exactly the cells that gained *or* lost coverage.

    Parameters
    ----------
    a, b : array_like
        Morton covers (mixed order allowed).

    Returns
    -------
    numpy.ndarray
        Sorted, compacted symmetric difference (``uint64``).

    See Also
    --------
    moc_or : union of two covers.
    moc_and : intersection of two covers.
    moc_minus : difference ``a \ b`` (the directional half of ``xor``).
    """
    a = np.asarray(a, dtype=np.uint64).ravel()
    b = np.asarray(b, dtype=np.uint64).ravel()
    return np.asarray(_rustie.rust_moc_xor(a, b))

moc_not(cover, domain=None)

Complement a morton cover within a domain.

The result is the cells in domain but not cover. A complement is only well-defined relative to a bounded domain, so moc_not is a domain-bounded difference: it returns domain \ cover, i.e. moc_minus(domain, cover).

Parameters:

Name Type Description Default
cover array_like

The morton cover to complement (mixed order allowed).

required
domain array_like

The morton cover to complement within. A single morton index or a list/array of them (e.g. a coarse "shard" cell whose finer cells are enumerated in cover). Defaults to the whole sphere — the 12 order-0 base cells.

None

Returns:

Type Description
ndarray

Sorted, compacted complement domain \ cover (uint64).

Warns:

Type Description
UserWarning

If cover contains cells outside domain. Such cells cannot be complemented within the domain, so they are clipped: the result is domain \ (cover ∩ domain), which equals domain \ cover whenever cover ⊆ domain.

See Also

moc_minus : difference a \ b (moc_not is moc_minus against a domain, with the whole-sphere default and an out-of-domain warning).

Examples:

The shard case — a coarse cell with some finer cells enumerated inside it, asking for the finer cells not yet enumerated within the shard:

>>> import mortie
>>> shard = mortie.norm2mort(0, 0, 0)          # one order-0 base cell
>>> enumerated = mortie.morton_coverage_moc(lats, lons, order=6)
>>> gaps = mortie.moc_not(enumerated, domain=shard)
Source code in mortie/coverage.py
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
def moc_not(cover, domain=None):
    r"""Complement a morton cover within a domain.

    The result is the cells in ``domain`` but not ``cover``. A complement is
    only well-defined relative to a bounded domain, so ``moc_not`` is a
    domain-bounded difference: it returns ``domain \ cover``, i.e.
    ``moc_minus(domain, cover)``.

    Parameters
    ----------
    cover : array_like
        The morton cover to complement (mixed order allowed).
    domain : array_like, optional
        The morton cover to complement *within*.  A single morton index or a
        list/array of them (e.g. a coarse "shard" cell whose finer cells are
        enumerated in ``cover``).  Defaults to the whole sphere — the 12 order-0
        base cells.

    Returns
    -------
    numpy.ndarray
        Sorted, compacted complement ``domain \ cover`` (``uint64``).

    Warns
    -----
    UserWarning
        If ``cover`` contains cells outside ``domain``.  Such cells cannot be
        complemented within the domain, so they are **clipped**: the result is
        ``domain \ (cover ∩ domain)``, which equals ``domain \ cover`` whenever
        ``cover ⊆ domain``.

    See Also
    --------
    moc_minus : difference ``a \ b`` (``moc_not`` is ``moc_minus`` against a
        domain, with the whole-sphere default and an out-of-domain warning).

    Examples
    --------
    The shard case — a coarse cell with some finer cells enumerated inside it,
    asking for the finer cells *not* yet enumerated within the shard:

    >>> import mortie
    >>> shard = mortie.norm2mort(0, 0, 0)          # one order-0 base cell
    >>> enumerated = mortie.morton_coverage_moc(lats, lons, order=6)  # doctest: +SKIP
    >>> gaps = mortie.moc_not(enumerated, domain=shard)               # doctest: +SKIP
    """
    cover = np.asarray(cover, dtype=np.uint64).ravel()
    if domain is None:
        domain = _whole_sphere()
    else:
        domain = np.asarray(domain, dtype=np.uint64).ravel()

    if domain.size == 0:
        # The complement within an empty domain is empty for any cover; the
        # out-of-domain warning would be vacuously true, so skip it.
        return np.asarray([], dtype=np.uint64)

    # Cells of `cover` outside `domain` cannot be complemented within it; warn
    # and clip them (the clip is implicit in `moc_minus(domain, cover)`, which
    # only ever subtracts the in-domain part of `cover`).
    if moc_minus(cover, domain).size > 0:
        warnings.warn(
            "moc_not: `cover` has cells outside `domain`; they cannot be "
            "complemented within the domain and are clipped away.",
            stacklevel=2,
        )

    return moc_minus(domain, cover)

common_ancestor(morton)

Deepest common ancestor (highest-order common parent) of a morton set.

The array-reduction sibling of :func:clip2order (coarsen): where coarsening lowers each word to a caller-given order, common_ancestor discovers the deepest order at which the whole input collapses to a single enclosing cell, and returns that one cell. Because a packed morton word self-encodes its order and ancestry, this is the longest shared path prefix after the common base cell, capped at each word's own order — so mixed-order input is fine (each word is capped at its own order).

Parameters:

Name Type Description Default
morton array_like

Morton indices (mixed order allowed). A single index returns itself.

required

Returns:

Type Description
uint64

The packed morton index of the deepest cell that contains every input. A batch (more than one input) always yields an area cell — even when the inputs collapse to a single order-29 cell, since the shared cell is an enclosing area, not any one input point. Only a single-element input is returned unchanged (its area/point kind preserved), so a lone area or point returns itself.

Raises:

Type Description
ValueError

If morton is empty, contains an empty/invalid word, or spans more than one HEALPix base cell — there is then no common ancestor above the (non-existent) whole-sphere root.

See Also

clip2order : coarsen each word to a fixed order (the elementwise form; common_ancestor is its reduce-by-common-coarsening reduction).

Examples:

The four order-5 children of an order-4 cell reduce to that parent:

>>> import mortie, numpy as np
>>> parent = mortie.norm2mort(11, 0, 4)              # one order-4 cell in base 0
>>> kids = mortie.norm2mort([11 * 4 + s for s in range(4)], [0] * 4, 5)
>>> int(mortie.common_ancestor(kids)) == int(parent)
True
Source code in mortie/coverage.py
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
def common_ancestor(morton):
    """Deepest common ancestor (highest-order common parent) of a morton set.

    The array-reduction sibling of :func:`clip2order` (coarsen): where coarsening
    lowers each word to a *caller-given* order, ``common_ancestor`` *discovers*
    the deepest order at which the whole input collapses to a single enclosing
    cell, and returns that one cell.  Because a packed morton word self-encodes
    its order and ancestry, this is the longest shared path prefix after the
    common base cell, capped at each word's own order — so mixed-order input is
    fine (each word is capped at its own order).

    Parameters
    ----------
    morton : array_like
        Morton indices (mixed order allowed).  A single index returns itself.

    Returns
    -------
    numpy.uint64
        The packed morton index of the deepest cell that contains every input.
        A batch (more than one input) always yields an **area** cell — even when
        the inputs collapse to a single order-29 cell, since the shared cell is
        an enclosing area, not any one input point.  Only a single-element input
        is returned unchanged (its area/point kind preserved), so a lone area or
        point returns itself.

    Raises
    ------
    ValueError
        If ``morton`` is empty, contains an empty/invalid word, or spans more
        than one HEALPix base cell — there is then no common ancestor above the
        (non-existent) whole-sphere root.

    See Also
    --------
    clip2order : coarsen each word to a fixed order (the elementwise form;
        ``common_ancestor`` is its reduce-by-common-coarsening reduction).

    Examples
    --------
    The four order-5 children of an order-4 cell reduce to that parent:

    >>> import mortie, numpy as np
    >>> parent = mortie.norm2mort(11, 0, 4)              # one order-4 cell in base 0
    >>> kids = mortie.norm2mort([11 * 4 + s for s in range(4)], [0] * 4, 5)
    >>> int(mortie.common_ancestor(kids)) == int(parent)
    True
    """
    morton = np.asarray(morton, dtype=np.uint64).ravel()
    return np.uint64(_rustie.rust_moc_min(morton))

split_base_cells(words, sort=False)

Partition a morton set by HEALPix base cell.

Each group is keyed by its own :func:moc_min. The companion to :func:moc_min for the cross-base-cell case it refuses: where moc_min reduces a single base cell's words to one ancestor and raises on mixed base cells, split_base_cells groups the words by base cell and hands back each group untouched. Every group is keyed by its own moc_min — the deepest cell enclosing that group — which is self- describing (a packed word the same 64 bits wide as the data) and from which the base cell id is cheap to recover (e.g. mort2healpix / MortonIndexArray.base_cell).

Parameters:

Name Type Description Default
words array_like

Morton indices (mixed order and mixed base cell allowed).

required
sort bool

If False (default, the faster path) each group keeps the input order of its words. If True each group's words are sorted, giving a canonical MOC per base cell.

False

Returns:

Type Description
dict[int, ndarray]

Maps the int of each group's moc_min word to that group's uint64 array of words. Empty input returns {}; a single base cell returns a one-entry dict.

Raises:

Type Description
ValueError

If a group's moc_min reduction fails — e.g. words contains an empty/invalid word (moc_min rejects it).

See Also

moc_min : the single-base-cell reduction this partitions for; its mixed- base-cell error points here.

Examples:

>>> import mortie, numpy as np
>>> a = np.atleast_1d(mortie.norm2mort(0, 2, 4))   # one cell in base 2
>>> b = np.atleast_1d(mortie.norm2mort(0, 5, 4))   # one cell in base 5
>>> groups = mortie.split_base_cells(np.concatenate([a, b]))
>>> sorted(int(np.uint64(k) >> np.uint64(60)) - 1 for k in groups)
[2, 5]
Source code in mortie/coverage.py
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
def split_base_cells(words, sort=False):
    """Partition a morton set by HEALPix base cell.

    Each group is keyed by its own :func:`moc_min`.
    The companion to :func:`moc_min` for the cross-base-cell case it refuses:
    where ``moc_min`` reduces a *single* base cell's words to one ancestor and
    raises on mixed base cells, ``split_base_cells`` groups the words by base
    cell and hands back each group untouched.  Every group is keyed by its own
    ``moc_min`` — the deepest cell enclosing that group — which is self-
    describing (a packed word the same 64 bits wide as the data) and from which
    the base cell id is cheap to recover (e.g. ``mort2healpix`` /
    ``MortonIndexArray.base_cell``).

    Parameters
    ----------
    words : array_like
        Morton indices (mixed order and mixed base cell allowed).
    sort : bool, optional
        If ``False`` (default, the faster path) each group keeps the input
        order of its words.  If ``True`` each group's words are sorted, giving a
        canonical MOC per base cell.

    Returns
    -------
    dict[int, numpy.ndarray]
        Maps the ``int`` of each group's ``moc_min`` word to that group's
        ``uint64`` array of words.  Empty input returns ``{}``; a single base
        cell returns a one-entry dict.

    Raises
    ------
    ValueError
        If a group's ``moc_min`` reduction fails — e.g. ``words`` contains an
        empty/invalid word (``moc_min`` rejects it).

    See Also
    --------
    moc_min : the single-base-cell reduction this partitions for; its mixed-
        base-cell error points here.

    Examples
    --------
    >>> import mortie, numpy as np
    >>> a = np.atleast_1d(mortie.norm2mort(0, 2, 4))   # one cell in base 2
    >>> b = np.atleast_1d(mortie.norm2mort(0, 5, 4))   # one cell in base 5
    >>> groups = mortie.split_base_cells(np.concatenate([a, b]))
    >>> sorted(int(np.uint64(k) >> np.uint64(60)) - 1 for k in groups)
    [2, 5]
    """
    words = np.asarray(words, dtype=np.uint64).ravel()
    if words.size == 0:
        return {}

    bases = _rustie.rust_mi_base_cell_of(words)
    out = {}
    # Stable group-by: dict.fromkeys yields base cells in first-seen order, and
    # the boolean mask below keeps each group's words in input order.
    for base in dict.fromkeys(bases.tolist()):
        group = words[bases == base]
        if sort:
            group = np.sort(group)
        out[int(moc_min(group))] = group
    return out