Ellipse Intersections and Locating a Moose Between Fixes

Mathematics
Geometry
Computational Geometry
Python
Shapely
Wildlife Ecology
Movement Ecology
GPS Telemetry
Moose
Time Geography
Author

Galen Seilis

Published

October 12, 2026

A GPS collar on a moose hands us a sequence of points: a latitude, a longitude, and a time. What it does not hand us is the path between those points. Between two consecutive fixes the moose could have wandered, rested, circled back, or run in a straight line, and all we observe is where it was at the two endpoints.

This is a familiar gap in movement ecology, and there is a classical geometric answer to a sharply-posed version of the question: if the moose can move no faster than some maximum speed, where could it possibly have been between the two fixes? The answer is an ellipse. And once we have ellipses for two animals, intersecting them gives a cheap first test of whether those animals could have crossed paths at all.

This post works through the geometry, the code, and the honesty required: the whole construction leans on an assumed maximum speed, and that assumption deserves scrutiny.

The ellipse between two fixes

Let a moose be observed at position \(x_0\) at time \(t_0\) and at position \(x_1\) at time \(t_1\). Write the interval length as

\[\Delta t = t_1 - t_0.\]

Suppose the moose can never travel faster than a maximum speed \(v_{\max}\). A point \(p\) was possible for the moose at some moment in the interval only if there is a route from \(x_0\), through \(p\), then on to \(x_1\), whose total length fits in the time available. The shortest such route is two straight segments, so the condition is

\[\lVert p - x_0 \rVert + \lVert p - x_1 \rVert \le v_{\max}\,\Delta t.\]

That inequality is precisely the definition of the interior of an ellipse with foci at the two fixes. The sum of distances to the foci is constant, and here the constant is the total distance the moose can cover. So the set of possible positions is a filled ellipse whose foci are the two fixes, with

\[2a = v_{\max}\,\Delta t, \qquad c = \frac{\lVert x_1 - x_0 \rVert}{2}, \qquad b = \sqrt{a^2 - c^2},\]

where \(a\) is the semi-major axis, \(c\) the focal distance, and \(b\) the semi-minor axis. The major axis lies along the line joining the fixes. This object has a name in time geography: the potential path area for a single time step. It is a region of possibility, not a probability distribution, and not a path.

Some immediate consequences fall out of the formula. If \(v_{\max}\,\Delta t < \lVert x_1 - x_0 \rVert\) then the inequality can never hold and the ellipse does not exist: the fixes are mutually inconsistent with the speed assumption, which usually means the speed bound is too low or the fixes carry enough error to make the implied speed misleading. And as \(\Delta t\) grows, the ellipse grows linearly in its long direction, so the method loses power exactly when fixes are sparse.

Drawing and intersecting ellipses

Analytic ellipse intersections are fiddly, but computational geometry libraries handle them directly. The script below builds an ellipse by buffering a point to a unit disc and then scaling, rotating, and translating it with Shapely, computes the foci, and takes the set operations we care about: intersection for the shared area, difference for the exclusive areas.

