Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# pyAvMap — Aviation Moving Map

**Status:** Open Source — Experimental Amateur-Built Category
**License:** See LICENSE
**Language:** Python 3
**Copyright:** 2018–2019 MakerPlane

---

## What This Is

pyAvMap is an open-source **aviation moving map library** written in Python. It renders FAA aeronautical charts (sectional, IFR low/high enroute, terminal area) with a GPS-driven aircraft position marker. It is designed to integrate with [pyEfis](../pyEfis) as the moving map panel within a full EFIS display, using flight data from [FIX-Gateway](../fix-gateway).

The map renders from pre-tiled versions of FAA-published raster charts so that real-time panning and zooming are fast even on low-powered embedded hardware like a Raspberry Pi.

## Chart Types Supported

| Type | Directory | FAA Source |
|---|---|---|
| Sectional VFR | `charts/Sectional/<ChartName>/` | FAA Digital Products - Sectional Raster Charts |
| IFR Low Enroute | `charts/IFR/<ChartName>/` | FAA Digital Products - IFR Low Enroute |
| Jet (IFR High) | `charts/Jet/<ChartName>/` | FAA Digital Products - IFR High Enroute |
| Terminal Area | `charts/Terminal/<ChartName>/` | FAA Digital Products - Terminal Area Charts |

