Angle interval intersection

Given two angle intervals, what is their intersection? An example of this problem is shown in the figure below.

Two open angle intervals, (30°,120°) in red, and (15°,75°) in blue, with their intersection (30°,75°) in purple.

Before we get to the solution, we need to make the problem more precise. But even before that, let’s start with a simpler problem whose solution will be a subroutine: computing the intersection of two real intervals on the number line. (I passed a milestone birthday recently, and I’m trying to lean into my age by using words like subroutine.)

Suppose that we have two such real intervals, each identified by their lower and upper bounds, A=(a_0,a_1) and B=(b_0,b_1). The following Python code computes their intersection as another interval:

def interval_intersect(a, b):
    """Intersection of two open intervals."""
    lower, upper = (max(a[0], b[0]), min(a[1], b[1]))
    return (min(lower, upper), upper)

Note that it is convenient to work with open intervals, excluding the endpoints, so that we won’t have to treat empty or degenerate intersections as a special case. (This does come at a cost of allowing multiple representations of the empty set, namely any interval with equal endpoints.)

Armed with this setup, we can now define an angle interval to be any open circular sector or arc… although I think using corresponding portions of an annulus in these visualizations does a better job than sectors or arcs of conveying the Venn-diagram-ness of the intersections we are trying to compute.

Just as we identify a real interval on the number line by its two endpoints, we can represent an angle interval by the two angles (a_0,a_1) made by its bounding radii relative to some zero reference… but we need some convention to deal with angle intervals that may contain that zero reference direction, as shown in the figure below.

Two open angle intervals, normalized (345°,405°) in red, and (30°,60°) in blue, with their unnormalized intersection (390°,405°), or normalized (30°,45°), in purple.

A convenient convention will be to enforce a_0 \leq a_1, so that the interval “sweeps counterclockwise” from direction a_0, through angle a_1-a_0, to direction a_1. And we can make such a representation unique if we “normalize” the bounding angles to enforce 0 \leq a_0 < 360^\circ (so that a_1 may exceed 360°; more on this shortly). The function below implements this normalization:

def normalize_angle_interval(a, circle=360):
    """Normalize open angle interval to lower bound in [0, circle)."""
    lower = a[0] % circle
    return (lower, lower + (a[1] - a[0]))

This convention greatly simplifies the case analysis involved in computing the intersection of two such normalized open angle intervals:

def angle_interval_intersect(a, b, circle=360):
    """Unnormalized intersection of normalized open angle intervals."""
    if a[1] > b[1]:
        b, a = a, b
    if a[0] + circle < b[1]:
        a = (a[0] + circle, a[1] + circle)
    return interval_intersect(a, b)

However, I’ve so far left out an important detail. The above implementation is correct, and reasonably elegant… as long as we are computing the intersection of open angle intervals that are not “wider” than 180°, such as the example shown in the figure below.

Two open angle intervals whose intersection is two disjoint intervals.

If we want to support intersections involving such “wide” angle intervals, then we have two problems: not only does the implementation become more complex, but even the interface does as well! That is, as the example above shows, in computing the intersection of two angle intervals we may need to return not just one but possibly two disjoint angle intervals.

In the application motivating this post, it was an acceptable tradeoff of generality for simplicity to restrict input angle intervals to at most 180°. And it’s a nice exercise to show that even if we allow “wide” inputs, the resulting single angle interval returned by the above implementation is either still correct, or at worst a subset of the full intersection.

East of here

This came up for a second time recently; during a family visit to the Assateague Island National Seashore, while standing on the beach looking east out over the Atlantic Ocean, it is natural to wonder, “What’s out there, beyond the horizon?”

There are at least two reasonable ways to make this question more precise. First, standing at a point somewhere on the east coast of North or South America, if we climb into our dinghy and row east… and continue to maintain an easterly heading throughout the trip, where do we first encounter land?

This is easy to plot on a world map with any cylindrical projection, since a constant east heading results in a track that is a horizontal line, as shown in the figure below.

Starting at Assateague Island in Maryland, rowing “ever east” brings us to the coast of Portugal.

But maintaining a constant east heading requires constantly turning (except at the equator). That is, if we imagine the oceans to be completely still water with no current, we must row with our left oar working harder than the right in order to maintain our east heading. Instead, suppose that we start rowing initially east, but continue rowing “ever straight” across the water, with both oars working equally hard, so that we are never locally turning. In this case, we are traveling along a geodesic, as shown in the figure below.

Again starting at Assateague Island, rowing initially east along a geodesic brings us to the disputed territory of Western Sahara on the coast of Africa.

The Python implementation of the geodesic direct and inverse problems (ported from the U.S. National Geodetic Survey Fortran source), as well as the map images (created using Natural Earth vector data version 5.1.1) and “coast to coast” example code used here are available on GitHub.