diff --git a/README.md b/README.md new file mode 100644 index 0000000..d9f4336 --- /dev/null +++ b/README.md @@ -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//` | FAA Digital Products - Sectional Raster Charts | +| IFR Low Enroute | `charts/IFR//` | FAA Digital Products - IFR Low Enroute | +| Jet (IFR High) | `charts/Jet//` | FAA Digital Products - IFR High Enroute | +| Terminal Area | `charts/Terminal//` | 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. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ab1f4ed --- /dev/null +++ b/tests/conftest.py @@ -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) diff --git a/tests/test_coordinate_math.py b/tests/test_coordinate_math.py new file mode 100644 index 0000000..963b7af --- /dev/null +++ b/tests/test_coordinate_math.py @@ -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()