![Sectional Example](https://raw.githubusercontent.com/Maker42/pyAvMap/master/doc/SectionalExample.jpg)
![IFR Enroute Example](https://raw.githubusercontent.com/Maker42/pyAvMap/master/doc/IFRExample.jpg)

## Installation

```bash
git clone https://github.com/makerplane/pyAvMap.git
cd pyAvMap

# Optional: install permanently (still in development)
sudo pip3 install .
```

## Chart Preparation

FAA chart files are large GeoTiff files that must be pre-tiled before use. This is a one-time step per chart:

```bash
# Download chart from FAA:
# https://www.faa.gov/air_traffic/flight_info/aeronav/digital_products/
# Unzip into the appropriate subdirectory

# For a sectional chart:
mkdir -p charts/Sectional/Albuquerque
# unzip downloaded file into charts/Sectional/Albuquerque/
cd charts/Sectional/Albuquerque
python pyAvMap/make_tiles/make_tiles.py "Albuquerque SEC 101"

# Remove the original large TIFF after tiling:
rm *.tif

# For charts where north is along the width axis (e.g., L-01, L-02 IFR):
python pyAvMap/make_tiles/make_tiles.py "L-01" 1 # the "1" rotates for correct orientation
```

Repeat for each chart you want available. Tiles are small PNG files stored in a directory hierarchy — the map library loads only those tiles visible in the current viewport.

## Dependencies

pyAvMap depends on **pyAvTools**, which must be cloned adjacent to the pyAvMap directory:

```bash
git clone https://github.com/makerplane/pyAvTools.git
```

Or set the `TOOLS_PATH` environment variable to point to the pyAvTools location.

## Integration with pyEfis

pyAvMap is loaded automatically by [pyEfis](../pyEfis) when the `MAP_PATH` environment variable is set or when the `pyAvMap` directory is adjacent to `pyEfis`. Map display is configured in the pyEfis screen YAML definitions.

## Data Flow

```
[FIX-Gateway] ──→ LAT, LONG, HEAD, GS, ALT ──→ [pyAvMap] ──→ [rendered chart tile + ownship symbol]
[faa-cifp-data] ──→ waypoint/procedure overlays ──→ [pyAvMap]
```

## Important Disclaimer

> pyAvMap is developed for Experimental Amateur-Built aircraft use only.
> FAA chart data is published for planning purposes. Moving map display is **not** a substitute for current official charts or a certified navigation system.
> Builders are responsible for all integration and safety decisions.
66 changes: 66 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
Qt stubs for pyavmap testing — allows importing pyavmap without a display.
Installed before any test collection so all star-imports in pyavmap/__init__.py resolve.
"""
import sys
import types


def _make_qt_stub():
"""Return a module object with enough Qt names to let pyavmap/__init__.py import."""

class _Dummy:
def __init__(self, *a, **kw):
pass
def __call__(self, *a, **kw):
return self
def __getattr__(self, name):
return self

class _Qt:
ScrollBarAlwaysOff = 0
NoFocus = 0
white = 0
black = 0
green = 0
yellow = 0

class QPointF(_Dummy):
def __init__(self, x=0, y=0):
self.x_val = x
self.y_val = y
def x(self): return self.x_val
def y(self): return self.y_val

class QGraphicsView(_Dummy): pass
class QPainter(_Dummy):
Antialiasing = 0
class QColor(_Dummy): pass
class QPolygonF(_Dummy): pass
class QGraphicsScene(_Dummy): pass
class QStyleOptionGraphicsItem(_Dummy): pass

stub = types.ModuleType("_qt_stub")
stub.Qt = _Qt
stub.QPointF = QPointF
stub.QGraphicsView = QGraphicsView
stub.QPainter = QPainter
stub.QColor = QColor
stub.QPolygonF = QPolygonF
stub.QGraphicsScene = QGraphicsScene
stub.QStyleOptionGraphicsItem = QStyleOptionGraphicsItem
return stub


_qt = _make_qt_stub()

for _mod_name in ("PyQt5", "PyQt5.QtGui", "PyQt5.QtCore", "PyQt5.QtWidgets",
"PyQt4", "PyQt4.QtGui", "PyQt4.QtCore"):
if _mod_name not in sys.modules:
sys.modules[_mod_name] = _qt

# Stub the avchart_proj sub-module
_proj = types.ModuleType("pyavmap.avchart_proj")
_proj.CT_SECTIONAL = 0
_proj.charts = {}
sys.modules.setdefault("pyavmap.avchart_proj", _proj)
170 changes: 170 additions & 0 deletions tests/test_coordinate_math.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
"""
Unit tests for pyavmap coordinate math functions.

These functions are pure math with no Qt dependency and can be tested
without a display or chart data.

Run from the repo root:
python -m pytest tests/test_coordinate_math.py -v
"""
import math
import unittest

# Qt and avchart_proj stubs are installed by conftest.py before collection.
from pyavmap import (
Distance,
GetRelLng,
Heading,
adjusted_polar_deltas,
METERS_PER_NM,
)

NM_TO_M = 1852
DEG_TO_RAD = math.pi / 180.0


class TestConstants(unittest.TestCase):
def test_meters_per_nm(self):
self.assertEqual(METERS_PER_NM, 1852)


class TestGetRelLng(unittest.TestCase):
"""GetRelLng(lat_radians) returns cos(lat) — longitude compression factor."""

def test_equator(self):
self.assertAlmostEqual(GetRelLng(0.0), 1.0, places=10)

def test_60_degrees(self):
# cos(60°) = 0.5
self.assertAlmostEqual(GetRelLng(60 * DEG_TO_RAD), 0.5, places=10)

def test_45_degrees(self):
self.assertAlmostEqual(GetRelLng(45 * DEG_TO_RAD), math.sqrt(2) / 2, places=10)

def test_90_degrees(self):
# cos(90°) = 0 — longitude has no length at the pole
self.assertAlmostEqual(GetRelLng(90 * DEG_TO_RAD), 0.0, places=10)


class TestAdjustedPolarDeltas(unittest.TestCase):
"""adjusted_polar_deltas applies longitude compression to the longitude delta."""

def test_due_north_at_equator(self):
course = ((0, 0), (0, 1)) # (lon, lat) pairs
dlng, dlat = adjusted_polar_deltas(course)
self.assertAlmostEqual(dlng, 0.0, places=10)
self.assertAlmostEqual(dlat, 1.0, places=10)

def test_due_east_at_equator(self):
# cos(0) = 1.0 → dlng unchanged
course = ((0, 0), (1, 0))
dlng, dlat = adjusted_polar_deltas(course)
self.assertAlmostEqual(dlng, 1.0, places=10)
self.assertAlmostEqual(dlat, 0.0, places=10)

def test_due_east_at_60_degrees(self):
# cos(60°) = 0.5 → dlng halved
course = ((0, 60), (1, 60))
dlng, dlat = adjusted_polar_deltas(course)
self.assertAlmostEqual(dlng, 0.5, places=5)
self.assertAlmostEqual(dlat, 0.0, places=10)

def test_explicit_rel_lng_overrides_computed(self):
course = ((0, 60), (1, 60)) # would compute cos(60°)=0.5 otherwise
dlng, dlat = adjusted_polar_deltas(course, rel_lng=0.25)
self.assertAlmostEqual(dlng, 0.25, places=10)

def test_diagonal_at_equator(self):
course = ((0, 0), (3, 4)) # dlng=3, dlat=4; rel_lng=cos(0)=1
dlng, dlat = adjusted_polar_deltas(course)
self.assertAlmostEqual(dlng, 3.0, places=10)
self.assertAlmostEqual(dlat, 4.0, places=10)


class TestDistance(unittest.TestCase):
"""Distance returns meters; each degree = 60 NM in the spherical approximation."""

def test_same_point(self):
course = ((0, 0), (0, 0))
self.assertAlmostEqual(Distance(course), 0.0, places=5)

def test_one_degree_north_from_equator(self):
# 1° lat = 60 NM = 111120 m
course = ((0, 0), (0, 1))
self.assertAlmostEqual(Distance(course), 60 * NM_TO_M, places=2)

def test_one_degree_east_at_equator(self):
# At equator, 1° lon = 60 NM (cos(0)=1)
course = ((0, 0), (1, 0))
self.assertAlmostEqual(Distance(course), 60 * NM_TO_M, places=2)

def test_one_degree_east_at_60_degrees(self):
# At 60° lat, 1° lon = 30 NM (cos(60°)=0.5)
course = ((0, 60), (1, 60))
self.assertAlmostEqual(Distance(course), 30 * NM_TO_M, places=0)

def test_pythagorean_3_4_5_triangle(self):
# 3° lng and 4° lat at equator → hypotenuse = 5° = 300 NM
course = ((0, 0), (3, 4))
self.assertAlmostEqual(Distance(course), 300 * NM_TO_M, places=2)

def test_distance_asymmetry_with_latitude(self):
# Distance uses the start point's latitude for longitude compression, so
# reversing a course that crosses latitudes gives a slightly different result.
# Both results should be within ~1% of each other for small lat changes.
c_fwd = ((0, 0), (1, 1))
c_rev = ((1, 1), (0, 0))
ratio = Distance(c_fwd) / Distance(c_rev)
self.assertAlmostEqual(ratio, 1.0, delta=0.01)

def test_distance_always_nonnegative(self):
for course in [((0, 0), (1, -1)), ((5, 5), (3, 2)), ((0, 45), (0, 45))]:
self.assertGreaterEqual(Distance(course), 0.0)


class TestHeading(unittest.TestCase):
"""Heading returns degrees true; atan2(dlng, dlat) convention."""

def test_due_north(self):
course = ((0, 0), (0, 1))
self.assertAlmostEqual(Heading(course), 0.0, places=5)

def test_due_east_at_equator(self):
course = ((0, 0), (1, 0))
self.assertAlmostEqual(Heading(course), 90.0, places=5)

def test_due_south(self):
course = ((0, 0), (0, -1))
# atan2(0, -1) = 180° or -180° — both are correct representations
h = Heading(course)
self.assertAlmostEqual(abs(h), 180.0, places=5)

def test_due_west_at_equator(self):
course = ((0, 0), (-1, 0))
self.assertAlmostEqual(Heading(course), -90.0, places=5)

def test_northeast_45_degrees(self):
# Equal north and east components at equator → NE = 45°
course = ((0, 0), (1, 1))
self.assertAlmostEqual(Heading(course), 45.0, places=5)

def test_northwest_minus_45_degrees(self):
course = ((0, 0), (-1, 1))
self.assertAlmostEqual(Heading(course), -45.0, places=5)

def test_heading_changes_with_latitude(self):
# At 60° lat, 1° lon is compressed to 0.5°; due east will still be 90°
# because dlat=0 and dlng>0 regardless of compression
course = ((0, 60), (1, 60))
self.assertAlmostEqual(Heading(course), 90.0, places=5)

def test_northeast_at_60_degrees_skews(self):
# Equal raw lon/lat deltas at 60° lat; lon is compressed by 0.5
# → effective dlng=0.5, dlat=1 → atan2(0.5, 1) ≈ 26.57°
course = ((0, 60), (1, 61))
expected = math.atan2(0.5, 1.0) * 180 / math.pi
self.assertAlmostEqual(Heading(course), expected, places=3)


if __name__ == "__main__":
unittest.main()