Metadata-Version: 2.4
Name: zenith-fixer
Version: 0.5.0
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Scientific/Engineering :: Astronomy
Classifier: Intended Audience :: Science/Research
Requires-Dist: numpy
Requires-Dist: zenith-fixer[tests] ; extra == 'all'
Requires-Dist: zenith-fixer[viz] ; extra == 'all'
Requires-Dist: astropy ; extra == 'tests'
Requires-Dist: astroquery ; extra == 'tests'
Requires-Dist: jupyter ; extra == 'tests'
Requires-Dist: matplotlib ; extra == 'tests'
Requires-Dist: numpy ; extra == 'tests'
Requires-Dist: polars ; extra == 'tests'
Requires-Dist: pytest ; extra == 'tests'
Requires-Dist: astropy ; extra == 'viz'
Requires-Dist: matplotlib ; extra == 'viz'
Requires-Dist: plotly ; extra == 'viz'
Provides-Extra: all
Provides-Extra: tests
Provides-Extra: viz
License-File: LICENSE
Summary: Automated celestial navigation: a position fix from a star photograph, an IMU gravity vector, and a UTC timestamp.
Keywords: celestial-navigation,astronomy,star-tracker,gnss-denied,positioning
Author: Tarek Allam Jr
License-Expression: Apache-2.0
Requires-Python: >=3.10
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Repository, https://github.com/tallamjr/zenith

# `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.

<img src="docs/images/howitworks.png" width="100%" alt="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.

<img src="docs/images/match-and-rotate.png" width="80%" alt="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`](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](docs/how-it-works.md) for the reasoning and
[docs/algorithms.md](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](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](docs/algorithms.md). For carrying a fix
through motion on a moving platform (the orientation MEKF and the position INS
Kalman filter, with the maths and worked examples), see
[docs/fusion.md](docs/fusion.md). For the camera-mount and optics study (which
camera, lens, and catalogue to carry, and what an end-to-end flight reveals that
a single frame cannot), see
[docs/camera-and-optics.md](docs/camera-and-optics.md).

## Architecture

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

### Project structure

```text
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`](https://docs.rapids.ai/api/cudf/stable/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.

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

```python
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`](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`](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`](examples/how-it-works.ipynb). For the executive
trade study that recommends what camera and mount to carry (the mount and optics
findings, a hardware shortlist, and what is ready for a field test), see
[`examples/camera-system-trade-study.ipynb`](examples/camera-system-trade-study.ipynb).

### Develop from source

Requirements: Rust 1.82+, Python 3.10+, [`uv`](https://docs.astral.sh/uv/).

```bash
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 calibration (Mahalanobis 2σ, ≥100 realisations) | 0.70 to 0.97 inside | ~0.92 inside (ideal 0.865) |

The covariance-calibration row is a true elliptical (Mahalanobis) containment
test: each noisy fix's offset from truth is projected onto the principal axes of
its own 2-sigma ellipse and counted inside when it lands within the ellipse, not
within a circle of the semi-major radius. For a well-calibrated 2D Gaussian the
expected containment is 1 - exp(-2) = 0.865; the harness measures about 0.92 over
at least 100 deterministic realisations (the covariance runs slightly
conservative), and the test asserts the fraction stays in the band 0.70 to 0.97.

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](docs/images/covariance_ellipse.png)

**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](docs/images/zero_crossing.png)

## Browser demo

![The Zenith browser tactical demo: synthesised sky, tactical fix, and globe](docs/images/demo-ui.png)

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 on a noiseless single frame (the
dilution-of-precision picture), and it gives the wide field of view a daytime fix
needs, at the cost of lower per-star angular resolution. At a realistic 16-bit
sensor depth that geometry advantage carries through real motion and centroiding
noise: the fisheye fixes at least as accurately as the pinhole. An earlier 8-bit
render had made the pinhole look the more precise lens, but that was a
dynamic-range artefact (the 8-bit sensor saturated the bright stars and buried the
faint ones), corrected once the study was regenerated at the depth a real star
camera delivers; see [camera-and-optics.md](docs/camera-and-optics.md)
(sub-project G). 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):

```bash
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:

```bash
.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:

```bash
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:

```bash
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/

