Skip to main content

Automated celestial navigation: a position fix from a star photograph, an IMU gravity vector, and a UTC timestamp.

Project description

zenith: Automated Celestial Navigation

zenith answers one question without GPS: where am I on Earth? Given a photograph of the night sky, an IMU gravity vector, and a UTC timestamp, it returns latitude, longitude, and a rigorous 2-sigma uncertainty ellipse.

The engine is validated end to end against an independent reference implementation (astropy) using the real Yale Bright Star Catalog: position fixes land within 0.1 to 0.2 nautical miles of truth on noise-free synthetic imagery, and within 5 nautical miles under realistic sensor noise (3 arcminutes of star-position error plus IMU tilt noise).

How it works

This is automated celestial navigation: the same idea sailors used with a sextant and star charts for centuries, now done by a computer from a single photograph in a fraction of a second.

Consider the night sky is a ceiling covered in dots, and every dot has a known name and a known place. If you can work out which dots you photographed and which way the camera was pointing, you can work backwards to the one spot on Earth where the sky would look exactly like that. Zenith needs only three things to do it: the photo, which way is down (gravity, from an IMU), and the exact time.

Zenith pipeline
architecture: an attitude subsystem (image, centroids, camera vectors, star
identification, attitude) feeds a position subsystem (direct zenith fix,
altitude sights, Gauss-Newton least squares) that outputs the position fix and
2-sigma ellipse, with external inputs for camera intrinsics, the star
catalogue, UTC, and the refraction model.

It runs in five steps:

  1. Find the dots. Pinpoint each star in the photo to within a fraction of a pixel.
  2. Name the dots. Measure the angles between stars (those stay the same however the camera is tilted) and look the pattern up in a catalogue of about 9,100 real stars. If the field is ambiguous, Zenith refuses to guess and raises an error rather than risk a wrong fix.
  3. Find where the camera pointed. Turn the named stars into the camera's orientation in the sky.
  4. Take a first position. Gravity tells it which way is straight up, and the star directly overhead is, in effect, your latitude and longitude. This falls straight out of a single frame, with no prior position and no dead reckoning.
  5. Refine and report. Polish the fix and report a 2-sigma uncertainty ellipse: "you are within this small patch".

Steps 2 and 3 are the heart of it: the angles between stars stay fixed however the camera is aimed, so a triangle of dots identifies the stars, and a single rotation then recovers where the camera pointed.

Two panels. Left:
three dots forming a triangle with its three side-angles labelled, captioned
'three angles identify which stars they are'. Right: the same rigid triangle
drawn in a camera frame and a catalogue frame, one rotation R apart, captioned
'same rigid triangle, one rotation aligns them'.

For a runnable, illustrated walkthrough of these two steps (with diagrams and animations), see examples/how-it-works.ipynb.

It also works by day. Daytime sky brightness is scattered sunlight, and scattering falls off steeply toward longer wavelengths, so in the short-wave infrared (about 0.9 to 1.7 micron) the sky background drops away while the brightest stars still shine. Zenith ships a separate infrared catalogue (real 2MASS J/H photometry) and a solar-disc detector, so a SWIR camera can fix position in daylight, resolving within about a nautical mile at the validation sites. The Sun itself can be added as an extra sight: a known body needs no identification, only an ephemeris, and it contributes one more line of position that strengthens a star fix or fills in when stars are sparse. See docs/how-it-works.md for the reasoning and docs/algorithms.md for the equations.