"""Two arbitrarily oriented ellipses that share a focus.

This is the worked example from the first half of the blog post: build one
ellipse, take one of its foci, construct a second ellipse rotated to a
different angle that passes through that same focus, and then fill the
set-theoretic regions (each ellipse only, and the overlap).
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.axes import Axes
from shapely.geometry.base import BaseGeometry

from moose_ellipse.geometry import ellipse, ellipse_foci, plot_geometry


@dataclass(frozen=True)
class EllipseSpec:
    """The parameters that define one ellipse."""

    cx: float
    cy: float
    a: float
    b: float
    angle_deg: float


@dataclass(frozen=True)
class SharedFocusDemo:
    """The geometry produced by the shared-focus construction."""

    e1: BaseGeometry
    e2: BaseGeometry
    e1_spec: EllipseSpec
    e2_spec: EllipseSpec
    foci1: list[tuple[float, float]]
    foci2: list[tuple[float, float]]
    shared_focus: tuple[float, float]
    intersection: BaseGeometry
    e1_only: BaseGeometry
    e2_only: BaseGeometry


def build_shared_focus_demo(
    e1: EllipseSpec = EllipseSpec(0, 0, 5, 3, 20),
    e2_a: float = 4,
    e2_b: float = 2,
    e2_angle: float = -35,
) -> SharedFocusDemo:
    """Construct a pair of ellipses that share one focus.

    The first ellipse is placed at the origin. The second is translated so
    that its leading focus lands exactly on the first focus of the first
    ellipse, then the two are intersected.

    Parameters
    ----------
    e1:
        Parameters of the first ellipse.
    e2_a, e2_b:
        Semi-major and semi-minor axes of the second ellipse.
    e2_angle:
        Rotation of the second ellipse's major axis, in degrees.
    """
    first = ellipse(e1.cx, e1.cy, e1.a, e1.b, e1.angle_deg)
    foci1 = ellipse_foci(e1.cx, e1.cy, e1.a, e1.b, e1.angle_deg)

    shared_focus = foci1[0]

    # For an ellipse, c = sqrt(a^2 - b^2) is the centre-to-focus distance.
    # The shared focus sits at centre - c * direction, so the second centre
    # is shared_focus + c * direction along its major axis.
    c2 = math.sqrt(e2_a**2 - e2_b**2)
    theta2 = math.radians(e2_angle)
    direction2 = (math.cos(theta2), math.sin(theta2))

    e2_spec = EllipseSpec(
        cx=shared_focus[0] + c2 * direction2[0],
        cy=shared_focus[1] + c2 * direction2[1],
        a=e2_a,
        b=e2_b,
        angle_deg=e2_angle,
    )

    second = ellipse(e2_spec.cx, e2_spec.cy, e2_spec.a, e2_spec.b, e2_spec.angle_deg)
    foci2 = ellipse_foci(e2_spec.cx, e2_spec.cy, e2_spec.a, e2_spec.b, e2_spec.angle_deg)

    return SharedFocusDemo(
        e1=first,
        e2=second,
        e1_spec=e1,
        e2_spec=e2_spec,
        foci1=foci1,
        foci2=foci2,
        shared_focus=shared_focus,
        intersection=first.intersection(second),
        e1_only=first.difference(second),
        e2_only=second.difference(first),
    )


def _label_region(geometry: BaseGeometry, text: str, ax: Axes, **kwargs: object) -> None:
    if not geometry.is_empty:
        point = geometry.representative_point()
        ax.annotate(text, (point.x, point.y), ha="center", va="center", **kwargs)


def plot_shared_focus(demo: SharedFocusDemo, ax: Axes) -> None:
    """Draw the shared-focus demo on ``ax``."""
    e1_spec, e2_spec = demo.e1_spec, demo.e2_spec

    plot_geometry(demo.e1_only, ax, color="cornflowerblue", alpha=0.65)
    plot_geometry(demo.e2_only, ax, color="salmon", alpha=0.65)
    plot_geometry(demo.intersection, ax, color="mediumseagreen", alpha=0.85)

    for geometry, color, label in [
        (demo.e1, "blue", "Ellipse 1"),
        (demo.e2, "red", "Ellipse 2"),
    ]:
        x, y = geometry.exterior.xy
        ax.plot(x, y, color=color, linewidth=2, label=label)

    ax.scatter(
        e1_spec.cx, e1_spec.cy, color="blue", marker="+", s=100, linewidths=2, zorder=10
    )
    ax.scatter(
        e2_spec.cx, e2_spec.cy, color="red", marker="+", s=100, linewidths=2, zorder=10
    )

    for i, (x, y) in enumerate(demo.foci1, 1):
        ax.scatter(x, y, color="blue", marker="x", s=90, linewidths=2.5, zorder=11)
        if (x, y) != demo.shared_focus:
            ax.annotate(
                f"F{i} (E1)",
                (x, y),
                xytext=(8, 8),
                textcoords="offset points",
                color="darkblue",
                fontsize=10,
                fontweight="bold",
            )

    for i, (x, y) in enumerate(demo.foci2, 1):
        is_shared = math.isclose(x, demo.shared_focus[0], abs_tol=1e-9) and math.isclose(
            y, demo.shared_focus[1], abs_tol=1e-9
        )
        if is_shared:
            ax.scatter(
                x,
                y,
                color="purple",
                marker="*",
                s=180,
                edgecolor="black",
                linewidth=0.8,
                zorder=12,
                label="Shared focus",
            )
            ax.annotate(
                "Shared focus\n(E1 & E2)",
                (x, y),
                xytext=(10, -35),
                textcoords="offset points",
                color="purple",
                fontsize=10,
                fontweight="bold",
                ha="left",
            )
        else:
            ax.scatter(x, y, color="red", marker="x", s=90, linewidths=2.5, zorder=11)
            ax.annotate(
                f"F{i} (E2)",
                (x, y),
                xytext=(8, 8),
                textcoords="offset points",
                color="darkred",
                fontsize=10,
                fontweight="bold",
            )

    _label_region(
        demo.e1_only,
        f"Ellipse 1 only\n{demo.e1_only.area:.3f}",
        ax,
        fontsize=11,
        color="darkblue",
    )
    _label_region(
        demo.e2_only,
        f"Ellipse 2 only\n{demo.e2_only.area:.3f}",
        ax,
        fontsize=11,
        color="darkred",
    )
    _label_region(
        demo.intersection,
        f"Overlap\n{demo.intersection.area:.3f}",
        ax,
        fontsize=11,
        color="darkgreen",
        fontweight="bold",
    )

    ax.set_aspect("equal", adjustable="box")
    ax.set_xlabel("x")
    ax.set_ylabel("y")
    ax.set_title("Overlap of Two Arbitrarily Oriented Ellipses\nwith a Shared Focus")
    ax.legend()
    ax.grid(True, alpha=0.25)


def run(output: str | Path = "figures/ellipse_intersection.png") -> Path:
    """Render the shared-focus figure and return the written path."""
    demo = build_shared_focus_demo()

    print("Ellipse 1 foci:")
    for i, (x, y) in enumerate(demo.foci1, 1):
        print(f"  F{i}: ({x:.6f}, {y:.6f})")

    print("\nEllipse 2 foci:")
    for i, (x, y) in enumerate(demo.foci2, 1):
        print(f"  F{i}: ({x:.6f}, {y:.6f})")

    print(f"\nShared focus: ({demo.shared_focus[0]:.6f}, {demo.shared_focus[1]:.6f})")

    print(f"\nEllipse 1 area:       {demo.e1.area:.6f}")
    print(f"Ellipse 2 area:       {demo.e2.area:.6f}")
    print(f"Intersection area:    {demo.intersection.area:.6f}")
    print(f"Ellipse 1 only area:  {demo.e1_only.area:.6f}")
    print(f"Ellipse 2 only area:  {demo.e2_only.area:.6f}")

    fig, ax = plt.subplots(figsize=(10, 7))
    plot_shared_focus(demo, ax)
    plt.tight_layout()

    output_path = Path(output)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(output_path, dpi=600, bbox_inches="tight", facecolor="white")
    plt.close(fig)

    print(f"\nSaved plot to: {output_path}")
    return output_path


def main() -> None:
    """Console-script entry point."""
    run()


if __name__ == "__main__":
    main()

The example uses two arbitrarily oriented ellipses that happen to share a focus, and reports the areas of each ellipse only, each intersection, and the overlap. Sharing a focus is a nice special case to look at because the foci are the anchor points of the construction, and it makes the geometry easy to read off the figure.

Overlap of two arbitrarily oriented ellipses with a shared focus

For the moose question the shared-focus case has a natural interpretation: if two consecutive fixes of a single moose happened to coincide with one focus of two successive ellipses, then the two intervals are stitched together at that point. More generally, for a single animal we get a chain of ellipses, one per interval, and the union of those ellipses is the animal’s potential path area across the whole tracking period.

Two moose: could they have met?

Now give two moose each a pair of fixes over the same interval, and give each its own maximum speed. Moose A gets ellipse \(E_A\) and moose B gets \(E_B\). A first, purely spatial question:

  • If \(E_A \cap E_B = \varnothing\), then there is no location that both animals could have occupied during the interval, so under these assumptions a meeting is ruled out. This is a genuinely useful negative result.
  • If \(E_A \cap E_B \neq \varnothing\), then a shared location is possible, and it is tempting to conclude that an interaction was possible. That conclusion is too quick.

The reason is that the ellipse is time-agnostic. It is the union, over every moment in the interval, of the positions the animal could have occupied. Two animals can have overlapping ellipses while being at the same place at disjoint times, which is not an interaction at all.

The sharper question uses the time axis. A moose that must be at \(x_0\) at \(t_0\) and at \(x_1\) at \(t_1\) can be at a point \(p\) at time \(t\) only if it can get there and still get to \(x_1\) afterwards:

\[t_0 + \frac{\lVert p - x_0 \rVert}{v_{\max}} \;\le\; t \;\le\; t_1 - \frac{\lVert p - x_1 \rVert}{v_{\max}}.\]

That is a window of possible presence at \(p\). The two moose could have met at \(p\) only if their windows overlap, that is, if

\[\max\bigl(\text{lo}_A, \text{lo}_B\bigr) \;\le\; \min\bigl(\text{hi}_A, \text{hi}_B\bigr).\]

A meeting is possible if such a \(p\) exists anywhere in the overlap region. The script below builds the reachable ellipses, intersects them, and then grids the overlap looking for points where both windows overlap. It runs three cases: an overlap that does support a common time, an overlap that exists in space but never in time, and no overlap at all.

"""Could two moose have been in the same place at the same time?

