Skip to main content

Circular arithmetic for geographic coordinates — bearings, longitudes, and bounding boxes

Project description

Rhodium

Circular arithmetic for geographic coordinates — bearings, longitudes, latitudes, and bounding boxes.

PyPI version Open In Colab

The Problem

Geographic math breaks at the seams. Bearings wrap at 360°. Longitudes wrap at ±180°. Naive arithmetic fails:

# Bug: bearing difference
naive_diff = 10 - 350  # Returns -340°, should be +20°

# Bug: bounding box from Tokyo to San Francisco
west, east = 139.7, -122.4
naive_width = east - west  # Returns -262°, should be ~98° crossing the antimeridian

Rhodium handles the wraparound correctly:

from rhodium import bearing, lng, bbox

bearing.diff(350, 10)  # → 20.0 (clockwise)
lng.diff(139.7, -122.4)  # → 97.9 (eastward across Pacific)

box = bbox.create(
    Point(lng=139.7, lat=35.7),   # Tokyo
    Point(lng=-122.4, lat=37.8),  # San Francisco
)
bbox.width(box)  # → 97.9 degrees
bbox.crosses_antimeridian(box)  # → True

Installation

pip install elemental-rhodium

Zero dependencies — uses only the Python standard library.

When to Use Rhodium

Use rhodium when:

  • You need correct bearing arithmetic (compass headings, turn angles)
  • You're working with coordinates near the antimeridian (±180° longitude)
  • You need bounding boxes that cross the date line (Fiji, Alaska, Russia)
  • You want simple coordinate math without pulling in heavy geo libraries
  • You're building map UI, tiling systems, or geofencing logic

Don't use rhodium when:

  • You need geodesic (Great Circle) distances in meters — use pyproj or geographiclib
  • You need complex geometric operations — use shapely
  • You need map projections — use pyproj
  • You need routing or pathfinding — use specialized routing libraries

API Reference

rhodium.bearing

Circular arithmetic for compass bearings (0° to 360°).

Function Description
normalize(degrees) Normalize to [0, 360)
diff(from_, to) Signed shortest arc [-180, +180]
mean(angles) Circular mean, or None if undefined
interpolate(a, b, t) Shortest arc linear interpolation
within(angle, target, tolerance) True if within ±tolerance
opposite(bearing) Opposite bearing (+180°)
reciprocal(bearing) Alias for opposite
normalize_many(list) Batch normalize
diff_many(pairs) Batch diff
from rhodium import bearing

bearing.normalize(710)        # → 350.0
bearing.normalize(-10)        # → 350.0

bearing.diff(350, 10)         # → 20.0 (clockwise)
bearing.diff(10, 350)         # → -20.0 (counterclockwise)

bearing.mean([350, 10])       # → 0.0 (north)
bearing.mean([0, 180])        # → None (undefined)

bearing.interpolate(350, 20, 0.5)  # → 5.0
bearing.within(355, 0, 10)         # → True

bearing.opposite(45)          # → 225.0
bearing.opposite(270)         # → 90.0

# Batch operations
bearing.normalize_many([710, -10, 360])  # → [350.0, 350.0, 0.0]

rhodium.lng

Circular arithmetic for longitudes (-180° to +180°).

Function Description
normalize(degrees) Normalize to (-180, +180]
diff(from_, to) Signed shortest arc (+ = east)
mean(longitudes) Circular mean, or None if undefined
interpolate(a, b, t) Shortest path interpolation
from rhodium import lng

lng.normalize(190)            # → -170.0
lng.normalize(-180)           # → 180.0

lng.diff(170, -170)           # → 20.0 (eastward)
lng.diff(-170, 170)           # → -20.0 (westward)

lng.mean([170, -170])         # → 180.0
lng.interpolate(170, -170, 0.5)  # → 180.0

rhodium.lat

Latitude operations (-90° to +90°). Unlike longitude, latitude clamps instead of wrapping.

Function Description
clamp(degrees) Clamp to [-90, 90]
is_valid(degrees) True if finite and within range
validate(degrees) Raise InvalidLatitudeError if invalid
midpoint(a, b) Simple arithmetic midpoint
within(lat, target, tolerance) True if within ±tolerance
hemisphere(degrees) Return 'N' or 'S'
clamp_many(list) Batch clamp
from rhodium import lat

lat.clamp(95)                 # → 90.0
lat.clamp(-100)               # → -90.0
lat.is_valid(45)              # → True
lat.is_valid(91)              # → False
lat.hemisphere(45)            # → 'N'
lat.hemisphere(-45)           # → 'S'

rhodium.bbox

Bounding box operations with antimeridian support.

Types

from rhodium.bbox import Point, BBox

point = Point(lng=-122.4, lat=37.8)
box = BBox(west=170, east=-170, south=-10, north=10)

# Both Point and BBox support __geo_interface__ for ecosystem compatibility
point.__geo_interface__  # → {"type": "Point", "coordinates": [-122.4, 37.8]}
box.__geo_interface__    # → {"type": "Polygon", "coordinates": [...]}