The deeper story lives in the docs. For the conceptual walkthrough (why each stage is built the way it is, the arcminute-level accuracy budget, and the refusal properties that make a fix with errors), see docs/how-it-works.md. For the full equations, the formal method names (subpixel centroiding, the k-vector lost-in-space match, Wahba's problem solved by SVD, the Marcq St. Hilaire intercept method), and code references, see docs/algorithms.md.

Architecture

Cargo workspaces are used to decouple the main resolver engine from other aspects of the system.

Project structure

zenith/
├── Cargo.toml                  # workspace
├── pyproject.toml              # Python package metadata (maturin)
├── crates/
│   ├── zenith-core/            # pure Rust engine (MSRV 1.82)
│   ├── zenith-py/              # PyO3 bindings (lib name: zenith)
│   └── zenith-wasm/            # wasm-bindgen bindings + scene synthesis
├── python/
│   ├── zenith/
│   │   ├── pipeline/           # Polars BSC5 builder + binary writers
│   │   └── simulate/           # astropy sky renderer + demo plots
│   └── tests/                  # bindings, pipeline, simulate, e2e suites
├── scripts/                    # node wasm/globe tests + howitworks figure generator
├── web/                        # browser tactical demo (no bundler)
├── packaging/                  # demo bundle README + macOS launcher
├── docs/images/                # rendered demonstration plots
├── data/                       # generated artefacts (gitignored)
└── .github/workflows/ci-cd.yml # fmt + clippy + cargo test + pytest + wasm
Crate / package Role
crates/zenith-core The full runtime path from pixels to position fix. Only dependency: nalgebra.
crates/zenith-py PyO3 bindings exposing resolve_fix, Catalog, detect_centroids, covariance_ellipse, and time/frame helpers as the zenith Python module.
crates/zenith-wasm wasm-bindgen bindings and in-engine scene synthesis powering the browser tactical demo.
python/zenith/pipeline Offline catalog builder: downloads the real BSC5, parses it with Polars, writes catalog.parquet plus the binary artefacts the engine embeds.
python/zenith/simulate astropy-driven synthetic sky renderer (independent ground truth for testing) and the demonstration plot scripts.

The embedded star catalog

The pipeline produces two compact little-endian artefacts consumed by the Rust core (formats documented in crates/zenith-core/src/catalog.rs):

  • catalog.bin: all ~9,100 BSC5 stars (HR number, magnitude, J2000 RA/Dec, precomputed unit vector), 48 bytes per star.
  • pairs.bin: every pair of bright stars (magnitude 4.5 or brighter, about 30,000 pairs within 30 degrees) sorted by angular separation, ready for binary search. Matching only ever needs bright stars; sight reduction uses the full table.

For daytime SWIR fixes the pipeline also writes catalog_ir.bin and pairs_ir.bin in the identical formats, built from real 2MASS J/H photometry cross-matched to the BSC5, with a brighter pair-index cut (J 4.0 or brighter).

There is no database engine at runtime. Polars does the heavy lifting offline, and the derived-column stage runs through the Polars lazy API, so it can execute on an NVIDIA GPU via the RAPIDS cudf-polars engine (parse(raw, engine="gpu")). At BSC5 scale that is architectural headroom for future Tycho-2 or Gaia subsets rather than a present-day speedup.

Roadmap

Possible future work: real camera ingest in place of synthetic imagery, a Gaia-scale catalog with quad-code hashing for the lost-in-space match, and more robust blend handling for crowded fields.

Quickstart

Install and use

The distribution is zenith-fixer; the import is zenith. The star catalogue ships inside the wheel, so a fix needs no download or build step.

pip install "zenith-fixer[viz]"   # [viz] adds matplotlib, plotly and astropy for the plots
import zenith

catalog = zenith.bundled_catalog()  # bundled with the wheel; no external files
fix = zenith.resolve_fix(
    image,                      # numpy uint8 array, shape (height, width)
    1500.0, 512.0, 512.0,       # focal length and principal point, pixels
    (ux, uy, uz),               # unit vector toward the zenith, camera frame
    (2026, 1, 15, 2, 0, 0.0),   # UTC
    catalog,
    sigma_arcmin=1.0,           # assumed 1-sigma altitude noise
)
print(fix.latitude_deg, fix.longitude_deg)
print(fix.ellipse_2sigma_nm)    # (semi-major nm, semi-minor nm, orientation deg)
print(fix.matched_hr_ids)       # which catalog stars were identified

The full Python API is documented in docs/python-api.md. For an end-to-end walkthrough with plots (synthesised sky, all-sky skymap, an interactive globe of the fix, and the uncertainty ellipse), see examples/quickstart.ipynb. For a conceptual companion that explains how the matcher names the dots and how Wahba's problem recovers the camera's orientation (with diagrams and animations), see examples/how-it-works.ipynb.

Develop from source

Requirements: Rust 1.82+, Python 3.10+, uv.

uv sync   # builds the extension and installs the full dev toolchain
# after changing Rust, rebuild the extension into the venv:
.venv/bin/maturin develop --manifest-path crates/zenith-py/Cargo.toml

# the catalogue is vendored under python/zenith/data; to rebuild it from the
# real BSC5/2MASS sources and refresh that copy:
make pkg-data

# run the tests
cargo test --workspace --exclude zenith-desktop
.venv/bin/pytest python/tests/

Validation

The synthetic test harness places stars with astropy (its own precession, aberration, and refraction models), renders them onto a sensor frame with Gaussian point-spread functions, and perturbs the IMU vector. Because the generator shares no transform code with the engine, the acceptance tests are a genuine cross-implementation check:

Scenario Requirement Achieved
Noise-free, 3 sites (35N 40W, 34S 18E, 60N 11E) < 0.5 nm 0.10 to 0.20 nm
3 arcmin star noise + 1 arcmin IMU tilt < 5 nm passes at all sites
Covariance honesty over 20 noise realisations 60% inside 2σ 20 of 20 inside

The matcher's refusal property is tested directly: a congruent star pattern duplicated on the opposite side of the sky must raise MatchAmbiguous rather than guess, while catalogued close doubles (Alnitak, Mintaka) must not trigger false ambiguity.

Demonstration plots

Generate with .venv/bin/python -m zenith.simulate.covariance_plot and .venv/bin/python -m zenith.simulate.zero_crossing.

Sight geometry drives uncertainty. Two stars separated by only 30 degrees of azimuth give nearly parallel lines of position; the 2-sigma ellipse (computed by the Rust engine, not re-derived in Python) stretches across the weakly constrained direction:

Uncertainty ellipse for a 30 degree sight separation

Shoot on the roll. At sea the camera cannot be stabilised, but it can be triggered. Sampling the IMU at high rate and firing the shutter as the hull rolls through zero removes the platform tilt from the measurement; the zero-crossing fixes cluster on the true position while randomly timed exposures smear with the roll angle:

Zero-crossing trigger strategy versus random triggering

Browser demo

The Zenith browser tactical demo: synthesised sky, tactical fix, and globe

The engine compiles to WebAssembly unchanged and ships with a fully client-side tactical display: pick a position, time, and noise level; the page synthesises the night sky that would be photographed there from the real BSC5 catalog, identifies the stars, and resolves the fix in the browser. No server-side computation is involved. Alongside the sky view, a 3D orthographic globe panel renders real Natural Earth coastlines that you can drag to rotate, with a pulsing pinpoint that recentres on every resolved fix.

The demo defaults to the fisheye all-sky lens; the LENS toggle also offers PINHOLE, and the projection is selectable from the fisheye_fov_deg parameter on resolve_fix in Python and on the WASM bindings. An all-sky field spreads the stars across the whole sky, which strengthens the fix geometry and yields a noticeably tighter uncertainty ellipse, and it gives the wide field of view a daytime fix needs, at the cost of lower per-star angular resolution. The model is an ideal equidistant projection; a real fisheye lens would need distortion calibration before use.

Run it

A Makefile wraps the build and serve steps. With the toolchain installed (see Quickstart):

make serve
# then open http://localhost:8123/web/

make serve rebuilds the WebAssembly package and the catalog artefacts when they are missing, then serves the repository root. In the page, RESOLVE FIX runs a fix, RANDOM SKY picks a random position and time, and the LENS toggle switches between the pinhole and fisheye all-sky models.

The equivalent manual steps:

.venv/bin/python -m zenith.pipeline   # catalog + coastline artefacts (one-time)
wasm-pack build crates/zenith-wasm --target web --out-dir ../../web/pkg
.venv/bin/python -m http.server 8123  # then visit http://localhost:8123/web/

Share it

To give the demo to someone with no toolchain, package it into one self-contained zip:

make bundle
# writes dist/zenith-demo.zip

The zip holds the web app, the compiled WebAssembly engine, the real catalog and coastline data, a plain-English README, and a double-click launcher for macOS. The recipient unzips it and runs a local static server (the bundled instructions cover macOS, Linux, and Windows); nothing is installed and nothing leaves their machine. Because the demo is fully static and client-side, it can equally be hosted on any static host (GitHub Pages, Netlify) and shared as a link; the host only needs to serve .wasm with the application/wasm MIME type, which those services do by default.

The same engine path is exercised headlessly by the smoke test:

make test          # full suite, including the wasm smoke test
# or just the wasm smoke test:
wasm-pack build crates/zenith-wasm --target nodejs --out-dir ../../build/wasm-node
node scripts/wasm_smoke.mjs

References

  • Yale Bright Star Catalogue, 5th revised edition: http://tdc-www.harvard.edu/catalogs/bsc5.html
  • Bennett, G. G. (1982). The calculation of astronomical refraction in marine navigation. Journal of Navigation, 35(2).
  • Markley, F. L. (1988). Attitude determination using vector observations and the singular value decomposition. Journal of the Astronautical Sciences, 36(3).
  • Mortari, D. et al. (2004). The pyramid star identification technique. Navigation, 51(3).
  • RAPIDS cuDF Polars GPU engine: https://docs.rapids.ai/api/cudf/stable/cudf_polars/

Project details


Download files

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

Source Distributions

No source distribution files available for this release.See tutorial on generating distribution archives.

Built Distributions

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

zenith_fixer-0.4.0-cp310-abi3-win_amd64.whl (3.0 MB view details)

Uploaded CPython 3.10+Windows x86-64

zenith_fixer-0.4.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl (3.1 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ x86-64

zenith_fixer-0.4.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (3.1 MB view details)

Uploaded CPython 3.10+manylinux: glibc 2.17+ ARM64

zenith_fixer-0.4.0-cp310-abi3-macosx_11_0_arm64.whl (3.1 MB view details)

Uploaded CPython 3.10+macOS 11.0+ ARM64

zenith_fixer-0.4.0-cp310-abi3-macosx_10_12_x86_64.whl (3.1 MB view details)

Uploaded CPython 3.10+macOS 10.12+ x86-64

File details

Details for the file zenith_fixer-0.4.0-cp310-abi3-win_amd64.whl.

File metadata

  • Download URL: zenith_fixer-0.4.0-cp310-abi3-win_amd64.whl
  • Upload date:
  • Size: 3.0 MB
  • Tags: CPython 3.10+, Windows x86-64
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for zenith_fixer-0.4.0-cp310-abi3-win_amd64.whl
Algorithm Hash digest
SHA256 17360480d9539603e1e485c391d4dc10023b76b226271a40eee9f9f29004e4bf
MD5 4c57666481a20ff548c125a0aba6a351
BLAKE2b-256 d02545344b4a5375fb73800f7d2ca38c7a43799b1370df6cb6e01b18b00e411d

See more details on using hashes here.

Provenance

The following attestation bundles were made for zenith_fixer-0.4.0-cp310-abi3-win_amd64.whl:

Publisher: release.yml on tallamjr/zenith

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file zenith_fixer-0.4.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl.

File metadata

File hashes

Hashes for zenith_fixer-0.4.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Algorithm Hash digest
SHA256 637a63109096e48de7366b7445cd06c82579196aece92027ccc1e4ffa6da5a9e
MD5 7e78560ae8f3202828b741caedff5ab9
BLAKE2b-256 7694aa1e9f6d08576171fee2e0761fcb7cbb309a3771ad61e73d3487fd0e86a0

See more details on using hashes here.

Provenance

The following attestation bundles were made for zenith_fixer-0.4.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl:

Publisher: release.yml on tallamjr/zenith

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file zenith_fixer-0.4.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl.

File metadata

File hashes

Hashes for zenith_fixer-0.4.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Algorithm Hash digest
SHA256 3ed992af5883e92c9ffecc5668ddd0f25f4fb470dedc76f9b654dea4d5f78d09
MD5 4dcdf7eaee303f27231507a54d719fa4
BLAKE2b-256 2d0d74caae2a239cdbc8e5338ece1607aa0202d50aad58ac43d4e2cf27010e8c

See more details on using hashes here.

Provenance

The following attestation bundles were made for zenith_fixer-0.4.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl:

Publisher: release.yml on tallamjr/zenith

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file zenith_fixer-0.4.0-cp310-abi3-macosx_11_0_arm64.whl.

File metadata

File hashes

Hashes for zenith_fixer-0.4.0-cp310-abi3-macosx_11_0_arm64.whl
Algorithm Hash digest
SHA256 b97d8c04ddb2593f9b25c6a95b5735b9d9c57a08f098e3efcc1ef3c1e5849afd
MD5 e3b1f88496c3d97cecfe5ff39abe2d67
BLAKE2b-256 1e565d509be6fb62166ddee2c94f9e0876ec27d5b3704edcee76d305beebda6e

See more details on using hashes here.

Provenance

The following attestation bundles were made for zenith_fixer-0.4.0-cp310-abi3-macosx_11_0_arm64.whl:

Publisher: release.yml on tallamjr/zenith

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file zenith_fixer-0.4.0-cp310-abi3-macosx_10_12_x86_64.whl.

File metadata

File hashes

Hashes for zenith_fixer-0.4.0-cp310-abi3-macosx_10_12_x86_64.whl
Algorithm Hash digest
SHA256 e43e9eed855daaf112758146b1b355525b2b0669600313d46a26021bba913ca7
MD5 1ea18fe625fec0c04455967deb7e7d36
BLAKE2b-256 052b3416e8af80e823c266810981414bc0a6c6305f4d22183a9e6d35ff6c900f

See more details on using hashes here.

Provenance

The following attestation bundles were made for zenith_fixer-0.4.0-cp310-abi3-macosx_10_12_x86_64.whl:

Publisher: release.yml on tallamjr/zenith

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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