Given a pair of fixes per moose (position at the start and at the end of an
interval), a maximum travel speed, and the length of the interval, each moose
gets an ellipse of positions it could have occupied during the interval.

This module:

1. builds those reachable ellipses for two moose,
2. intersects them,
3. asks a stricter question than "do the ellipses overlap": is there a point
   in the overlap where *both* moose could have been present at a *common*
   time?

A moose that starts at ``fix0`` at time ``t0`` and must be at ``fix1`` at time
``t1`` can be at point ``p`` at time ``t`` only if it has enough time to reach
``p`` and still enough time left to reach ``fix1``::

    t0 + |p - fix0| / v  <=  t  <=  t1 - |fix1 - p| / v

So for each moose we get a window of times at which it could have been at ``p``,
and the two moose could have met at ``p`` only if those windows overlap.
"""

from __future__ import annotations

import math
from dataclasses import dataclass
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from shapely.geometry import Point
from shapely.geometry.base import BaseGeometry

from moose_ellipse.geometry import ellipse, polygons

Point2D = tuple[float, float]
"""A planar coordinate in kilometres."""


@dataclass(frozen=True)
class Scenario:
    """A single two-moose comparison over a shared time interval."""

    label: str
    fixes_a: tuple[Point2D, Point2D]
    fixes_b: tuple[Point2D, Point2D]
    v_a: float
    v_b: float
    t0: float
    t1: float


def reachable_ellipse(
    fix0: Point2D,
    fix1: Point2D,
    v_max: float,
    dt: float,
) -> BaseGeometry:
    """Positions visitable between two fixes without exceeding ``v_max``.

    ``fix0`` and ``fix1`` are ``(x, y)`` pairs in kilometres, ``v_max`` is in
    kilometres per hour, and ``dt`` is in hours.

    Raises
    ------
    ValueError
        If the fixes are farther apart than the moose could possibly travel
        in ``dt`` hours at ``v_max``.
    """
    (x0, y0), (x1, y1) = fix0, fix1

    separation = math.hypot(x1 - x0, y1 - y0)
    a = v_max * dt / 2

    if 2 * a < separation:
        raise ValueError(
            f"Fixes are {separation:.3f} km apart but the moose can cover at "
            f"most {2 * a:.3f} km in {dt} h at {v_max} km/h."
        )

    b = math.sqrt(a**2 - (separation / 2) ** 2)
    angle = math.degrees(math.atan2(y1 - y0, x1 - x0))
    cx, cy = (x0 + x1) / 2, (y0 + y1) / 2

    return ellipse(cx, cy, a, b, angle)


def presence_window(
    p: Point2D,
    fix0: Point2D,
    fix1: Point2D,
    v_max: float,
    t0: float,
    t1: float,
) -> tuple[float, float]:
    """Times in ``[t0, t1]`` at which the moose could be at point ``p``."""
    lo = t0 + math.dist(p, fix0) / v_max
    hi = t1 - math.dist(p, fix1) / v_max
    return lo, hi


def simultaneous_points(
    region: BaseGeometry,
    fixes_a: tuple[Point2D, Point2D],
    fixes_b: tuple[Point2D, Point2D],
    v_max_a: float,
    v_max_b: float,
    t0: float,
    t1: float,
    step: float = 0.05,
) -> list[tuple[float, float, float, float]]:
    """Grid ``region`` for points where both presence windows overlap.

    Returns a list of ``(x, y, lo, hi)`` rows, where ``[lo, hi]`` is the
    common time window during which a meeting at ``(x, y)`` is feasible.
    """
    if region.is_empty:
        return []

    minx, miny, maxx, maxy = region.bounds

    points: list[tuple[float, float, float, float]] = []
    for x in np.arange(minx, maxx + step, step):
        for y in np.arange(miny, maxy + step, step):
            p = (x, y)
            if not region.contains(Point(p)):
                continue

            lo_a, hi_a = presence_window(p, fixes_a[0], fixes_a[1], v_max_a, t0, t1)
            lo_b, hi_b = presence_window(p, fixes_b[0], fixes_b[1], v_max_b, t0, t1)

            lo = max(lo_a, lo_b)
            hi = min(hi_a, hi_b)

            if lo <= hi:
                points.append((x, y, lo, hi))

    return points


def plot_scenario(ax: Axes, scenario: Scenario) -> None:
    """Draw one scenario's reachable ellipses and feasible meeting places."""
    fixes_a = scenario.fixes_a
    fixes_b = scenario.fixes_b
    v_a, v_b = scenario.v_a, scenario.v_b
    t0, t1 = scenario.t0, scenario.t1

    e_a = reachable_ellipse(fixes_a[0], fixes_a[1], v_a, t1 - t0)
    e_b = reachable_ellipse(fixes_b[0], fixes_b[1], v_b, t1 - t0)
    overlap = e_a.intersection(e_b)
    simultaneous = simultaneous_points(overlap, fixes_a, fixes_b, v_a, v_b, t0, t1)

    for polygon in polygons(e_a.difference(e_b)):
        x, y = polygon.exterior.xy
        ax.fill(x, y, color="cornflowerblue", alpha=0.65)
    for polygon in polygons(e_b.difference(e_a)):
        x, y = polygon.exterior.xy
        ax.fill(x, y, color="salmon", alpha=0.65)
    for polygon in polygons(overlap):
        x, y = polygon.exterior.xy
        ax.fill(x, y, color="mediumseagreen", alpha=0.85)

    for geometry, color, name in [
        (e_a, "blue", "Moose A reachable"),
        (e_b, "red", "Moose B reachable"),
    ]:
        for polygon in polygons(geometry):
            x, y = polygon.exterior.xy
            ax.plot(x, y, color=color, linewidth=2, label=name)

    if simultaneous:
        pts = np.array([(x, y) for x, y, _, _ in simultaneous])
        ax.scatter(
            pts[:, 0],
            pts[:, 1],
            s=5,
            color="darkgreen",
            alpha=0.55,
            label="Possible meeting place",
            zorder=5,
        )

    for fix in fixes_a:
        ax.scatter(*fix, color="blue", marker="+", s=130, linewidths=2, zorder=10)
    for fix in fixes_b:
        ax.scatter(*fix, color="red", marker="x", s=70, linewidths=2, zorder=10)

    ax.set_aspect("equal", adjustable="box")
    ax.set_title(scenario.label)
    ax.set_xlabel("km")
    ax.set_ylabel("km")
    ax.grid(True, alpha=0.25)
    ax.legend(loc="upper right", fontsize=8)