Pythonic Property Access

BBox provides convenient property access alongside module functions:

box = BBox(west=0, east=100, south=-50, north=50)

# Property style (Pythonic)
box.width                 # → 100.0
box.height                # → 100.0
box.center_point          # → Point(lng=50.0, lat=0.0)
box.crosses_antimeridian  # → False

# Functional style (also available)
from rhodium import bbox
bbox.width(box)           # → 100.0

Functions

Function Description
create(sw, ne) Create bbox from corners
from_points(points) Smallest bbox containing all points
width(box) Width in degrees (antimeridian-aware)
height(box) Height in degrees
contains(box, point) Point containment test
intersects(a, b) Intersection test
intersection(a, b) Compute intersection, or None
union(a, b) Smallest box containing both
expand(box, point) Expand box to include point
crosses_antimeridian(box) True if box crosses ±180°
center(box) Center point of box
from rhodium import bbox
from rhodium.bbox import Point

# Pacific Ocean bounding box
pacific = bbox.create(
    Point(lng=140, lat=-50),
    Point(lng=-100, lat=60),
)

bbox.width(pacific)                # → 120.0
bbox.crosses_antimeridian(pacific) # → True
bbox.contains(pacific, Point(lng=180, lat=0))   # → True
bbox.contains(pacific, Point(lng=0, lat=0))     # → False

# Find bbox from points
points = [
    Point(lng=170, lat=10),
    Point(lng=-170, lat=20),
]
box = bbox.from_points(points)
bbox.width(box)  # → 20.0 (not 340°)

# Expand box to include new point
expanded = bbox.expand(box, Point(lng=-160, lat=15))

Exceptions

Rhodium provides a hierarchy of exceptions for precise error handling:

from rhodium import (
    RhodiumError,           # Base class for all rhodium errors
    InvalidCoordinateError, # Base for coordinate errors
    InvalidLatitudeError,   # Latitude out of range or invalid
    InvalidLongitudeError,  # NaN or infinite longitude
    InvalidBearingError,    # NaN or infinite bearing
    InvalidBBoxError,       # Invalid bounding box
    EmptyInputError,        # Empty input where not allowed
)

# Catch specific errors
try:
    lat.validate(91)
except InvalidLatitudeError as e:
    print(f"Bad latitude: {e.value}")  # → Bad latitude: 91

# Or catch all rhodium errors
try:
    bbox.from_points([])
except RhodiumError:
    print("Something went wrong")

Cookbook

Filter points in an antimeridian-crossing bbox

from rhodium import bbox
from rhodium.bbox import Point, BBox

# Fiji region (crosses antimeridian)
fiji_box = BBox(west=177, east=-178, south=-21, north=-12)

all_points = [
    Point(lng=178, lat=-18),   # Suva
    Point(lng=-179, lat=-16),  # Eastern Fiji
    Point(lng=0, lat=0),       # Not in Fiji
]

fiji_points = [p for p in all_points if bbox.contains(fiji_box, p)]
# → [Point(lng=178, lat=-18), Point(lng=-179, lat=-16)]

Calculate mean heading from GPS track

from rhodium import bearing

# Headings recorded every second
headings = [355, 358, 2, 5, 8, 3, 359]

avg_heading = bearing.mean(headings)
# → ~2.4° (correctly averages around north)

# Compare to naive average:
naive_avg = sum(headings) / len(headings)
# → 147° (completely wrong!)

Check if two ship routes might intersect

from rhodium import bbox
from rhodium.bbox import Point

# Route A: Tokyo to San Francisco
route_a = bbox.from_points([
    Point(lng=139.7, lat=35.7),
    Point(lng=-122.4, lat=37.8),
])

# Route B: Sydney to Vancouver
route_b = bbox.from_points([
    Point(lng=151.2, lat=-33.9),
    Point(lng=-123.1, lat=49.3),
])

if bbox.intersects(route_a, route_b):
    print("Routes may intersect - check more carefully")

Normalize user-entered coordinates

from rhodium import lng, lat

def normalize_coordinate(user_lng: float, user_lat: float) -> tuple[float, float]:
    """Normalize and validate user input."""
    # Longitude wraps (user might enter 190 meaning -170)
    normalized_lng = lng.normalize(user_lng)

    # Latitude clamps (user might enter 95 meaning 90)
    normalized_lat = lat.clamp(user_lat)

    return normalized_lng, normalized_lat

normalize_coordinate(190, 95)  # → (-170.0, 90.0)

Find the back-bearing for navigation

from rhodium import bearing

# You're heading 45° (northeast)
current_heading = 45

# What bearing to look behind you?
back_bearing = bearing.opposite(current_heading)
# → 225° (southwest)

# Verify it's 180° different
bearing.diff(current_heading, back_bearing)
# → 180.0 (or -180.0)

Why rhodium?

Bearings wrap at 360°. Longitudes wrap at ±180°. These are the same problem, but everyone solves them separately, partially, or with heavy dependencies.