def default_scenarios() -> list[Scenario]:
    """The three illustrative scenarios used by the blog post."""
    t0, t1 = 0.0, 2.0
    return [
        Scenario(
            label="Overlap with a common time",
            fixes_a=((0.0, 0.0), (5.0, 1.0)),
            fixes_b=((6.0, 0.0), (1.0, 2.0)),
            v_a=6.0,
            v_b=6.0,
            t0=t0,
            t1=t1,
        ),
        Scenario(
            label="Overlap in space but not in time",
            fixes_a=((0.0, 0.0), (9.0, 0.0)),
            fixes_b=((0.0, 9.0), (0.0, 0.0)),
            v_a=5.0,
            v_b=5.0,
            t0=t0,
            t1=t1,
        ),
        Scenario(
            label="No overlap: meeting ruled out",
            fixes_a=((0.0, 0.0), (5.0, 0.0)),
            fixes_b=((0.0, 8.0), (5.0, 8.0)),
            v_a=4.0,
            v_b=4.0,
            t0=t0,
            t1=t1,
        ),
    ]


def summarise(scenario: Scenario) -> dict[str, float | str]:
    """Return the area and verdict statistics for one scenario."""
    e_a = reachable_ellipse(
        scenario.fixes_a[0], scenario.fixes_a[1], scenario.v_a, scenario.t1 - scenario.t0
    )
    e_b = reachable_ellipse(
        scenario.fixes_b[0], scenario.fixes_b[1], scenario.v_b, scenario.t1 - scenario.t0
    )
    overlap = e_a.intersection(e_b)
    simultaneous = simultaneous_points(
        overlap,
        scenario.fixes_a,
        scenario.fixes_b,
        scenario.v_a,
        scenario.v_b,
        scenario.t0,
        scenario.t1,
    )

    result: dict[str, float | str] = {
        "area_a": e_a.area,
        "area_b": e_b.area,
        "overlap_area": overlap.area,
        "n_meeting_places": len(simultaneous),
    }

    if overlap.is_empty:
        result["verdict"] = "no shared place, so no meeting under this v_max"
    elif not simultaneous:
        result["verdict"] = "shared places exist, but never at a shared time"
    else:
        best = max(simultaneous, key=lambda row: row[3] - row[2])
        x, y, lo, hi = best
        result["verdict"] = "meeting feasible"
        result["best_x"] = x
        result["best_y"] = y
        result["best_lo"] = lo
        result["best_hi"] = hi

    return result


def run(output: str | Path = "figures/cooccurrence_feasibility.png") -> Path:
    """Render the three-scenario figure and return the written path."""
    scenarios = default_scenarios()

    fig, axes = plt.subplots(1, 3, figsize=(18, 6))

    for ax, scenario in zip(axes, scenarios, strict=True):
        plot_scenario(ax, scenario)
        result = summarise(scenario)

        print(f"\n{scenario.label}")
        print(f"  Moose A ellipse area: {result['area_a']:9.3f} km^2")
        print(f"  Moose B ellipse area: {result['area_b']:9.3f} km^2")
        print(f"  Overlap area:         {result['overlap_area']:9.3f} km^2")

        if result["verdict"] == "meeting feasible":
            print(f"  Candidate meeting places on grid: {result['n_meeting_places']}")
            print(
                f"  Widest window at ({result['best_x']:.2f}, {result['best_y']:.2f}) "
                f"km: t in [{result['best_lo']:.3f}, {result['best_hi']:.3f}] h"
            )
        else:
            print(f"  Verdict: {result['verdict']}")

    fig.suptitle(
        "Reachable ellipses for two moose over t in [0, 2] h under three assumptions"
    )
    fig.tight_layout()

    output_path = Path(output)
    output_path.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(output_path, dpi=300, bbox_inches="tight", facecolor="white")
    plt.close(fig)
    print(f"\nSaved plot to: {output_path}")
    return output_path