# The bug everyone ships
>>> (10 - 350)  # "How far do I turn from 350° to 10°?"
-340  # Wrong. Should be +20°.

>>> bbox = {"west": 170, "east": -170}
>>> bbox["east"] - bbox["west"]  # "How wide is this box?"
-340  # Wrong. Should be 20°.

What rhodium does differently

  • Zero dependencies — stdlib only
  • Pure functions — no state, easy to test
  • Unified model — bearings and longitudes are both circular; one abstraction handles both
  • Bbox that workscontains(), intersects(), width() all handle antimeridian correctly
  • Small — you can read the whole thing

Math Model

Rhodium uses spherical circular arithmetic (topology), not ellipsoidal geodesy (shape).

  • Bearings & Longitudes: Treated as angles on a circle. Arithmetic handles the modular wrap-around (360° for bearings, ±180° for longitudes).
  • Interpolation: Uses linear interpolation in the coordinate space (Equirectangular approximation). This follows the shortest path on the circle of longitude or compass rose, but it is not a Great Circle path on a sphere.
  • Distance: Rhodium does not calculate surface distances (meters). It calculates angular differences (degrees).

For high-precision Great Circle calculations (e.g., flight paths, missile trajectories), use pyproj or geographiclib. Use Rhodium for logic, UI, and map tiling where coordinate wrapping is the primary complexity.

rhodium sits underneath your geo stack — handling the wrap-around math before you pass data to heavier libraries.

Use cases

  • Bearing arithmetic: turn angles, circular mean, interpolation
  • Longitude arithmetic: normalize, diff, interpolate across ±180°
  • Latitude handling: clamping, validation, hemisphere detection
  • Bounding boxes that work for Fiji, Alaska, or Russia

Performance

Rhodium is designed for low-latency coordinate operations (benchmarked on Python 3.12, M1 Mac):

  • bearing.normalize(): 0.22µs (~4.5M ops/sec)
  • bearing.diff(): 0.28µs (~3.6M ops/sec)
  • lng.normalize(): 0.27µs (~3.7M ops/sec)
  • bbox.contains(): 5µs (~200k ops/sec)
  • lat.clamp(): 0.13µs (~7.5M ops/sec)

Batch operations (normalize_many(), diff_many()) provide ~100x overhead reduction for processing lists of 100+ values.

Not optimized for: Bulk processing of 1M+ coordinates. For large-scale vectorized operations, consider numpy-based alternatives. See benchmarks/ for detailed results.

Numerical Precision

Rhodium uses standard Python float (IEEE 754 double precision):

  • Precision: ~15-17 significant decimal digits
  • Angular precision: ~1e-10 degrees (~0.01mm on Earth's surface)
  • Accumulation: Multiple operations may accumulate floating-point error

Edge cases:

  • NaN and Infinity inputs raise InvalidCoordinateError
  • Very small differences near wrap boundaries (e.g., 179.9999° vs -180°) are handled correctly
  • Antipodal points (exactly opposite on sphere) may produce undefined circular means (None)

Not suitable for: High-precision geodesy requiring millimeter accuracy. Use pyproj or geographiclib for that.

What Rhodium Does NOT Do

To set clear expectations:

  • Geodesic distances (Great Circle, meters/km) — use pyproj or geographiclib
  • Map projections — use pyproj
  • Geometric operations (polygon intersection, buffering) — use shapely
  • Routing — use specialized routing libraries
  • Point-in-polygon (except bounding boxes)
  • Coordinate transformations (WGS84 ↔ UTM, etc.) — use pyproj

Rhodium handles circular arithmetic for coordinates. For geometry, use Shapely. For distances, use pyproj.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

elemental_rhodium-0.9.0.tar.gz (38.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

elemental_rhodium-0.9.0-py3-none-any.whl (19.8 kB view details)

Uploaded Python 3

File details

Details for the file elemental_rhodium-0.9.0.tar.gz.

File metadata

  • Download URL: elemental_rhodium-0.9.0.tar.gz
  • Upload date:
  • Size: 38.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.6

File hashes

Hashes for elemental_rhodium-0.9.0.tar.gz
Algorithm Hash digest
SHA256 5b67a153f0704483a9e537c583090dd223f5375ef647ad058bb6fde546b98e17
MD5 abae64ae32893bff6f873e7b50ea63f4
BLAKE2b-256 f19771707f7aadcb887ed7ab10b49e7d370ebc7879cf5bf4e4b79590eef58196

See more details on using hashes here.

File details

Details for the file elemental_rhodium-0.9.0-py3-none-any.whl.

File metadata

File hashes

Hashes for elemental_rhodium-0.9.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2eb4bdafc703d8d53ab78d5432785a49581fe74c873445ba9bf7fb16261ef9f1
MD5 3b90d0ed80531e7dbb2a1285e0b0d722
BLAKE2b-256 ee4f586dc3f8e3759511400fc8ace9f59a20d2712da16a0dbbfe39be252708bd

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page