def main() -> None:
    """Console-script entry point."""
    run()


if __name__ == "__main__":
    main()

Three two-moose scenarios: a feasible meeting place, an overlap that never coincides in time, and a meeting ruled out

The three panels make the point better than prose. In the first, the fast-moving animals have fat ellipses that overlap generously and there are many candidate meeting places, with the widest window at roughly \((3.0, 1.2)\) km lasting from about \(t = 0.54\) h to \(t = 1.64\) h. In the second, one moose is committed to crossing the interval eastwards and the other southwards, and the small overlap near the shared endpoint is only reachable by the first animal at the start of the interval and by the second at the end, so the spatial overlap never coincides with a temporal overlap. In the third, the two corridors are far enough apart that even a generous speed limit leaves the ellipses disjoint, so the meeting is impossible under that speed bound.

Strengths

  • Cheap and closed-form. Two fixes, one speed, and a formula give an answer. No fitting, no simulation, no parameters beyond the speed bound.
  • Interpretable and auditable. Every component has a physical meaning, and a biologist can argue with the speed assumption directly rather than interpreting a fitted diffusion coefficient.
  • Sound for ruling things out. A non-intersection is a real, assumption-conditional impossibility result. That makes the method a good screen before committing to expensive movement models.
  • Composable. Ellipses chain across intervals into a potential path area for a whole tracking period, and the same formula generalises to any distance metric you like.
  • Bridges behavior and data. It turns sparse telemetry into an explicit statement about what interaction is and is not consistent with the data.

Limitations

The most consequential assumption is the maximum speed itself, and it cuts both ways. If \(v_{\max}\) is too high, the ellipses inflate and the test almost never rules anything out; if it is too low, feasible meetings are falsely excluded and the fixes look impossible. Real moose speeds depend on terrain, snow depth, season, sex, presence of a calf, and whether the animal was fleeing a predator or crossing a road. There is no single correct value, and the honest approach is to report results across a range of plausible speeds and to show where conclusions flip. The examples above make this concrete: the same fix pairs give a permitted meeting at one speed and a ruled-out meeting at another.

Beyond the speed assumption:

  • Barriers and habitat. The construction assumes straight-line reachability and ignores lakes, rivers, cliffs, highways, and fences. It can therefore be optimistic in exactly the places where a landscape matters. Least-cost or path-based distances can fix this, but then the reachable region is generally not an ellipse anymore.
  • It is a region, not a path. The ellipse is the union of positions across all moments, so it is much larger than any real trajectory, and it grows linearly with the interval. For widely spaced fixes it may be uselessly large.
  • Location error propagates. Fixes are uncertain, so the foci are really discs of error rather than points, and the true region is the ellipse dilated by roughly that error radius. Fix error can also make the implied speed exceed \(v_{\max}\), producing the “impossible” case above. A proper treatment inflates the ellipses and reports intervals rather than point estimates.
  • Geometry versus probability. The method makes no statement about likelihood. It answers “could” rather than “did” or “probably”. Interaction also requires more than co-location; two moose standing in the same meadow at the same minute have not necessarily interacted.
  • Temporal resolution and aliasing. Whether an interaction is even detectable depends on the sampling interval relative to how long an encounter lasts. Coarse sampling gives large ellipses and weak power; fine sampling gives short intervals and narrow ellipses but more of them.
  • Independence assumptions. With two independently tracked animals, both the movement and the error are typically treated as independent, which may fail when both carry the same satellite clock error or move in response to the same weather.
  • Planar approximation. Coordinates must be projected into a local metric frame, and distances should be computed in that frame rather than in raw degrees.

What I would do with this in practice

Use the ellipses as the first pass. They are fast, transparent, and they fail safely in the sense that a non-intersection is informative. Then bring in the machinery that does the hard parts: continuous-time movement models or Brownian-bridge movement models to replace “possible” with a probability, a landscape cost surface to replace straight-line distance, and a proper error model in place of point fixes. The ellipse construction earns its place by telling you, quickly, which pairs of animals and which intervals are even worth the effort.

The broader lesson is the same one that shows up whenever data are sparse: the connection between two observations is not an observation, and any inference about what happened in between is only as good as the assumptions you are willing to defend. The maximum-speed ellipse is a very defensible assumption, as long as you remember it is an assumption and not a fact.