├── .flake8 ├── .gitattributes ├── .github ├── contributors.md └── workflows │ ├── README.md │ ├── ci.yml │ ├── ci_stdlib_only.yml │ └── publish-python-package.yml ├── .gitignore ├── .nojekyll ├── CITATION.cff ├── Examples ├── angle_distance.py ├── azel2radec.py ├── geodetic to ENU.ipynb ├── plot_geodetic2ecef.py ├── radec2azel.py └── vdist_poi.py ├── LICENSE ├── README.md ├── codemeta.json ├── paper ├── codemeta.json ├── generate.rb ├── paper.bib └── paper.md ├── pyproject.toml ├── scripts ├── benchmark_ecef2geo.py ├── benchmark_vincenty.py └── helper_vdist.m └── src └── pymap3d ├── __init__.py ├── aer.py ├── azelradec.py ├── ecef.py ├── eci.py ├── ellipsoid.py ├── enu.py ├── haversine.py ├── latitude.py ├── los.py ├── lox.py ├── mathfun.py ├── ned.py ├── rcurve.py ├── rsphere.py ├── sidereal.py ├── spherical.py ├── tests ├── __init__.py ├── matlab_engine.py ├── matlab_toolbox.m ├── test_aer.py ├── test_eci.py ├── test_ellipsoid.py ├── test_enu.py ├── test_geodetic.py ├── test_latitude.py ├── test_look_spheroid.py ├── test_matlab_ecef2eci.py ├── test_matlab_lox.py ├── test_matlab_track2.py ├── test_matlab_vdist.py ├── test_matlab_vreckon.py ├── test_ned.py ├── test_pyproj.py ├── test_rcurve.py ├── test_rhumb.py ├── test_rsphere.py ├── test_sidereal.py ├── test_sky.py ├── test_spherical.py ├── test_time.py ├── test_vincenty.py ├── test_vincenty_dist.py └── test_vincenty_vreckon.py ├── timeconv.py ├── utils.py ├── vallado.py └── vincenty.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501, W503 3 | exclude = .git,__pycache__,.eggs/,doc/,docs/,build/,dist/ 4 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | archive/* linguist-documentation 2 | docs/* linguist-documentation 3 | paper/* linguist-documentation 4 | -------------------------------------------------------------------------------- /.github/contributors.md: -------------------------------------------------------------------------------- 1 | Thanks to those who contributed code and ideas, including: 2 | 3 | ``` 4 | @aldebaran1 (robustness) 5 | @rpavlick (multiple features + functions) 6 | @cchuravy (Ellipsoid parameters) 7 | @jprMesh (more conversion functions) 8 | @Fil (docs) 9 | @SamuelMarks (docs) 10 | @Yozh2 (numerous ellipsoids and tests) 11 | ``` 12 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions CI workflows 2 | Definitions for GitHub Actions (continuous integration) workflows 3 | 4 | ## Publishing 5 | To publish a new version of the `pymap3d` package to PyPI, create and publish a 6 | release in GitHub (preferrably from a Git tag) for the version; the workflow 7 | will automatically build and publish an sdist and wheel from the tag. 8 | 9 | Requires the repo secret `PYPI_API_TOKEN` to be set to a PyPI API token: see 10 | [PyPI's help](https://pypi.org/help/#apitoken) for instructions on how to 11 | generate one. 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | paths: 6 | - "**.py" 7 | - .github/workflows/ci.yml 8 | - "!scripts/**" 9 | 10 | jobs: 11 | 12 | full: 13 | runs-on: ${{ matrix.os }} 14 | 15 | name: ${{ matrix.os }} Python ${{ matrix.python-version }} 16 | strategy: 17 | matrix: 18 | python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] 19 | os: [ubuntu-latest] 20 | include: 21 | - os: macos-latest 22 | python-version: '3.13' 23 | - os: windows-latest 24 | python-version: '3.13' 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - run: pip install .[full,tests,lint] 33 | 34 | - run: flake8 35 | 36 | - run: mypy 37 | if: ${{ matrix.python-version >= '3.9' }} 38 | 39 | - run: pytest 40 | 41 | 42 | coverage: 43 | runs-on: ubuntu-latest 44 | name: Coverage Python ${{ matrix.python-version }} 45 | 46 | strategy: 47 | matrix: 48 | python-version: ["3.13"] 49 | 50 | steps: 51 | - uses: actions/checkout@v4 52 | 53 | - name: Set up Python 54 | uses: actions/setup-python@v5 55 | with: 56 | python-version: ${{ matrix.python-version }} 57 | 58 | - name: Install tests and lint dependencies 59 | run: pip install -e .[tests,coverage] 60 | 61 | - name: Collect coverage without NumPy 62 | run: pytest --cov=src --cov-report=xml 63 | 64 | - name: Install NumPy 65 | run: pip install -e .[core] 66 | 67 | - name: Collect coverage with NumPy 68 | run: pytest --cov=src --cov-report=xml --cov-append 69 | 70 | # - name: Install full dependencies 71 | # run: pip install -e .[full] 72 | # - name: Test with full dependencies and collect coverage 73 | # run: pytest --cov=src --cov-report=xml --cov-append 74 | 75 | - name: Upload coverage 76 | uses: codecov/codecov-action@v3 77 | with: 78 | file: ./coverage.xml 79 | name: Python ${{ matrix.python-version }} 80 | -------------------------------------------------------------------------------- /.github/workflows/ci_stdlib_only.yml: -------------------------------------------------------------------------------- 1 | name: ci_stdlib_only 2 | 3 | on: 4 | push: 5 | paths: 6 | - "**.py" 7 | - .github/workflows/ci_stdlib_only.yml 8 | - "!scripts/**" 9 | 10 | jobs: 11 | 12 | stdlib_only: 13 | runs-on: ${{ matrix.os }} 14 | 15 | name: ${{ matrix.os }} Python ${{ matrix.python-version }} 16 | strategy: 17 | matrix: 18 | python-version: ['3.8', '3.9', '3.10', '3.11', '3.12', '3.13'] 19 | os: ['ubuntu-22.04'] 20 | include: 21 | - os: macos-latest 22 | python-version: '3.13' 23 | - os: windows-latest 24 | python-version: '3.13' 25 | 26 | steps: 27 | - uses: actions/checkout@v4 28 | - uses: actions/setup-python@v5 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | 32 | - run: pip install .[tests] 33 | 34 | - run: pytest 35 | -------------------------------------------------------------------------------- /.github/workflows/publish-python-package.yml: -------------------------------------------------------------------------------- 1 | # https://docs.pypi.org/trusted-publishers/using-a-publisher/ 2 | 3 | name: publish 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | release: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | environment: release 15 | 16 | permissions: 17 | id-token: write 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | 22 | - name: Set up Python 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: '3.x' 26 | 27 | - name: Install builder 28 | run: pip install build 29 | 30 | - name: Build package 31 | run: python -m build 32 | 33 | - name: Publish package 34 | uses: pypa/gh-action-pypi-publish@release/v1 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache/ 2 | .pytest_cache/ 3 | 4 | __pycache__/ 5 | 6 | build/ 7 | dist/ 8 | -------------------------------------------------------------------------------- /.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geospace-code/pymap3d/2e4d77fb180a900efc19a3fd446fa2be75bb3e69/.nojekyll -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | authors: 3 | - family-names: Hirsch 4 | given-names: Michael 5 | orcid: 0000-0002-1637-6526 6 | title: PyGemini 7 | doi: 10.5281/zenodo.3262738 8 | -------------------------------------------------------------------------------- /Examples/angle_distance.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from argparse import ArgumentParser 3 | 4 | from pymap3d.haversine import anglesep, anglesep_meeus 5 | from pytest import approx 6 | 7 | p = ArgumentParser(description="angular distance between two sky points") 8 | p.add_argument("r0", help="right ascension: first point [deg]", type=float) 9 | p.add_argument("d0", help="declination: first point [deg]", type=float) 10 | p.add_argument("r1", help="right ascension: 2nd point [deg]", type=float) 11 | p.add_argument("d1", help="declination: 2nd point [degrees]", type=float) 12 | a = p.parse_args() 13 | 14 | dist_deg = anglesep_meeus(a.r0, a.d0, a.r1, a.d1) 15 | dist_deg_astropy = anglesep(a.r0, a.d0, a.r1, a.d1) 16 | 17 | print(f"{dist_deg:.6f} deg sep") 18 | 19 | assert dist_deg == approx(dist_deg_astropy) 20 | -------------------------------------------------------------------------------- /Examples/azel2radec.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Example Kitt Peak 4 | 5 | ./demo_azel2radec.py 264.9183 37.911388 31.9583 -111.597 2014-12-25T22:00:00MST 6 | """ 7 | from argparse import ArgumentParser 8 | 9 | from pymap3d import azel2radec 10 | 11 | p = ArgumentParser( 12 | description="convert azimuth and elevation to " "right ascension and declination" 13 | ) 14 | p.add_argument("azimuth", help="azimuth [deg]", type=float) 15 | p.add_argument("elevation", help="elevation [deg]", type=float) 16 | p.add_argument("lat", help="WGS84 obs. lat [deg]", type=float) 17 | p.add_argument("lon", help="WGS84 obs. lon [deg]", type=float) 18 | p.add_argument("time", help="obs. time YYYY-mm-ddTHH:MM:SSZ") 19 | P = p.parse_args() 20 | 21 | ra, dec = azel2radec(P.azimuth, P.elevation, P.lat, P.lon, P.time) 22 | 23 | print("ra [deg] ", ra, " dec [deg] ", dec) 24 | -------------------------------------------------------------------------------- /Examples/geodetic to ENU.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "import pymap3d as pm\n", 10 | "\n", 11 | "lat0, lon0, alt0 = 4029.25745, 11146.61129, 1165.2\n", 12 | "lat, lon, alt = 4028.25746, 11147.61125, 1165.1\n", 13 | "e, n, u = pm.geodetic2enu(lat, lon, alt, lat0, lon0, alt0)\n", 14 | "print(e, n, u)" 15 | ] 16 | } 17 | ], 18 | "metadata": { 19 | "kernelspec": { 20 | "display_name": "Python 3", 21 | "language": "python", 22 | "name": "python3" 23 | }, 24 | "language_info": { 25 | "codemirror_mode": { 26 | "name": "ipython", 27 | "version": 3 28 | }, 29 | "file_extension": ".py", 30 | "mimetype": "text/x-python", 31 | "name": "python", 32 | "nbconvert_exporter": "python", 33 | "pygments_lexer": "ipython3", 34 | "version": "3.8.5" 35 | } 36 | }, 37 | "nbformat": 4, 38 | "nbformat_minor": 4 39 | } 40 | -------------------------------------------------------------------------------- /Examples/plot_geodetic2ecef.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from __future__ import annotations 4 | import typing 5 | import argparse 6 | 7 | import matplotlib.pyplot as mpl 8 | import numpy as np 9 | import pymap3d as pm 10 | 11 | p = argparse.ArgumentParser() 12 | p.add_argument("alt_m", help="altitude [meters]", type=float, default=0.0, nargs="?") 13 | args = p.parse_args() 14 | 15 | lat, lon = np.meshgrid(np.arange(-90, 90, 0.1), np.arange(-180, 180, 0.2)) 16 | 17 | x, y, z = pm.geodetic2ecef(lat, lon, args.alt_m) 18 | 19 | 20 | def panel(ax, val, name: str, cmap: str | None = None): 21 | hi = ax.pcolormesh(lon, lat, val, cmap=cmap) 22 | ax.set_title(name) 23 | fg.colorbar(hi, ax=ax).set_label(name + " [m]") 24 | ax.set_xlabel("longitude [deg]") 25 | 26 | 27 | fg = mpl.figure(figsize=(16, 5)) 28 | axs: typing.Any = fg.subplots(1, 3, sharey=True) 29 | fg.suptitle("geodetic2ecef") 30 | 31 | panel(axs[0], x, "x", "bwr") 32 | panel(axs[1], y, "y", "bwr") 33 | panel(axs[2], z, "z", "bwr") 34 | 35 | axs[0].set_ylabel("latitude [deg]") 36 | 37 | 38 | fg = mpl.figure(figsize=(16, 5)) 39 | axs = fg.subplots(1, 3, sharey=True) 40 | fg.suptitle(r"|$\nabla$ geodetic2ecef|") 41 | 42 | 43 | panel(axs[0], np.hypot(*np.gradient(x)), r"|$\nabla$ x|") 44 | panel(axs[1], np.hypot(*np.gradient(y)), r"|$\nabla$ y|") 45 | panel(axs[2], np.hypot(*np.gradient(z)), r"|$\nabla$ z|") 46 | 47 | axs[0].set_ylabel("latitude [deg]") 48 | 49 | mpl.show() 50 | -------------------------------------------------------------------------------- /Examples/radec2azel.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Example Kitt Peak 4 | 5 | ./radec2azel.py 257.96295344 15.437854 31.9583 -111.5967 2014-12-25T22:00:00MST 6 | """ 7 | from argparse import ArgumentParser 8 | 9 | from pymap3d import radec2azel 10 | 11 | p = ArgumentParser(description="RightAscension,Declination =>" "Azimuth,Elevation") 12 | p.add_argument("ra", help="right ascension [degrees]", type=float) 13 | p.add_argument("dec", help="declination [degrees]", type=float) 14 | p.add_argument("lat", help="WGS84 latitude of observer [degrees]", type=float) 15 | p.add_argument("lon", help="WGS84 latitude of observer [degrees]", type=float) 16 | p.add_argument("time", help="UTC time of observation YYYY-mm-ddTHH:MM:SSZ") 17 | P = p.parse_args() 18 | 19 | az_deg, el_deg = radec2azel(P.ra, P.dec, P.lat, P.lon, P.time) 20 | print("azimuth: [deg]", az_deg) 21 | print("elevation [deg]:", el_deg) 22 | -------------------------------------------------------------------------------- /Examples/vdist_poi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | Example of using Google Maps queries and PyMap3D 4 | 5 | https://developers.google.com/places/web-service/search 6 | 7 | This requires a Google Cloud key, and costs a couple US cents per query. 8 | 9 | TODO: Would like to instead query a larger region, would OSM be an option? 10 | """ 11 | import functools 12 | from argparse import ArgumentParser 13 | from pathlib import Path 14 | 15 | import pandas 16 | import requests 17 | from pymap3d.vincenty import vdist 18 | 19 | URL = "https://maps.googleapis.com/maps/api/place/nearbysearch/json?" 20 | 21 | 22 | @functools.lru_cache() 23 | def get_place_coords( 24 | place_type: str, latitude: float, longitude: float, search_radius_km: int, keyfn: Path 25 | ) -> pandas.DataFrame: 26 | """ 27 | Get places using Google Maps Places API 28 | Requires you to have a Google Cloud account with API key. 29 | """ 30 | 31 | keyfn = Path(keyfn).expanduser() 32 | key = keyfn.read_text() 33 | 34 | stub = URL + f"location={latitude},{longitude}" 35 | 36 | stub += f"&radius={search_radius_km * 1000}" 37 | 38 | stub += f"&types={place_type}" 39 | 40 | stub += f"&key={key}" 41 | 42 | r = requests.get(stub) 43 | r.raise_for_status() 44 | 45 | place_json = r.json()["results"] 46 | 47 | places = pandas.DataFrame( 48 | index=[p["name"] for p in place_json], 49 | columns=["latitude", "longitude", "distance_km", "vicinity"], 50 | ) 51 | places["latitude"] = [p["geometry"]["location"]["lat"] for p in place_json] 52 | places["longitude"] = [p["geometry"]["location"]["lng"] for p in place_json] 53 | places["vicinity"] = [p["vicinity"] for p in place_json] 54 | 55 | return places 56 | 57 | 58 | if __name__ == "__main__": 59 | p = ArgumentParser() 60 | p.add_argument( 61 | "place_type", 62 | help="Place type to search: https://developers.google.com/places/supported_types", 63 | ) 64 | p.add_argument( 65 | "searchloc", help="initial latituude, longitude to search from", nargs=2, type=float 66 | ) 67 | p.add_argument("radius", help="search radius (kilometers)", type=int) 68 | p.add_argument("refloc", help="reference location (lat, lon)", nargs=2, type=float) 69 | p.add_argument("-k", "--keyfn", help="Google Places API key file", default="~/googlemaps.key") 70 | a = p.parse_args() 71 | 72 | place_coords = get_place_coords(a.place_type, *a.searchloc, a.radius, a.keyfn) 73 | 74 | place_coords["distance_km"] = ( 75 | vdist(place_coords["latitude"], place_coords["longitude"], *a.refloc)[0] / 1e3 76 | ) 77 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2022 Michael Hirsch, Ph.D. 2 | Copyright (c) 2013, Felipe Geremia Nievinski 3 | Copyright (c) 2004-2007 Michael Kleder 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | 11 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python 3-D coordinate conversions 2 | 3 | [![image](https://zenodo.org/badge/DOI/10.5281/zenodo.213676.svg)](https://doi.org/10.5281/zenodo.213676) 4 | [![image](http://joss.theoj.org/papers/10.21105/joss.00580/status.svg)](https://doi.org/10.21105/joss.00580) 5 | [![codecov](https://codecov.io/gh/geospace-code/pymap3d/branch/main/graph/badge.svg?token=DFWBW6TKNr)](https://codecov.io/gh/geospace-code/pymap3d) 6 | ![Actions Status](https://github.com/geospace-code/pymap3d/workflows/ci/badge.svg) 7 | ![Actions Status](https://github.com/geospace-code/pymap3d/workflows/ci_stdlib_only/badge.svg) 8 | [![image](https://img.shields.io/pypi/pyversions/pymap3d.svg)](https://pypi.python.org/pypi/pymap3d) 9 | [![PyPi Download stats](http://pepy.tech/badge/pymap3d)](http://pepy.tech/project/pymap3d) 10 | 11 | Pure Python (no prerequistes beyond Python itself) 3-D geographic coordinate conversions and geodesy. 12 | Function syntax is roughly similar to Matlab Mapping Toolbox. 13 | PyMap3D is intended for non-interactive use on massively parallel (HPC) and embedded systems. 14 | 15 | [API docs](https://geospace-code.github.io/pymap3d/) 16 | 17 | Thanks to our [contributors](./.github/contributors.md). 18 | 19 | ## Similar toolboxes in other code languages 20 | 21 | * [Matlab, GNU Octave](https://github.com/geospace-code/matmap3d) 22 | * [Fortran](https://github.com/geospace-code/maptran3d) 23 | * [Rust](https://github.com/gberrante/map_3d) 24 | * [C++](https://github.com/ClancyWalters/cppmap3d) 25 | 26 | ## Prerequisites 27 | 28 | Numpy and AstroPy are optional. 29 | Algorithms from Vallado and Meeus are used if AstroPy is not present. 30 | 31 | ## Install 32 | 33 | ```sh 34 | python3 -m pip install pymap3d 35 | ``` 36 | 37 | or for the latest development code: 38 | 39 | ```sh 40 | git clone https://github.com/geospace-code/pymap3d 41 | 42 | pip install -e pymap3d 43 | ``` 44 | 45 | One can verify Python functionality after installation by: 46 | 47 | ```sh 48 | pytest pymap3d 49 | ``` 50 | 51 | ## Usage 52 | 53 | Where consistent with the definition of the functions, all arguments may 54 | be arbitrarily shaped (scalar, N-D array). 55 | 56 | ```python 57 | import pymap3d as pm 58 | 59 | x,y,z = pm.geodetic2ecef(lat,lon,alt) 60 | 61 | az,el,range = pm.geodetic2aer(lat, lon, alt, observer_lat, observer_lon, 0) 62 | ``` 63 | 64 | [Python](https://www.python.org/dev/peps/pep-0448/) 65 | [argument unpacking](https://docs.python.org/3/tutorial/controlflow.html#unpacking-argument-lists) 66 | can be used for compact function arguments with scalars or arbitrarily 67 | shaped N-D arrays: 68 | 69 | ```python 70 | aer = (az,el,slantrange) 71 | obslla = (obs_lat ,obs_lon, obs_alt) 72 | 73 | lla = pm.aer2geodetic(*aer, *obslla) 74 | ``` 75 | 76 | where tuple `lla` is comprised of scalar or N-D arrays `(lat,lon,alt)`. 77 | 78 | Example scripts are in the [examples](./Examples) directory. 79 | 80 | Native Python float is typically [64 bit](https://docs.python.org/3/library/stdtypes.html#typesnumeric). 81 | Numpy can select real precision bits: 32, 64, 128, etc. 82 | 83 | ### Functions 84 | 85 | Popular mapping toolbox functions ported to Python include the 86 | following, where the source coordinate system (before the "2") is 87 | converted to the desired coordinate system: 88 | 89 | ``` 90 | aer2ecef aer2enu aer2geodetic aer2ned 91 | ecef2aer ecef2enu ecef2enuv ecef2geodetic ecef2ned ecef2nedv 92 | ecef2eci eci2ecef eci2aer aer2eci geodetic2eci eci2geodetic 93 | enu2aer enu2ecef enu2geodetic 94 | geodetic2aer geodetic2ecef geodetic2enu geodetic2ned 95 | ned2aer ned2ecef ned2geodetic 96 | azel2radec radec2azel 97 | lookAtSpheroid 98 | track2 departure meanm 99 | rcurve rsphere 100 | geod2geoc geoc2geod 101 | geodetic2spherical spherical2geodetic 102 | ``` 103 | 104 | Vincenty functions "vincenty.vreckon" and "vincenty.vdist" are accessed like: 105 | 106 | ```python 107 | import pymap3d.vincenty as pmv 108 | 109 | lat2, lon2 = pmv.vreckon(lat1, lon1, ground_range_m, azimuth_deg) 110 | dist_m, azimuth_deg = pmv.vdist(lat1, lon1, lat2, lon2) 111 | ``` 112 | 113 | Additional functions: 114 | 115 | * loxodrome_inverse: rhumb line distance and azimuth between ellipsoid points (lat,lon) akin to Matlab `distance('rh', ...)` and `azimuth('rh', ...)` 116 | * loxodrome_direct 117 | * geodetic latitude transforms to/from: parametric, authalic, isometric, and more in pymap3d.latitude 118 | 119 | Abbreviations: 120 | 121 | * [AER: Azimuth, Elevation, Range](https://en.wikipedia.org/wiki/Spherical_coordinate_system) 122 | * [ECEF: Earth-centered, Earth-fixed](https://en.wikipedia.org/wiki/ECEF) 123 | * [ECI: Earth-centered Inertial using IERS](https://www.iers.org/IERS/EN/Home/home_node.html) via `astropy` 124 | * [ENU: East North Up](https://en.wikipedia.org/wiki/Axes_conventions#Ground_reference_frames:_ENU_and_NED) 125 | * [NED: North East Down](https://en.wikipedia.org/wiki/North_east_down) 126 | * [radec: right ascension, declination](https://en.wikipedia.org/wiki/Right_ascension) 127 | 128 | ### Ellipsoid 129 | 130 | Numerous functions in pymap3d use an ellipsoid model. 131 | The default is WGS84 Ellipsoid. 132 | Numerous other ellipsoids are available in pymap3d.Ellipsoid. 133 | 134 | Print available ellipsoid models: 135 | 136 | ```python 137 | import pymap3d as pm 138 | 139 | print(pm.Ellipsoid.models) 140 | ``` 141 | 142 | Specify GRS80 ellipsoid: 143 | 144 | ```python 145 | import pymap3d as pm 146 | 147 | ell = pm.Ellipsoid.from_name('grs80') 148 | ``` 149 | 150 | ### array vs scalar 151 | 152 | Use of pymap3d on embedded systems or other streaming data applications often deal with scalar position data. 153 | These data are handled efficiently with the Python math stdlib module. 154 | Vector data can be handled via list comprehension. 155 | 156 | Those needing multidimensional data with SIMD and other Numpy and/or PyPy accelerated performance can do so automatically by installing Numpy. 157 | pymap3d seamlessly falls back to Python's math module if Numpy isn't present. 158 | To keep the code clean, only scalar data can be used without Numpy. 159 | As noted above, use list comprehension if you need vector data without Numpy. 160 | 161 | ### Caveats 162 | 163 | * Atmospheric effects neglected in all functions not invoking AstroPy. 164 | Would need to update code to add these input parameters (just start a GitHub Issue to request). 165 | * Planetary perturbations and nutation etc. not fully considered. 166 | 167 | ## Compare to Matlab Mapping and Aerospace Toolbox 168 | 169 | The tests in files tests/test_matlab*.py selected by 170 | 171 | ```sh 172 | pytest -k matlab 173 | # run from pymap3d/ top-level directory 174 | ``` 175 | 176 | use 177 | [Matlab Engine for Python](https://www.mathworks.com/help/matlab/matlab_external/install-the-matlab-engine-for-python.html) 178 | to compare Python PyMap3D output with Matlab output using Matlab functions. 179 | 180 | ## Notes 181 | 182 | As compared to [PyProj](https://github.com/jswhit/pyproj): 183 | 184 | * PyMap3D does not require anything beyond pure Python for most transforms 185 | * Astronomical conversions are done using (optional) AstroPy for established accuracy 186 | * PyMap3D API is similar to Matlab Mapping Toolbox, while PyProj's interface is quite distinct 187 | * PyMap3D intrinsically handles local coordinate systems such as ENU, 188 | while PyProj ENU requires some [additional effort](https://github.com/jswhit/pyproj/issues/105). 189 | * PyProj is oriented towards points on the planet surface, while PyMap3D handles points on or above the planet surface equally well, particularly important for airborne vehicles and remote sensing. 190 | 191 | ### AstroPy.Units.Quantity 192 | 193 | At this time, 194 | [AstroPy.Units.Quantity](http://docs.astropy.org/en/stable/units/) 195 | is not supported. 196 | Let us know if this is of interest. 197 | Impacts on performance would have to be considered before making Quantity a first-class citizen. 198 | For now, you can workaround by passing in the `.value` of the variable. 199 | -------------------------------------------------------------------------------- /codemeta.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://doi.org/10.5063/schema/codemeta-2.0", 3 | "@type": "SoftwareSourceCode", 4 | "license": "https://spdx.org/licenses/BSD-2-Clause", 5 | "codeRepository": "https://github.com/geospace-code/pymap3d", 6 | "contIntegration": "https://github.com/geospace-code/pymap3d/actions", 7 | "dateCreated": "2014-08-01", 8 | "datePublished": "2014-08-03", 9 | "dateModified": "2020-05-10", 10 | "issueTracker": "https://github.com/geospace-code/pymap3d/issues", 11 | "name": "PyMap3d", 12 | "identifier": "10.5281/zenodo.3262738", 13 | "description": "pure-Python (Numpy optional) 3D coordinate conversions for geospace", 14 | "applicationCategory": "geospace", 15 | "developmentStatus": "active", 16 | "funder": { 17 | "@type": "Organization", 18 | "name": "AFOSR" 19 | }, 20 | "keywords": [ 21 | "coordinate transformation" 22 | ], 23 | "programmingLanguage": [ 24 | "Python" 25 | ], 26 | "author": [ 27 | { 28 | "@type": "Person", 29 | "@id": "https://orcid.org/0000-0002-1637-6526", 30 | "givenName": "Michael", 31 | "familyName": "Hirsch" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /paper/codemeta.json: -------------------------------------------------------------------------------- 1 | { 2 | "@context": "https://raw.githubusercontent.com/codemeta/codemeta/master/codemeta.jsonld", 3 | "@type": "Code", 4 | "author": [ 5 | { 6 | "@id": "", 7 | "@type": "Person", 8 | "email": "", 9 | "name": "Michael Hirsch, Ph.D", 10 | "affiliation": "" 11 | } 12 | ], 13 | "identifier": "http://doi.org/10.5281/zenodo.213676", 14 | "codeRepository": "https://github.com/scivision/pymap3d", 15 | "datePublished": "2018-01-28", 16 | "dateModified": "2018-01-28", 17 | "dateCreated": "2018-01-28", 18 | "description": "3-D coordinate conversions in pure Python", 19 | "keywords": "geospace,coordinates", 20 | "license": "BSD", 21 | "title": "pymap3d", 22 | "version": "1.4.0" 23 | } 24 | -------------------------------------------------------------------------------- /paper/generate.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/ruby 2 | # https://gist.github.com/arfon/478b2ed49e11f984d6fb 3 | # For an OO language, this is distinctly procedural. Should probably fix that. 4 | require 'json' 5 | 6 | details = Hash.new({}) 7 | 8 | capture_params = [ 9 | { :name => "title", :message => "Enter project name." }, 10 | { :name => "url", :message => "Enter the URL of the project repository." }, 11 | { :name => "description", :message => "Enter the (short) project description." }, 12 | { :name => "license", :message => "Enter the license this software shared under. (hit enter to skip)\nFor example MIT, BSD, GPL v3.0, Apache 2.0" }, 13 | { :name => "doi", :message => "Enter the DOI of the archived version of this code. (hit enter to skip)\nFor example http://dx.doi.org/10.6084/m9.figshare.828487" }, 14 | { :name => "keywords", :message => "Enter keywords that should be associated with this project (hit enter to skip)\nComma-separated, for example: turkey, chicken, pot pie" }, 15 | { :name => "version", :message => "Enter the version of your software (hit enter to skip)\nSEMVER preferred: http://semver.org e.g. v1.0.0" } 16 | ] 17 | 18 | puts "I'm going to try and help you prepare some things for your JOSS submission" 19 | puts "If all goes well then we'll have a nice codemeta.json file soon..." 20 | puts "" 21 | puts "************************************" 22 | puts "* First, some basic details *" 23 | puts "************************************" 24 | puts "" 25 | 26 | # Loop through the desired captures and print out for clarity 27 | capture_params.each do |param| 28 | puts param[:message] 29 | print "> " 30 | input = gets 31 | 32 | details[param[:name]] = input.chomp 33 | 34 | puts "" 35 | puts "OK, your project has #{param[:name]}: #{input}" 36 | puts "" 37 | end 38 | 39 | puts "" 40 | puts "************************************" 41 | puts "* Experimental stuff *" 42 | puts "************************************" 43 | puts "" 44 | 45 | puts "Would you like me to try and build a list of authors for you?" 46 | puts "(You need to be running this script in a git repository for this to work)" 47 | print "> (Y/N)" 48 | answer = gets.chomp 49 | 50 | case answer.downcase 51 | when "y", "yes" 52 | 53 | # Use git shortlog to extract a list of author names and commit counts. 54 | # Note we don't extract emails here as there's often different emails for 55 | # each user. Instead we capture emails at the end. 56 | 57 | git_log = `git shortlog --summary --numbered --no-merges` 58 | 59 | # ["252\tMichael Jackson", "151\tMC Hammer"] 60 | authors_and_counts = git_log.split("\n").map(&:strip) 61 | 62 | authors_and_counts.each do |author_count| 63 | count, author = author_count.split("\t").map(&:strip) 64 | 65 | puts "Looks like #{author} made #{count} commits" 66 | puts "Add them to the output?" 67 | print "> (Y/N)" 68 | answer = gets.chomp 69 | 70 | # If a user chooses to add this author to the output then we ask for some 71 | # additional information including their email, ORCID and affiliation. 72 | case answer.downcase 73 | when "y", "yes" 74 | puts "What is #{author}'s email address? (hit enter to skip)" 75 | print "> " 76 | email = gets.chomp 77 | 78 | puts "What is #{author}'s ORCID? (hit enter to skip)" 79 | puts "For example: http://orcid.org/0000-0000-0000-0000" 80 | print "> " 81 | orcid = gets.chomp 82 | 83 | puts "What is #{author}'s affiliation? (hit enter to skip)" 84 | print "> " 85 | affiliation = gets.chomp 86 | 87 | 88 | details['authors'].merge!(author => { 'commits' => count, 89 | 'email' => email, 90 | 'orcid' => orcid, 91 | 'affiliation' => affiliation }) 92 | 93 | when "n", "no" 94 | puts "OK boss..." 95 | puts "" 96 | end 97 | end 98 | when "n", "no" 99 | puts "OK boss..." 100 | puts "" 101 | end 102 | 103 | puts "Reticulating splines" 104 | 105 | 5.times do 106 | print "." 107 | sleep 0.5 108 | end 109 | 110 | puts "" 111 | puts "Generating some JSON goodness..." 112 | 113 | # TODO: work out how to use some kind of JSON template here. 114 | # Build the output list of authors from the inputs we've collected. 115 | output_authors = [] 116 | 117 | details['authors'].each do |author_name, values| 118 | entry = { 119 | "@id" => values['orcid'], 120 | "@type" => "Person", 121 | "email" => values['email'], 122 | "name" => author_name, 123 | "affiliation" => values['affiliation'] 124 | } 125 | output_authors << entry 126 | end 127 | 128 | # TODO: this is currently a static template (written out here). It would be good 129 | # to do something smarter here. 130 | output = { 131 | "@context" => "https://raw.githubusercontent.com/codemeta/codemeta/master/codemeta.jsonld", 132 | "@type" => "Code", 133 | "author" => output_authors, 134 | "identifier" => details['doi'], 135 | "codeRepository" => details['url'], 136 | "datePublished" => Time.now.strftime("%Y-%m-%d"), 137 | "dateModified" => Time.now.strftime("%Y-%m-%d"), 138 | "dateCreated" => Time.now.strftime("%Y-%m-%d"), 139 | "description" => details['description'], 140 | "keywords" => details['keywords'], 141 | "license" => details['license'], 142 | "title" => details['title'], 143 | "version" => details['version'] 144 | } 145 | 146 | File.open('codemeta.json', 'w') {|f| f.write(JSON.pretty_generate(output)) } 147 | -------------------------------------------------------------------------------- /paper/paper.bib: -------------------------------------------------------------------------------- 1 | 2 | 3 | @article{vincenty, 4 | author = {T. Vincenty}, 5 | title = {Direct and Inverse Solutions of Geodesics on the Ellipsoid with Application of Nested Equations}, 6 | journal = {Survey Review}, 7 | volume = 23, 8 | number = 176, 9 | year = 1975, 10 | month = 4, 11 | pages = {88--93}, 12 | url = {https://www.ngs.noaa.gov/PUBS_LIB/inverse.pdf}, 13 | } 14 | 15 | 16 | @online{veness, 17 | author = {Chris Veness}, 18 | title = {Vincenty solutions of geodesics on the ellipsoid}, 19 | year = 2016, 20 | url = {http://www.movable-type.co.uk/scripts/latlong-vincenty.html#direct}, 21 | } 22 | 23 | @online{pymap3d, 24 | author = {Michael Hirsch}, 25 | title = {PyMap3D: 3-D coordinate conversion software for Python and Matlab}, 26 | year = 2018, 27 | url = {https://github.com/scivision/pymap3d}, 28 | doi = {https://doi.org/10.5281/zenodo.595430}, 29 | } 30 | 31 | @ARTICLE{7368896, 32 | author={M. Hirsch and J. Semeter and M. Zettergren and H. Dahlgren and C. Goenka and H. Akbari}, 33 | journal={IEEE Transactions on Geoscience and Remote Sensing}, 34 | title={Reconstruction of Fine-Scale Auroral Dynamics}, 35 | year={2016}, 36 | volume={54}, 37 | number={5}, 38 | pages={2780-2791}, 39 | doi={10.1109/TGRS.2015.2505686}, 40 | ISSN={0196-2892}, 41 | month={May},} 42 | -------------------------------------------------------------------------------- /paper/paper.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'PyMap3D: 3-D coordinate conversions for terrestrial and geospace environments' 3 | tags: 4 | authors: 5 | - name: Michael Hirsch 6 | orcid: 0000-0002-1637-6526 7 | affiliation: "1, 2" 8 | affiliations: 9 | - name: Boston University ECE Dept. 10 | index: 1 11 | - name: SciVision, Inc. 12 | index: 2 13 | date: 29 January 2018 14 | bibliography: paper.bib 15 | --- 16 | 17 | # Summary 18 | 19 | PyMap3D [@pymap3d] is a pure Python coordinate transformation program that converts between geographic coordinate systems and local coordinate systems useful for airborne, space and remote sensing systems. 20 | Additional standalone coordinate conversions are provided for Matlab/GNU Octave and Fortran. 21 | A subset of PyMap3D functions using syntax compatible with the $1000 Matlab Mapping Toolbox is provided for Matlab and GNU Octave users in the ``matlab/`` directory. 22 | A modern Fortran 2018 implementation of many of the PyMap3D routines is provided in the ``fortran/`` directory. 23 | 24 | The Fortran procedures are "elemental", so they may be used for massively parallel processing of arbitrarily shaped coordinate arrays. 25 | For Python, increased performance and accuracy is optionally available for certain functions with AstroPy. 26 | Numpy is optional to enable multi-dimensional array inputs, but most of the functions work with Python alone (without Numpy). 27 | Other functions that are iterative could possibly be sped up with modules such as Cython or Numba. 28 | 29 | PyMap3D is targeted for users needing conversions between coordinate systems for observation platforms near Earth's surface, 30 | whether underwater, ground-based or space-based platforms. 31 | This includes rocket launches, orbiting spacecrafts, UAVs, cameras, radars and many more. 32 | By adding ellipsoid parameters, it could be readily be used for other planets as well. 33 | The coordinate systems included are: 34 | * ECEF (Earth centered, Earth fixed) 35 | * ENU (East, North, Up) 36 | * NED (North, East, Down) 37 | * ECI (Earth Centered Inertial) 38 | * Geodetic (Latitude, Longitude, Altitude) 39 | * Horizontal Celestial (Alt-Az or Az-El) 40 | * Equatorial Celestial (Right Ascension, Declination) 41 | 42 | Additionally, Vincenty [@vincenty, @veness] geodesic distances and direction are computed. 43 | 44 | PyMap3D has already seen usage in projects including 45 | * [EU ECSEL project 662107 SWARMs](http://swarms.eu/) 46 | * Rafael Defense Systems DataHack 2017 47 | * HERA radiotelescope 48 | * Mahali (NSF Grant: AGS-1343967) 49 | * Solar Eclipse network (NSF Grant: AGS-1743832) 50 | * High Speed Auroral Tomography (NSF Grant: AGS-1237376) [@7368896] 51 | 52 | ## Other Programs 53 | 54 | Other Python geodesy programs include: 55 | 56 | * [PyGeodesy](https://github.com/mrJean1/PyGeodesy) MIT license 57 | * [PyProj](https://github.com/jswhit/pyproj) ISC license 58 | 59 | These programs are targeted for geodesy experts, and require additional packages beyond Python that may not be readily accessible to users. 60 | Further, these programs do not include all the functions of PyMap3D, and do not have the straightforward function-based API of PyMap3D. 61 | 62 | 63 | # References 64 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "pymap3d" 7 | description = "pure Python (no prereqs) coordinate conversions, following convention of several popular Matlab routines." 8 | keywords = ["coordinate-conversion", "geodesy"] 9 | classifiers = ["Development Status :: 5 - Production/Stable", 10 | "Environment :: Console", 11 | "Intended Audience :: Science/Research", 12 | "License :: OSI Approved :: BSD License", 13 | "Operating System :: OS Independent", 14 | "Programming Language :: Python :: 3", 15 | "Topic :: Scientific/Engineering :: GIS" 16 | ] 17 | requires-python = ">=3.8" 18 | dynamic = ["version", "readme"] 19 | 20 | [tool.setuptools.dynamic] 21 | readme = {file = ["README.md"], content-type = "text/markdown"} 22 | version = {attr = "pymap3d.__version__"} 23 | 24 | [project.optional-dependencies] 25 | tests = ["pytest", "pytest-timeout"] 26 | lint = ["flake8", "flake8-bugbear", "flake8-builtins", "flake8-blind-except", "mypy", 27 | "types-python-dateutil", "types-requests"] 28 | coverage = ["pytest-cov"] 29 | format = ["black[jupyter]", "isort"] 30 | core = ["python-dateutil", "numpy >= 1.10.0"] 31 | full = ["astropy", "xarray"] 32 | proj = ["pyproj"] 33 | 34 | [tool.black] 35 | line-length = 100 36 | 37 | [tool.isort] 38 | profile = "black" 39 | known_third_party = ["pymap3d"] 40 | 41 | [tool.mypy] 42 | files = ["src", "Examples", "scripts"] 43 | 44 | ignore_missing_imports = true 45 | 46 | [tool.coverage.run] 47 | branch = true 48 | source = ["src/"] 49 | 50 | [tool.coverage.report] 51 | # Regexes for lines to exclude from consideration 52 | exclude_lines = [ 53 | # Have to re-enable the standard pragma 54 | "pragma: no cover", 55 | # Don't complain about function overloading 56 | "@overload", 57 | ] 58 | -------------------------------------------------------------------------------- /scripts/benchmark_ecef2geo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | benchmark ecef2geodetic 4 | """ 5 | import argparse 6 | import time 7 | 8 | import numpy as np 9 | from pymap3d.ecef import ecef2geodetic 10 | 11 | ll0 = (42.0, 82.0) 12 | 13 | 14 | def bench(N: int) -> float: 15 | x = np.random.random(N) 16 | y = np.random.random(N) 17 | z = np.random.random(N) 18 | 19 | tic = time.monotonic() 20 | _, _, _ = ecef2geodetic(x, y, z) 21 | 22 | return time.monotonic() - tic 23 | 24 | 25 | if __name__ == "__main__": 26 | p = argparse.ArgumentParser() 27 | p.add_argument("N", type=int) 28 | args = p.parse_args() 29 | N = args.N 30 | 31 | print(f"ecef2geodetic: {bench(N):.3f} seconds") 32 | -------------------------------------------------------------------------------- /scripts/benchmark_vincenty.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | vreckon and vdist are iterative algorithms. 4 | How much does PyPy help over Cpython? 5 | 6 | Hmm, PyPy is slower than Cpython.. 7 | 8 | $ pypy3 tests/benchmark_vincenty.py 10000 9 | 2.1160879135131836 10 | 0.06056046485900879 11 | 12 | $ python tests/benchmark_vincenty.py 10000 13 | 0.3325080871582031 14 | 0.02107095718383789 15 | """ 16 | 17 | import argparse 18 | import shutil 19 | import subprocess 20 | import time 21 | from pathlib import Path 22 | 23 | import numpy as np 24 | from pymap3d.vincenty import vdist, vreckon 25 | 26 | R = Path(__file__).parent 27 | 28 | MATLAB = shutil.which("matlab") 29 | 30 | ll0 = (42.0, 82.0) 31 | 32 | 33 | def bench_vreckon(N: int) -> float: 34 | sr = np.random.random(N) 35 | az = np.random.random(N) 36 | 37 | tic = time.monotonic() 38 | _, _ = vreckon(ll0[0], ll0[1], sr, az) 39 | 40 | return time.monotonic() - tic 41 | 42 | 43 | def bench_vdist(N: int) -> float: 44 | lat = np.random.random(N) 45 | lon = np.random.random(N) 46 | 47 | tic = time.monotonic() 48 | _, _ = vdist(ll0[0], ll0[1], lat, lon) 49 | 50 | return time.monotonic() - tic 51 | 52 | 53 | if __name__ == "__main__": 54 | p = argparse.ArgumentParser() 55 | p.add_argument("N", help="number of iterations", type=int) 56 | args = p.parse_args() 57 | N = args.N 58 | 59 | print(f"vreckon: {bench_vreckon(N):.3f}") 60 | print(f"vdist: {bench_vdist(N):.3f}") 61 | 62 | if MATLAB: 63 | print(f"matlab path {R}") 64 | subprocess.check_call( 65 | f'matlab -batch "helper_vdist({ll0[0]}, {ll0[1]}, {N})"', text=True, timeout=90, cwd=R 66 | ) 67 | -------------------------------------------------------------------------------- /scripts/helper_vdist.m: -------------------------------------------------------------------------------- 1 | function helper_vdist(lat, lon, N) 2 | 3 | addons = matlab.addons.installedAddons(); 4 | 5 | M1 = repmat(lat, N, 1); 6 | M2 = repmat(lon, N, 1); 7 | L1 = rand(N,1); 8 | L2 = rand(N,1); 9 | 10 | if any(addons.Name == "Mapping Toolbox") 11 | disp("Using Mapping Toolbox distance()") 12 | f = @() distance(lat, lon, L1, L2); 13 | elseif ~isempty(what("matmap3d")) 14 | disp("Using matmap3d.vdist()") 15 | f = @() matmap3d.vdist(M1, M2, L1, L2); 16 | else 17 | error("Matlab Mapping Toolbox is not installed") 18 | end 19 | 20 | t = timeit(f); 21 | disp(t) 22 | 23 | end 24 | -------------------------------------------------------------------------------- /src/pymap3d/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PyMap3D provides coordinate transforms and geodesy functions with a similar API 3 | to the Matlab Mapping Toolbox, but was of course independently derived. 4 | 5 | For all functions, the default units are: 6 | 7 | distance : float 8 | METERS 9 | angles : float 10 | DEGREES 11 | time : datetime.datetime 12 | UTC time of observation 13 | 14 | These functions may be used with any planetary body, provided the appropriate 15 | reference ellipsoid is defined. The default ellipsoid is WGS-84 16 | 17 | deg : bool = True means degrees. False = radians. 18 | 19 | Most functions accept NumPy arrays of any shape, as well as compatible data types 20 | including AstroPy, Pandas and Xarray that have Numpy-like data properties. 21 | For clarity, we omit all these types in the docs, and just specify the scalar type. 22 | 23 | Other languages 24 | --------------- 25 | 26 | Companion packages exist for: 27 | 28 | * Matlab / GNU Octave: [Matmap3D](https://github.com/geospace-code/matmap3d) 29 | * Fortran: [Maptran3D](https://github.com/geospace-code/maptran3d) 30 | """ 31 | 32 | __version__ = "3.1.1" 33 | 34 | from .aer import aer2ecef, aer2geodetic, ecef2aer, geodetic2aer 35 | from .ecef import ( 36 | ecef2enu, 37 | ecef2enuv, 38 | ecef2geodetic, 39 | eci2geodetic, 40 | enu2ecef, 41 | enu2uvw, 42 | geodetic2ecef, 43 | geodetic2eci, 44 | uvw2enu, 45 | ) 46 | from .ellipsoid import Ellipsoid 47 | from .enu import aer2enu, enu2aer, enu2geodetic, geodetic2enu, enu2ecefv 48 | from .ned import ( 49 | aer2ned, 50 | ecef2ned, 51 | ecef2nedv, 52 | geodetic2ned, 53 | ned2aer, 54 | ned2ecef, 55 | ned2geodetic, 56 | ) 57 | from .sidereal import datetime2sidereal, greenwichsrt 58 | from .spherical import geodetic2spherical, spherical2geodetic 59 | from .timeconv import str2dt 60 | 61 | from .latitude import ( 62 | geodetic2isometric, 63 | isometric2geodetic, 64 | geodetic2rectifying, 65 | rectifying2geodetic, 66 | geodetic2conformal, 67 | conformal2geodetic, 68 | geodetic2parametric, 69 | parametric2geodetic, 70 | geodetic2geocentric, 71 | geocentric2geodetic, 72 | geodetic2authalic, 73 | authalic2geodetic, 74 | geod2geoc, 75 | geoc2geod, 76 | ) 77 | 78 | from .rcurve import parallel, meridian, transverse, geocentric_radius 79 | 80 | __all__ = [ 81 | "aer2ecef", 82 | "aer2geodetic", 83 | "ecef2aer", 84 | "geodetic2aer", 85 | "ecef2enu", 86 | "ecef2enuv", 87 | "ecef2geodetic", 88 | "eci2geodetic", 89 | "enu2ecef", 90 | "enu2uvw", 91 | "geodetic2ecef", 92 | "geodetic2eci", 93 | "uvw2enu", 94 | "Ellipsoid", 95 | "aer2enu", 96 | "enu2aer", 97 | "enu2geodetic", 98 | "enu2ecefv", 99 | "geodetic2enu", 100 | "aer2ned", 101 | "ecef2ned", 102 | "ecef2nedv", 103 | "geodetic2ned", 104 | "ned2aer", 105 | "ned2ecef", 106 | "ned2geodetic", 107 | "datetime2sidereal", 108 | "greenwichsrt", 109 | "geodetic2spherical", 110 | "spherical2geodetic", 111 | "str2dt", 112 | "azel2radec", 113 | "radec2azel", 114 | "parallel", 115 | "meridian", 116 | "transverse", 117 | "geocentric_radius", 118 | "geodetic2isometric", 119 | "isometric2geodetic", 120 | "geodetic2rectifying", 121 | "rectifying2geodetic", 122 | "geodetic2conformal", 123 | "conformal2geodetic", 124 | "geodetic2parametric", 125 | "parametric2geodetic", 126 | "geodetic2geocentric", 127 | "geocentric2geodetic", 128 | "geodetic2authalic", 129 | "authalic2geodetic", 130 | "geod2geoc", 131 | "geoc2geod", 132 | ] 133 | 134 | try: 135 | from .aer import aer2eci, eci2aer 136 | from .azelradec import azel2radec, radec2azel 137 | from .eci import ecef2eci, eci2ecef 138 | 139 | __all__ += ["aer2eci", "eci2aer", "ecef2eci", "eci2ecef"] 140 | except ImportError: 141 | from .vallado import azel2radec, radec2azel 142 | -------------------------------------------------------------------------------- /src/pymap3d/aer.py: -------------------------------------------------------------------------------- 1 | """ transforms involving AER: azimuth, elevation, slant range""" 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | 7 | from .ecef import ecef2enu, ecef2geodetic, enu2uvw, geodetic2ecef 8 | from .ellipsoid import Ellipsoid 9 | from .enu import aer2enu, enu2aer, geodetic2enu 10 | 11 | try: 12 | from .eci import ecef2eci, eci2ecef 13 | except ImportError: 14 | 15 | def eci2ecef(x, y, z, time: datetime) -> tuple: 16 | raise ImportError("Numpy required for eci2ecef") 17 | 18 | def ecef2eci(x, y, z, time: datetime) -> tuple: 19 | raise ImportError("Numpy required for ecef2eci") 20 | 21 | 22 | __all__ = ["aer2ecef", "ecef2aer", "geodetic2aer", "aer2geodetic", "eci2aer", "aer2eci"] 23 | 24 | ELL = Ellipsoid.from_name("wgs84") 25 | 26 | 27 | def ecef2aer( 28 | x, 29 | y, 30 | z, 31 | lat0, 32 | lon0, 33 | h0, 34 | ell: Ellipsoid = ELL, 35 | deg: bool = True, 36 | ) -> tuple: 37 | """ 38 | compute azimuth, elevation and slant range from an Observer to a Point with ECEF coordinates. 39 | 40 | ECEF input location is with units of meters 41 | 42 | Parameters 43 | ---------- 44 | 45 | x : float 46 | ECEF x coordinate (meters) 47 | y : float 48 | ECEF y coordinate (meters) 49 | z : float 50 | ECEF z coordinate (meters) 51 | lat0 : float 52 | Observer geodetic latitude 53 | lon0 : float 54 | Observer geodetic longitude 55 | h0 : float 56 | observer altitude above geodetic ellipsoid (meters) 57 | ell : Ellipsoid, optional 58 | reference ellipsoid 59 | deg : bool, optional 60 | degrees input/output (False: radians in/out) 61 | 62 | Returns 63 | ------- 64 | az : float 65 | azimuth to target 66 | el : float 67 | elevation to target 68 | srange : float 69 | slant range [meters] 70 | """ 71 | 72 | xEast, yNorth, zUp = ecef2enu(x, y, z, lat0, lon0, h0, ell, deg=deg) 73 | 74 | return enu2aer(xEast, yNorth, zUp, deg=deg) 75 | 76 | 77 | def geodetic2aer( 78 | lat, 79 | lon, 80 | h, 81 | lat0, 82 | lon0, 83 | h0, 84 | ell: Ellipsoid = ELL, 85 | deg: bool = True, 86 | ) -> tuple: 87 | """ 88 | gives azimuth, elevation and slant range from an Observer to a Point with geodetic coordinates. 89 | 90 | 91 | Parameters 92 | ---------- 93 | 94 | lat : float 95 | target geodetic latitude 96 | lon : float 97 | target geodetic longitude 98 | h : float 99 | target altitude above geodetic ellipsoid (meters) 100 | lat0 : float 101 | Observer geodetic latitude 102 | lon0 : float 103 | Observer geodetic longitude 104 | h0 : float 105 | observer altitude above geodetic ellipsoid (meters) 106 | ell : Ellipsoid, optional 107 | reference ellipsoid 108 | deg : bool, optional 109 | degrees input/output (False: radians in/out) 110 | 111 | Returns 112 | ------- 113 | az : float 114 | azimuth 115 | el : float 116 | elevation 117 | srange : float 118 | slant range [meters] 119 | """ 120 | e, n, u = geodetic2enu(lat, lon, h, lat0, lon0, h0, ell, deg=deg) 121 | 122 | return enu2aer(e, n, u, deg=deg) 123 | 124 | 125 | def aer2geodetic( 126 | az, 127 | el, 128 | srange, 129 | lat0, 130 | lon0, 131 | h0, 132 | ell: Ellipsoid = ELL, 133 | deg: bool = True, 134 | ) -> tuple: 135 | """ 136 | gives geodetic coordinates of a point with az, el, range 137 | from an observer at lat0, lon0, h0 138 | 139 | Parameters 140 | ---------- 141 | az : float 142 | azimuth to target 143 | el : float 144 | elevation to target 145 | srange : float 146 | slant range [meters] 147 | lat0 : float 148 | Observer geodetic latitude 149 | lon0 : float 150 | Observer geodetic longitude 151 | h0 : float 152 | observer altitude above geodetic ellipsoid (meters) 153 | ell : Ellipsoid, optional 154 | reference ellipsoid 155 | deg : bool, optional 156 | degrees input/output (False: radians in/out) 157 | 158 | Returns 159 | ------- 160 | 161 | In reference ellipsoid system: 162 | 163 | lat : float 164 | geodetic latitude 165 | lon : float 166 | geodetic longitude 167 | alt : float 168 | altitude above ellipsoid (meters) 169 | """ 170 | x, y, z = aer2ecef(az, el, srange, lat0, lon0, h0, ell=ell, deg=deg) 171 | 172 | return ecef2geodetic(x, y, z, ell=ell, deg=deg) 173 | 174 | 175 | def eci2aer(x, y, z, lat0, lon0, h0, t: datetime, *, deg: bool = True) -> tuple: 176 | """ 177 | takes Earth Centered Inertial x,y,z ECI coordinates of point and gives az, el, slant range from Observer 178 | 179 | Parameters 180 | ---------- 181 | 182 | x : float 183 | ECI x-location [meters] 184 | y : float 185 | ECI y-location [meters] 186 | z : float 187 | ECI z-location [meters] 188 | lat0 : float 189 | Observer geodetic latitude 190 | lon0 : float 191 | Observer geodetic longitude 192 | h0 : float 193 | observer altitude above geodetic ellipsoid (meters) 194 | t : datetime.datetime 195 | Observation time 196 | deg : bool, optional 197 | true: degrees, false: radians 198 | 199 | Returns 200 | ------- 201 | az : float 202 | azimuth to target 203 | el : float 204 | elevation to target 205 | srange : float 206 | slant range [meters] 207 | """ 208 | 209 | xe, ye, ze = eci2ecef(x, y, z, t) 210 | 211 | return ecef2aer(xe, ye, ze, lat0, lon0, h0, deg=deg) 212 | 213 | 214 | def aer2eci( 215 | az, 216 | el, 217 | srange, 218 | lat0, 219 | lon0, 220 | h0, 221 | t: datetime, 222 | ell: Ellipsoid = ELL, 223 | *, 224 | deg: bool = True, 225 | ) -> tuple: 226 | """ 227 | gives ECI of a point from an observer at az, el, slant range 228 | 229 | Parameters 230 | ---------- 231 | az : float 232 | azimuth to target 233 | el : float 234 | elevation to target 235 | srange : float 236 | slant range [meters] 237 | lat0 : float 238 | Observer geodetic latitude 239 | lon0 : float 240 | Observer geodetic longitude 241 | h0 : float 242 | observer altitude above geodetic ellipsoid (meters) 243 | t : datetime.datetime 244 | Observation time 245 | ell : Ellipsoid, optional 246 | reference ellipsoid 247 | deg : bool, optional 248 | degrees input/output (False: radians in/out) 249 | 250 | Returns 251 | ------- 252 | 253 | Earth Centered Inertial x,y,z 254 | 255 | x : float 256 | ECEF x coordinate (meters) 257 | y : float 258 | ECEF y coordinate (meters) 259 | z : float 260 | ECEF z coordinate (meters) 261 | """ 262 | 263 | x, y, z = aer2ecef(az, el, srange, lat0, lon0, h0, ell, deg=deg) 264 | 265 | return ecef2eci(x, y, z, t) 266 | 267 | 268 | def aer2ecef( 269 | az, 270 | el, 271 | srange, 272 | lat0, 273 | lon0, 274 | alt0, 275 | ell: Ellipsoid = ELL, 276 | deg: bool = True, 277 | ) -> tuple: 278 | """ 279 | converts target azimuth, elevation, range from observer at lat0,lon0,alt0 to ECEF coordinates. 280 | 281 | Parameters 282 | ---------- 283 | az : float 284 | azimuth to target 285 | el : float 286 | elevation to target 287 | srange : float 288 | slant range [meters] 289 | lat0 : float 290 | Observer geodetic latitude 291 | lon0 : float 292 | Observer geodetic longitude 293 | alt0 : float 294 | observer altitude above geodetic ellipsoid (meters) 295 | ell : Ellipsoid, optional 296 | reference ellipsoid 297 | deg : bool, optional 298 | degrees input/output (False: radians in/out) 299 | 300 | Returns 301 | ------- 302 | 303 | ECEF (Earth centered, Earth fixed) x,y,z 304 | 305 | x : float 306 | ECEF x coordinate (meters) 307 | y : float 308 | ECEF y coordinate (meters) 309 | z : float 310 | ECEF z coordinate (meters) 311 | 312 | 313 | Notes 314 | ------ 315 | if srange==NaN, z=NaN 316 | """ 317 | # Origin of the local system in geocentric coordinates. 318 | x0, y0, z0 = geodetic2ecef(lat0, lon0, alt0, ell, deg=deg) 319 | # Convert Local Spherical AER to ENU 320 | e1, n1, u1 = aer2enu(az, el, srange, deg=deg) 321 | # Rotating ENU to ECEF 322 | dx, dy, dz = enu2uvw(e1, n1, u1, lat0, lon0, deg=deg) 323 | # Origin + offset from origin equals position in ECEF 324 | return x0 + dx, y0 + dy, z0 + dz 325 | -------------------------------------------------------------------------------- /src/pymap3d/azelradec.py: -------------------------------------------------------------------------------- 1 | """ 2 | Azimuth / elevation <==> Right ascension, declination 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | from datetime import datetime 8 | 9 | from .timeconv import str2dt # astropy can't handle xarray times (yet) 10 | from .vallado import azel2radec as vazel2radec 11 | from .vallado import radec2azel as vradec2azel 12 | 13 | try: 14 | from astropy import units as u 15 | from astropy.coordinates import ICRS, AltAz, Angle, EarthLocation, SkyCoord 16 | from astropy.time import Time 17 | except ImportError: 18 | pass 19 | 20 | __all__ = ["radec2azel", "azel2radec"] 21 | 22 | 23 | def azel2radec( 24 | az_deg: float, 25 | el_deg: float, 26 | lat_deg: float, 27 | lon_deg: float, 28 | time: datetime, 29 | ) -> tuple[float, float]: 30 | """ 31 | viewing angle (az, el) to sky coordinates (ra, dec) 32 | 33 | Parameters 34 | ---------- 35 | az_deg : float 36 | azimuth [degrees clockwize from North] 37 | el_deg : float 38 | elevation [degrees above horizon (neglecting aberration)] 39 | lat_deg : float 40 | observer latitude [-90, 90] 41 | lon_deg : float 42 | observer longitude [-180, 180] (degrees) 43 | time : datetime.datetime or str 44 | time of observation 45 | 46 | Returns 47 | ------- 48 | ra_deg : float 49 | ecliptic right ascension (degress) 50 | dec_deg : float 51 | ecliptic declination (degrees) 52 | """ 53 | 54 | try: 55 | return azel2radec_astropy(az_deg, el_deg, lat_deg, lon_deg, time) 56 | except NameError: 57 | return vazel2radec(az_deg, el_deg, lat_deg, lon_deg, time) 58 | 59 | 60 | def azel2radec_astropy( 61 | az_deg: float, el_deg: float, lat_deg: float, lon_deg: float, time: datetime 62 | ) -> tuple[float, float]: 63 | """azel2radec using Astropy 64 | see azel2radec() for description 65 | """ 66 | obs = EarthLocation(lat=lat_deg * u.deg, lon=lon_deg * u.deg) 67 | 68 | direc = AltAz(location=obs, obstime=Time(str2dt(time)), az=az_deg * u.deg, alt=el_deg * u.deg) 69 | 70 | sky = SkyCoord(direc.transform_to(ICRS())) 71 | 72 | return sky.ra.deg, sky.dec.deg 73 | 74 | 75 | def radec2azel( 76 | ra_deg: float, 77 | dec_deg: float, 78 | lat_deg: float, 79 | lon_deg: float, 80 | time: datetime, 81 | ) -> tuple[float, float]: 82 | """ 83 | sky coordinates (ra, dec) to viewing angle (az, el) 84 | 85 | Parameters 86 | ---------- 87 | ra_deg : float 88 | ecliptic right ascension (degress) 89 | dec_deg : float 90 | ecliptic declination (degrees) 91 | lat_deg : float 92 | observer latitude [-90, 90] 93 | lon_deg : float 94 | observer longitude [-180, 180] (degrees) 95 | time : datetime.datetime or str 96 | time of observation 97 | 98 | Returns 99 | ------- 100 | az_deg : float 101 | azimuth [degrees clockwize from North] 102 | el_deg : float 103 | elevation [degrees above horizon (neglecting aberration)] 104 | """ 105 | 106 | try: 107 | return radec2azel_astropy(ra_deg, dec_deg, lat_deg, lon_deg, time) 108 | except NameError: 109 | return vradec2azel(ra_deg, dec_deg, lat_deg, lon_deg, time) 110 | 111 | 112 | def radec2azel_astropy( 113 | ra_deg: float, 114 | dec_deg: float, 115 | lat_deg: float, 116 | lon_deg: float, 117 | time: datetime, 118 | ) -> tuple[float, float]: 119 | """ 120 | rade2azel using Astropy 121 | see radec2azel() for description 122 | """ 123 | 124 | obs = EarthLocation(lat=lat_deg * u.deg, lon=lon_deg * u.deg) 125 | 126 | points = SkyCoord(Angle(ra_deg, unit=u.deg), Angle(dec_deg, unit=u.deg), equinox="J2000.0") 127 | 128 | altaz = points.transform_to(AltAz(location=obs, obstime=Time(str2dt(time)))) 129 | 130 | return altaz.az.degree, altaz.alt.degree 131 | -------------------------------------------------------------------------------- /src/pymap3d/ecef.py: -------------------------------------------------------------------------------- 1 | """ Transforms involving ECEF: earth-centered, earth-fixed frame """ 2 | 3 | from __future__ import annotations 4 | 5 | import warnings 6 | 7 | try: 8 | from numpy import asarray, empty_like, finfo, where 9 | 10 | from .eci import ecef2eci, eci2ecef 11 | except ImportError: 12 | 13 | def eci2ecef(x, y, z, time: datetime) -> tuple: 14 | raise ImportError("Numpy required for eci2ecef") 15 | 16 | def ecef2eci(x, y, z, time: datetime) -> tuple: 17 | raise ImportError("Numpy required for ecef2eci") 18 | 19 | 20 | from datetime import datetime 21 | from math import pi 22 | 23 | from .ellipsoid import Ellipsoid 24 | from .mathfun import atan, atan2, cos, degrees, hypot, isclose, radians, sin, sqrt, tan 25 | 26 | __all__ = [ 27 | "geodetic2ecef", 28 | "ecef2geodetic", 29 | "ecef2enuv", 30 | "ecef2enu", 31 | "enu2uvw", 32 | "uvw2enu", 33 | "eci2geodetic", 34 | "geodetic2eci", 35 | "enu2ecef", 36 | ] 37 | 38 | ELL = Ellipsoid.from_name("wgs84") 39 | 40 | 41 | def geodetic2ecef( 42 | lat, 43 | lon, 44 | alt, 45 | ell: Ellipsoid = ELL, 46 | deg: bool = True, 47 | ) -> tuple: 48 | """ 49 | point transformation from Geodetic of specified ellipsoid (default WGS-84) to ECEF 50 | 51 | Parameters 52 | ---------- 53 | 54 | lat 55 | target geodetic latitude 56 | lon 57 | target geodetic longitude 58 | alt 59 | target altitude above geodetic ellipsoid (meters) 60 | ell : Ellipsoid, optional 61 | reference ellipsoid 62 | deg : bool, optional 63 | degrees input/output (False: radians in/out) 64 | 65 | 66 | Returns 67 | ------- 68 | 69 | ECEF (Earth centered, Earth fixed) x,y,z 70 | 71 | x 72 | target x ECEF coordinate (meters) 73 | y 74 | target y ECEF coordinate (meters) 75 | z 76 | target z ECEF coordinate (meters) 77 | """ 78 | 79 | if deg: 80 | lat = radians(lat) 81 | lon = radians(lon) 82 | 83 | # radius of curvature of the prime vertical section 84 | N = ell.semimajor_axis**2 / hypot(ell.semimajor_axis * cos(lat), ell.semiminor_axis * sin(lat)) 85 | # Compute cartesian (geocentric) coordinates given (curvilinear) geodetic coordinates. 86 | x = (N + alt) * cos(lat) * cos(lon) 87 | y = (N + alt) * cos(lat) * sin(lon) 88 | z = (N * (ell.semiminor_axis / ell.semimajor_axis) ** 2 + alt) * sin(lat) 89 | 90 | return x, y, z 91 | 92 | 93 | def ecef2geodetic( 94 | x, 95 | y, 96 | z, 97 | ell: Ellipsoid = ELL, 98 | deg: bool = True, 99 | ) -> tuple: 100 | """ 101 | convert ECEF (meters) to geodetic coordinates 102 | 103 | Parameters 104 | ---------- 105 | x 106 | target x ECEF coordinate (meters) 107 | y 108 | target y ECEF coordinate (meters) 109 | z 110 | target z ECEF coordinate (meters) 111 | ell : Ellipsoid, optional 112 | reference ellipsoid 113 | deg : bool, optional 114 | degrees input/output (False: radians in/out) 115 | 116 | Returns 117 | ------- 118 | lat 119 | target geodetic latitude 120 | lon 121 | target geodetic longitude 122 | alt 123 | target altitude above geodetic ellipsoid (meters) 124 | 125 | based on: 126 | You, Rey-Jer. (2000). Transformation of Cartesian to Geodetic Coordinates without Iterations. 127 | Journal of Surveying Engineering. doi: 10.1061/(ASCE)0733-9453 128 | """ 129 | 130 | try: 131 | x = asarray(x) 132 | y = asarray(y) 133 | z = asarray(z) 134 | except NameError: 135 | pass 136 | 137 | r = sqrt(x**2 + y**2 + z**2) 138 | 139 | E = sqrt(ell.semimajor_axis**2 - ell.semiminor_axis**2) 140 | 141 | # eqn. 4a 142 | u = sqrt(0.5 * (r**2 - E**2) + 0.5 * hypot(r**2 - E**2, 2 * E * z)) 143 | 144 | hxy = hypot(x, y) 145 | 146 | huE = hypot(u, E) 147 | 148 | # eqn. 4b 149 | try: 150 | Beta = empty_like(r) 151 | ibad = isclose(u, 0) | isclose(hxy, 0) 152 | Beta[~ibad] = atan(huE[~ibad] / u[~ibad] * z[~ibad] / hxy[~ibad]) 153 | # eqn. 13 154 | Beta[~ibad] += ( 155 | (ell.semiminor_axis * u[~ibad] - ell.semimajor_axis * huE[~ibad] + E**2) 156 | * sin(Beta[~ibad]) 157 | ) / (ell.semimajor_axis * huE[~ibad] * 1 / cos(Beta[~ibad]) - E**2 * cos(Beta[~ibad])) 158 | iz = ibad & isclose(z, 0) 159 | i1 = ibad & ~iz & (z > 0) 160 | i2 = ibad & ~iz & ~i1 161 | 162 | Beta[iz] = 0 163 | Beta[i1] = pi / 2 164 | Beta[i2] = -pi / 2 165 | except NameError: 166 | try: 167 | with warnings.catch_warnings(record=True): 168 | warnings.simplefilter("error") 169 | Beta = atan(huE / u * z / hxy) 170 | # eqn. 13 171 | Beta += ( 172 | (ell.semiminor_axis * u - ell.semimajor_axis * huE + E**2) * sin(Beta) 173 | ) / (ell.semimajor_axis * huE * 1 / cos(Beta) - E**2 * cos(Beta)) 174 | except (ArithmeticError, RuntimeWarning): 175 | if isclose(z, 0): 176 | Beta = 0 177 | elif z > 0: 178 | Beta = pi / 2 179 | else: 180 | Beta = -pi / 2 181 | 182 | # eqn. 4c 183 | # %% final output 184 | lat = atan(ell.semimajor_axis / ell.semiminor_axis * tan(Beta)) 185 | 186 | try: 187 | # patch latitude for float32 precision loss 188 | lim_pi2 = pi / 2 - finfo(Beta.dtype).eps 189 | lat = where(Beta >= lim_pi2, pi / 2, lat) 190 | lat = where(Beta <= -lim_pi2, -pi / 2, lat) 191 | except NameError: 192 | pass 193 | 194 | lon = atan2(y, x) 195 | 196 | # eqn. 7 197 | cosBeta = cos(Beta) 198 | try: 199 | # patch altitude for float32 precision loss 200 | cosBeta = where(Beta >= lim_pi2, 0, cosBeta) 201 | cosBeta = where(Beta <= -lim_pi2, 0, cosBeta) 202 | except NameError: 203 | pass 204 | 205 | alt = hypot(z - ell.semiminor_axis * sin(Beta), hxy - ell.semimajor_axis * cosBeta) 206 | 207 | # inside ellipsoid? 208 | inside = ( 209 | x**2 / ell.semimajor_axis**2 + y**2 / ell.semimajor_axis**2 + z**2 / ell.semiminor_axis**2 210 | < 1 211 | ) 212 | 213 | try: 214 | if inside.any(): 215 | # avoid all false assignment bug 216 | alt[inside] = -alt[inside] 217 | except (TypeError, AttributeError): 218 | if inside: 219 | alt = -alt 220 | 221 | if deg: 222 | lat = degrees(lat) 223 | lon = degrees(lon) 224 | else: 225 | try: 226 | lat = lat.squeeze()[()] 227 | # ensures scalar in, scalar out 228 | except AttributeError: 229 | pass 230 | 231 | return lat, lon, alt 232 | 233 | 234 | def ecef2enuv(u, v, w, lat0, lon0, deg: bool = True) -> tuple: 235 | """ 236 | VECTOR from observer to target ECEF => ENU 237 | 238 | Parameters 239 | ---------- 240 | u 241 | target x ECEF coordinate (meters) 242 | v 243 | target y ECEF coordinate (meters) 244 | w 245 | target z ECEF coordinate (meters) 246 | lat0 247 | Observer geodetic latitude 248 | lon0 249 | Observer geodetic longitude 250 | deg : bool, optional 251 | degrees input/output (False: radians in/out) 252 | 253 | Returns 254 | ------- 255 | uEast 256 | target east ENU coordinate (meters) 257 | vNorth 258 | target north ENU coordinate (meters) 259 | wUp 260 | target up ENU coordinate (meters) 261 | 262 | """ 263 | if deg: 264 | lat0 = radians(lat0) 265 | lon0 = radians(lon0) 266 | 267 | t = cos(lon0) * u + sin(lon0) * v 268 | uEast = -sin(lon0) * u + cos(lon0) * v 269 | wUp = cos(lat0) * t + sin(lat0) * w 270 | vNorth = -sin(lat0) * t + cos(lat0) * w 271 | 272 | return uEast, vNorth, wUp 273 | 274 | 275 | def ecef2enu( 276 | x, 277 | y, 278 | z, 279 | lat0, 280 | lon0, 281 | h0, 282 | ell: Ellipsoid = ELL, 283 | deg: bool = True, 284 | ) -> tuple: 285 | """ 286 | from observer to target, ECEF => ENU 287 | 288 | Parameters 289 | ---------- 290 | x 291 | target x ECEF coordinate (meters) 292 | y 293 | target y ECEF coordinate (meters) 294 | z 295 | target z ECEF coordinate (meters) 296 | lat0 297 | Observer geodetic latitude 298 | lon0 299 | Observer geodetic longitude 300 | h0 301 | observer altitude above geodetic ellipsoid (meters) 302 | ell : Ellipsoid, optional 303 | reference ellipsoid 304 | deg : bool, optional 305 | degrees input/output (False: radians in/out) 306 | 307 | Returns 308 | ------- 309 | East 310 | target east ENU coordinate (meters) 311 | North 312 | target north ENU coordinate (meters) 313 | Up 314 | target up ENU coordinate (meters) 315 | 316 | """ 317 | x0, y0, z0 = geodetic2ecef(lat0, lon0, h0, ell, deg=deg) 318 | 319 | return uvw2enu(x - x0, y - y0, z - z0, lat0, lon0, deg=deg) 320 | 321 | 322 | def enu2uvw( 323 | east, 324 | north, 325 | up, 326 | lat0, 327 | lon0, 328 | deg: bool = True, 329 | ) -> tuple: 330 | """ 331 | Parameters 332 | ---------- 333 | 334 | east 335 | target east ENU coordinate (meters) 336 | north 337 | target north ENU coordinate (meters) 338 | up 339 | target up ENU coordinate (meters) 340 | lat0 341 | Observer geodetic latitude 342 | lon0 343 | Observer geodetic longitude 344 | deg : bool, optional 345 | degrees input/output (False: radians in/out) 346 | 347 | Results 348 | ------- 349 | 350 | u 351 | v 352 | w 353 | """ 354 | 355 | if deg: 356 | lat0 = radians(lat0) 357 | lon0 = radians(lon0) 358 | 359 | t = cos(lat0) * up - sin(lat0) * north 360 | w = sin(lat0) * up + cos(lat0) * north 361 | 362 | u = cos(lon0) * t - sin(lon0) * east 363 | v = sin(lon0) * t + cos(lon0) * east 364 | 365 | return u, v, w 366 | 367 | 368 | def uvw2enu(u, v, w, lat0, lon0, deg: bool = True) -> tuple: 369 | """ 370 | Parameters 371 | ---------- 372 | 373 | u 374 | target x ECEF coordinate (meters) 375 | v 376 | target y ECEF coordinate (meters) 377 | w 378 | target z ECEF coordinate (meters) 379 | lat0 380 | Observer geodetic latitude 381 | lon0 382 | Observer geodetic longitude 383 | deg : bool, optional 384 | degrees input/output (False: radians in/out) 385 | 386 | Results 387 | ------- 388 | 389 | East 390 | target east ENU coordinate (meters) 391 | North 392 | target north ENU coordinate (meters) 393 | Up 394 | target up ENU coordinate (meters) 395 | """ 396 | if deg: 397 | lat0 = radians(lat0) 398 | lon0 = radians(lon0) 399 | 400 | t = cos(lon0) * u + sin(lon0) * v 401 | East = -sin(lon0) * u + cos(lon0) * v 402 | Up = cos(lat0) * t + sin(lat0) * w 403 | North = -sin(lat0) * t + cos(lat0) * w 404 | 405 | return East, North, Up 406 | 407 | 408 | def eci2geodetic(x, y, z, t: datetime, ell: Ellipsoid = ELL, *, deg: bool = True) -> tuple: 409 | """ 410 | convert Earth Centered Internal ECI to geodetic coordinates 411 | 412 | J2000 time 413 | 414 | Parameters 415 | ---------- 416 | x 417 | ECI x-location [meters] 418 | y 419 | ECI y-location [meters] 420 | z 421 | ECI z-location [meters] 422 | t : datetime.datetime, float 423 | UTC time 424 | ell : Ellipsoid, optional 425 | planet ellipsoid model 426 | deg : bool, optional 427 | if True, degrees. if False, radians 428 | 429 | Results 430 | ------- 431 | lat 432 | geodetic latitude 433 | lon 434 | geodetic longitude 435 | alt 436 | altitude above ellipsoid (meters) 437 | 438 | eci2geodetic() a.k.a. eci2lla() 439 | """ 440 | 441 | xecef, yecef, zecef = eci2ecef(x, y, z, t) 442 | 443 | return ecef2geodetic(xecef, yecef, zecef, ell, deg) 444 | 445 | 446 | def geodetic2eci(lat, lon, alt, t: datetime, ell: Ellipsoid = ELL, *, deg: bool = True) -> tuple: 447 | """ 448 | convert geodetic coordinates to Earth Centered Internal ECI 449 | 450 | J2000 frame 451 | 452 | Parameters 453 | ---------- 454 | lat 455 | geodetic latitude 456 | lon 457 | geodetic longitude 458 | alt 459 | altitude above ellipsoid (meters) 460 | t : datetime.datetime, float 461 | UTC time 462 | ell : Ellipsoid, optional 463 | planet ellipsoid model 464 | deg : bool, optional 465 | if True, degrees. if False, radians 466 | 467 | Results 468 | ------- 469 | x 470 | ECI x-location [meters] 471 | y 472 | ECI y-location [meters] 473 | z 474 | ECI z-location [meters] 475 | 476 | geodetic2eci() a.k.a lla2eci() 477 | """ 478 | 479 | x, y, z = geodetic2ecef(lat, lon, alt, ell, deg) 480 | 481 | return ecef2eci(x, y, z, t) 482 | 483 | 484 | def enu2ecef( 485 | e1, 486 | n1, 487 | u1, 488 | lat0, 489 | lon0, 490 | h0, 491 | ell: Ellipsoid = ELL, 492 | deg: bool = True, 493 | ) -> tuple: 494 | """ 495 | ENU to ECEF 496 | 497 | Parameters 498 | ---------- 499 | 500 | e1 501 | target east ENU coordinate (meters) 502 | n1 503 | target north ENU coordinate (meters) 504 | u1 505 | target up ENU coordinate (meters) 506 | lat0 507 | Observer geodetic latitude 508 | lon0 509 | Observer geodetic longitude 510 | h0 511 | observer altitude above geodetic ellipsoid (meters) 512 | ell : Ellipsoid, optional 513 | reference ellipsoid 514 | deg : bool, optional 515 | degrees input/output (False: radians in/out) 516 | 517 | 518 | Results 519 | ------- 520 | x 521 | target x ECEF coordinate (meters) 522 | y 523 | target y ECEF coordinate (meters) 524 | z 525 | target z ECEF coordinate (meters) 526 | """ 527 | 528 | x0, y0, z0 = geodetic2ecef(lat0, lon0, h0, ell, deg=deg) 529 | dx, dy, dz = enu2uvw(e1, n1, u1, lat0, lon0, deg=deg) 530 | 531 | return x0 + dx, y0 + dy, z0 + dz 532 | -------------------------------------------------------------------------------- /src/pymap3d/eci.py: -------------------------------------------------------------------------------- 1 | """ transforms involving ECI earth-centered inertial """ 2 | 3 | from __future__ import annotations 4 | 5 | from datetime import datetime 6 | 7 | import numpy 8 | 9 | try: 10 | import astropy.units as u 11 | from astropy.coordinates import GCRS, ITRS, CartesianRepresentation, EarthLocation 12 | except ImportError: 13 | pass 14 | 15 | from .sidereal import greenwichsrt, juliandate 16 | 17 | __all__ = ["eci2ecef", "ecef2eci"] 18 | 19 | 20 | def eci2ecef(x, y, z, time: datetime) -> tuple: 21 | """ 22 | Observer => Point ECI => ECEF 23 | 24 | J2000 frame 25 | 26 | Parameters 27 | ---------- 28 | x : float 29 | ECI x-location [meters] 30 | y : float 31 | ECI y-location [meters] 32 | z : float 33 | ECI z-location [meters] 34 | time : datetime.datetime 35 | time of obsevation (UTC) 36 | 37 | Results 38 | ------- 39 | x_ecef : float 40 | x ECEF coordinate 41 | y_ecef : float 42 | y ECEF coordinate 43 | z_ecef : float 44 | z ECEF coordinate 45 | """ 46 | 47 | try: 48 | return eci2ecef_astropy(x, y, z, time) 49 | except NameError: 50 | return eci2ecef_numpy(x, y, z, time) 51 | 52 | 53 | def eci2ecef_astropy(x, y, z, t: datetime) -> tuple: 54 | """ 55 | eci2ecef using Astropy 56 | 57 | see eci2ecef() for description 58 | """ 59 | 60 | gcrs = GCRS(CartesianRepresentation(x * u.m, y * u.m, z * u.m), obstime=t) 61 | itrs = gcrs.transform_to(ITRS(obstime=t)) 62 | 63 | x_ecef = itrs.x.value 64 | y_ecef = itrs.y.value 65 | z_ecef = itrs.z.value 66 | 67 | return x_ecef, y_ecef, z_ecef 68 | 69 | 70 | def eci2ecef_numpy(x, y, z, t: datetime) -> tuple: 71 | """ 72 | eci2ecef using Numpy 73 | 74 | see eci2ecef() for description 75 | """ 76 | 77 | x = numpy.atleast_1d(x) 78 | y = numpy.atleast_1d(y) 79 | z = numpy.atleast_1d(z) 80 | gst = numpy.atleast_1d(greenwichsrt(juliandate(t))) 81 | assert ( 82 | x.shape == y.shape == z.shape 83 | ), f"shape mismatch: x: ${x.shape} y: {y.shape} z: {z.shape}" 84 | 85 | if gst.size == 1 and x.size != 1: 86 | gst = numpy.broadcast_to(gst, x.shape[0]) 87 | assert x.size == gst.size, f"shape mismatch: x: {x.shape} gst: {gst.shape}" 88 | 89 | eci = numpy.column_stack((x.ravel(), y.ravel(), z.ravel())) 90 | ecef = numpy.empty((x.size, 3)) 91 | for i in range(eci.shape[0]): 92 | ecef[i, :] = R3(gst[i]) @ eci[i, :].T 93 | 94 | x_ecef = ecef[:, 0].reshape(x.shape) 95 | y_ecef = ecef[:, 1].reshape(y.shape) 96 | z_ecef = ecef[:, 2].reshape(z.shape) 97 | 98 | return x_ecef.squeeze()[()], y_ecef.squeeze()[()], z_ecef.squeeze()[()] 99 | 100 | 101 | def ecef2eci(x, y, z, time: datetime) -> tuple: 102 | """ 103 | Point => Point ECEF => ECI 104 | 105 | J2000 frame 106 | 107 | Parameters 108 | ---------- 109 | 110 | x : float 111 | target x ECEF coordinate 112 | y : float 113 | target y ECEF coordinate 114 | z : float 115 | target z ECEF coordinate 116 | time : datetime.datetime 117 | time of observation 118 | 119 | Results 120 | ------- 121 | x_eci : float 122 | x ECI coordinate 123 | y_eci : float 124 | y ECI coordinate 125 | z_eci : float 126 | z ECI coordinate 127 | """ 128 | 129 | try: 130 | return ecef2eci_astropy(x, y, z, time) 131 | except NameError: 132 | return ecef2eci_numpy(x, y, z, time) 133 | 134 | 135 | def ecef2eci_astropy(x, y, z, t: datetime) -> tuple: 136 | """ecef2eci using Astropy 137 | see ecef2eci() for description 138 | """ 139 | itrs = ITRS(CartesianRepresentation(x * u.m, y * u.m, z * u.m), obstime=t) 140 | gcrs = itrs.transform_to(GCRS(obstime=t)) 141 | eci = EarthLocation(*gcrs.cartesian.xyz) 142 | 143 | x_eci = eci.x.value 144 | y_eci = eci.y.value 145 | z_eci = eci.z.value 146 | 147 | return x_eci, y_eci, z_eci 148 | 149 | 150 | def ecef2eci_numpy(x, y, z, t: datetime) -> tuple: 151 | """ecef2eci using Numpy 152 | see ecef2eci() for description 153 | """ 154 | 155 | x = numpy.atleast_1d(x) 156 | y = numpy.atleast_1d(y) 157 | z = numpy.atleast_1d(z) 158 | gst = numpy.atleast_1d(greenwichsrt(juliandate(t))) 159 | assert x.shape == y.shape == z.shape 160 | assert x.size == gst.size 161 | 162 | ecef = numpy.column_stack((x.ravel(), y.ravel(), z.ravel())) 163 | eci = numpy.empty((x.size, 3)) 164 | for i in range(x.size): 165 | eci[i, :] = R3(gst[i]).T @ ecef[i, :] 166 | 167 | x_eci = eci[:, 0].reshape(x.shape) 168 | y_eci = eci[:, 1].reshape(y.shape) 169 | z_eci = eci[:, 2].reshape(z.shape) 170 | 171 | return x_eci.squeeze()[()], y_eci.squeeze()[()], z_eci.squeeze()[()] 172 | 173 | 174 | def R3(x: float): 175 | """Rotation matrix for ECI""" 176 | return numpy.array( 177 | [[numpy.cos(x), numpy.sin(x), 0], [-numpy.sin(x), numpy.cos(x), 0], [0, 0, 1]] 178 | ) 179 | -------------------------------------------------------------------------------- /src/pymap3d/ellipsoid.py: -------------------------------------------------------------------------------- 1 | """Minimal class for planetary ellipsoids""" 2 | 3 | from __future__ import annotations 4 | from math import sqrt 5 | from dataclasses import dataclass, field 6 | from typing import TypedDict 7 | import sys 8 | 9 | if sys.version_info < (3, 9): 10 | from typing import Dict 11 | else: 12 | Dict = dict 13 | 14 | 15 | class Model(TypedDict): 16 | """Ellipsoid parameters""" 17 | 18 | name: str 19 | a: float 20 | b: float 21 | 22 | 23 | @dataclass 24 | class Ellipsoid: 25 | """ 26 | generate reference ellipsoid parameters 27 | 28 | as everywhere else in pymap3d, distance units are METERS 29 | 30 | Ellipsoid sources 31 | ----------------- 32 | 33 | maupertuis, plessis, everest1830, everest1830m, everest1967, 34 | airy, bessel, clarke1866, clarke1878, clarke1860, helmert, hayford, 35 | international1924, krassovsky1940, wgs66, australian, international1967, 36 | grs67, sa1969, wgs72, iers1989, iers2003: 37 | 38 | - https://en.wikipedia.org/wiki/Earth_ellipsoid#Historical_Earth_ellipsoids 39 | - https://en.wikibooks.org/wiki/PROJ.4#Spheroid 40 | 41 | wgs84: https://en.wikipedia.org/wiki/World_Geodetic_System#WGS84 42 | 43 | wgs84_mean: https://en.wikipedia.org/wiki/Earth_radius#Mean_radii 44 | 45 | grs80: https://en.wikipedia.org/wiki/GRS_80 46 | 47 | io: https://doi.org/10.1006/icar.1998.5987 48 | 49 | pz90.11: https://structure.mil.ru/files/pz-90.pdf 50 | 51 | gsk2011: https://racurs.ru/downloads/documentation/gost_r_32453-2017.pdf 52 | 53 | mars: https://tharsis.gsfc.nasa.gov/geodesy.html 54 | 55 | mercury, venus, moon, jupiter, saturn, uranus, neptune: 56 | 57 | - https://nssdc.gsfc.nasa.gov/planetary/factsheet/index.html 58 | 59 | feel free to suggest additional ellipsoids 60 | """ 61 | 62 | model: str # short name 63 | name: str # name for printing 64 | semimajor_axis: float 65 | semiminor_axis: float 66 | flattening: float 67 | thirdflattening: float 68 | eccentricity: float 69 | models = field(default_factory=Dict[str, Model]) 70 | 71 | def __init__( 72 | self, semimajor_axis: float, semiminor_axis: float, name: str = "", model: str = "" 73 | ): 74 | """ 75 | Ellipsoidal model of world 76 | 77 | Parameters 78 | ---------- 79 | semimajor_axis : float 80 | semimajor axis in meters 81 | semiminor_axis : float 82 | semiminor axis in meters 83 | name: str, optional 84 | Human-friendly name for the ellipsoid 85 | model: str, optional 86 | Short name for the ellipsoid 87 | """ 88 | 89 | self.flattening = (semimajor_axis - semiminor_axis) / semimajor_axis 90 | assert self.flattening >= 0, "flattening must be >= 0" 91 | self.thirdflattening = (semimajor_axis - semiminor_axis) / (semimajor_axis + semiminor_axis) 92 | self.eccentricity = sqrt(2 * self.flattening - self.flattening**2) 93 | 94 | self.name = name 95 | self.model = model 96 | self.semimajor_axis = semimajor_axis 97 | self.semiminor_axis = semiminor_axis 98 | 99 | models = { 100 | # Earth ellipsoids 101 | "maupertuis": {"name": "Maupertuis (1738)", "a": 6397300.0, "b": 6363806.283}, 102 | "plessis": {"name": "Plessis (1817)", "a": 6376523.0, "b": 6355862.9333}, 103 | "everest1830": {"name": "Everest (1830)", "a": 6377299.365, "b": 6356098.359}, 104 | "everest1830m": { 105 | "name": "Everest 1830 Modified (1967)", 106 | "a": 6377304.063, 107 | "b": 6356103.039, 108 | }, 109 | "everest1967": { 110 | "name": "Everest 1830 (1967 Definition)", 111 | "a": 6377298.556, 112 | "b": 6356097.55, 113 | }, 114 | "airy": {"name": "Airy (1830)", "a": 6377563.396, "b": 6356256.909}, 115 | "bessel": {"name": "Bessel (1841)", "a": 6377397.155, "b": 6356078.963}, 116 | "clarke1866": {"name": "Clarke (1866)", "a": 6378206.4, "b": 6356583.8}, 117 | "clarke1878": {"name": "Clarke (1878)", "a": 6378190.0, "b": 6356456.0}, 118 | "clarke1860": {"name": "Clarke (1880)", "a": 6378249.145, "b": 6356514.87}, 119 | "helmert": {"name": "Helmert (1906)", "a": 6378200.0, "b": 6356818.17}, 120 | "hayford": {"name": "Hayford (1910)", "a": 6378388.0, "b": 6356911.946}, 121 | "international1924": {"name": "International (1924)", "a": 6378388.0, "b": 6356911.946}, 122 | "krassovsky1940": {"name": "Krassovsky (1940)", "a": 6378245.0, "b": 6356863.019}, 123 | "wgs66": {"name": "WGS66 (1966)", "a": 6378145.0, "b": 6356759.769}, 124 | "australian": {"name": "Australian National (1966)", "a": 6378160.0, "b": 6356774.719}, 125 | "international1967": { 126 | "name": "New International (1967)", 127 | "a": 6378157.5, 128 | "b": 6356772.2, 129 | }, 130 | "grs67": {"name": "GRS-67 (1967)", "a": 6378160.0, "b": 6356774.516}, 131 | "sa1969": {"name": "South American (1969)", "a": 6378160.0, "b": 6356774.719}, 132 | "wgs72": {"name": "WGS-72 (1972)", "a": 6378135.0, "b": 6356750.52001609}, 133 | "grs80": {"name": "GRS-80 (1979)", "a": 6378137.0, "b": 6356752.31414036}, 134 | "wgs84": {"name": "WGS-84 (1984)", "a": 6378137.0, "b": 6356752.31424518}, 135 | "wgs84_mean": {"name": "WGS-84 (1984) Mean", "a": 6371008.7714, "b": 6371008.7714}, 136 | "iers1989": {"name": "IERS (1989)", "a": 6378136.0, "b": 6356751.302}, 137 | "pz90.11": {"name": "ПЗ-90 (2011)", "a": 6378136.0, "b": 6356751.3618}, 138 | "iers2003": {"name": "IERS (2003)", "a": 6378136.6, "b": 6356751.9}, 139 | "gsk2011": {"name": "ГСК (2011)", "a": 6378136.5, "b": 6356751.758}, 140 | # Other worlds 141 | "mercury": {"name": "Mercury", "a": 2440500.0, "b": 2438300.0}, 142 | "venus": {"name": "Venus", "a": 6051800.0, "b": 6051800.0}, 143 | "moon": {"name": "Moon", "a": 1738100.0, "b": 1736000.0}, 144 | "mars": {"name": "Mars", "a": 3396900.0, "b": 3376097.80585952}, 145 | "jupyter": {"name": "Jupiter", "a": 71492000.0, "b": 66770054.3475922}, 146 | "io": {"name": "Io", "a": 1829.7, "b": 1815.8}, 147 | "saturn": {"name": "Saturn", "a": 60268000.0, "b": 54364301.5271271}, 148 | "uranus": {"name": "Uranus", "a": 25559000.0, "b": 24973000.0}, 149 | "neptune": {"name": "Neptune", "a": 24764000.0, "b": 24341000.0}, 150 | "pluto": {"name": "Pluto", "a": 1188000.0, "b": 1188000.0}, 151 | } 152 | 153 | @classmethod 154 | def from_name(cls, name: str) -> Ellipsoid: 155 | """Create an Ellipsoid from a name.""" 156 | 157 | return cls( 158 | cls.models[name]["a"], cls.models[name]["b"], name=cls.models[name]["name"], model=name 159 | ) 160 | -------------------------------------------------------------------------------- /src/pymap3d/enu.py: -------------------------------------------------------------------------------- 1 | """ transforms involving ENU East North Up """ 2 | 3 | from __future__ import annotations 4 | 5 | from math import tau 6 | 7 | try: 8 | from numpy import asarray 9 | except ImportError: 10 | pass 11 | 12 | from .ecef import ecef2geodetic, enu2ecef, geodetic2ecef, uvw2enu 13 | from .ellipsoid import Ellipsoid 14 | from .mathfun import atan2, cos, degrees, hypot, radians, sin 15 | 16 | __all__ = ["enu2aer", "aer2enu", "enu2geodetic", "geodetic2enu", "enu2ecefv"] 17 | 18 | ELL = Ellipsoid.from_name("wgs84") 19 | 20 | 21 | def enu2aer(e, n, u, deg: bool = True) -> tuple: 22 | """ 23 | ENU to Azimuth, Elevation, Range 24 | 25 | Parameters 26 | ---------- 27 | 28 | e : float 29 | ENU East coordinate (meters) 30 | n : float 31 | ENU North coordinate (meters) 32 | u : float 33 | ENU Up coordinate (meters) 34 | deg : bool, optional 35 | degrees input/output (False: radians in/out) 36 | 37 | Results 38 | ------- 39 | 40 | azimuth : float 41 | azimuth to rarget 42 | elevation : float 43 | elevation to target 44 | srange : float 45 | slant range [meters] 46 | """ 47 | 48 | # 1 millimeter precision for singularity stability 49 | 50 | try: 51 | e[abs(e) < 1e-3] = 0.0 52 | n[abs(n) < 1e-3] = 0.0 53 | u[abs(u) < 1e-3] = 0.0 54 | except TypeError: 55 | if abs(e) < 1e-3: 56 | e = 0.0 57 | if abs(n) < 1e-3: 58 | n = 0.0 59 | if abs(u) < 1e-3: 60 | u = 0.0 61 | 62 | r = hypot(e, n) 63 | slantRange = hypot(r, u) 64 | elev = atan2(u, r) 65 | az = atan2(e, n) % tau 66 | 67 | if deg: 68 | az = degrees(az) 69 | elev = degrees(elev) 70 | 71 | return az, elev, slantRange 72 | 73 | 74 | def aer2enu(az, el, srange, deg: bool = True) -> tuple: 75 | """ 76 | Azimuth, Elevation, Slant range to target to East, North, Up 77 | 78 | Parameters 79 | ---------- 80 | az : float 81 | azimuth clockwise from north (degrees) 82 | el : float 83 | elevation angle above horizon, neglecting aberrations (degrees) 84 | srange : float 85 | slant range [meters] 86 | deg : bool, optional 87 | degrees input/output (False: radians in/out) 88 | 89 | Returns 90 | -------- 91 | e : float 92 | East ENU coordinate (meters) 93 | n : float 94 | North ENU coordinate (meters) 95 | u : float 96 | Up ENU coordinate (meters) 97 | """ 98 | if deg: 99 | el = radians(el) 100 | az = radians(az) 101 | 102 | try: 103 | if (asarray(srange) < 0).any(): 104 | raise ValueError("Slant range [0, Infinity)") 105 | except NameError: 106 | if srange < 0: 107 | raise ValueError("Slant range [0, Infinity)") 108 | 109 | r = srange * cos(el) 110 | 111 | return r * sin(az), r * cos(az), srange * sin(el) 112 | 113 | 114 | def enu2geodetic( 115 | e, 116 | n, 117 | u, 118 | lat0, 119 | lon0, 120 | h0, 121 | ell: Ellipsoid = ELL, 122 | deg: bool = True, 123 | ) -> tuple: 124 | """ 125 | East, North, Up to target to geodetic coordinates 126 | 127 | Parameters 128 | ---------- 129 | e : float 130 | East ENU coordinate (meters) 131 | n : float 132 | North ENU coordinate (meters) 133 | u : float 134 | Up ENU coordinate (meters) 135 | lat0 : float 136 | Observer geodetic latitude 137 | lon0 : float 138 | Observer geodetic longitude 139 | h0 : float 140 | observer altitude above geodetic ellipsoid (meters) 141 | ell : Ellipsoid, optional 142 | reference ellipsoid 143 | deg : bool, optional 144 | degrees input/output (False: radians in/out) 145 | 146 | 147 | Results 148 | ------- 149 | lat : float 150 | geodetic latitude 151 | lon : float 152 | geodetic longitude 153 | alt : float 154 | altitude above ellipsoid (meters) 155 | """ 156 | 157 | x, y, z = enu2ecef(e, n, u, lat0, lon0, h0, ell, deg=deg) 158 | 159 | return ecef2geodetic(x, y, z, ell, deg=deg) 160 | 161 | 162 | def geodetic2enu( 163 | lat, 164 | lon, 165 | h, 166 | lat0, 167 | lon0, 168 | h0, 169 | ell: Ellipsoid = ELL, 170 | deg: bool = True, 171 | ) -> tuple: 172 | """ 173 | Parameters 174 | ---------- 175 | lat : float 176 | target geodetic latitude 177 | lon : float 178 | target geodetic longitude 179 | h : float 180 | target altitude above ellipsoid (meters) 181 | lat0 : float 182 | Observer geodetic latitude 183 | lon0 : float 184 | Observer geodetic longitude 185 | h0 : float 186 | observer altitude above geodetic ellipsoid (meters) 187 | ell : Ellipsoid, optional 188 | reference ellipsoid 189 | deg : bool, optional 190 | degrees input/output (False: radians in/out) 191 | 192 | 193 | Results 194 | ------- 195 | e : float 196 | East ENU 197 | n : float 198 | North ENU 199 | u : float 200 | Up ENU 201 | """ 202 | x1, y1, z1 = geodetic2ecef(lat, lon, h, ell, deg=deg) 203 | x2, y2, z2 = geodetic2ecef(lat0, lon0, h0, ell, deg=deg) 204 | 205 | return uvw2enu(x1 - x2, y1 - y2, z1 - z2, lat0, lon0, deg=deg) 206 | 207 | 208 | def enu2ecefv(e, n, u, lat0, lon0, deg: bool = True) -> tuple: 209 | """ 210 | VECTOR from observer to target ENU => ECEF 211 | 212 | Parameters 213 | ---------- 214 | e 215 | target e ENU coordinate 216 | n 217 | target n ENU coordinate 218 | u 219 | target u ENU coordinate 220 | lat0 221 | Observer geodetic latitude 222 | lon0 223 | Observer geodetic longitude 224 | deg : bool, optional 225 | degrees input/output (False: radians in/out) 226 | 227 | Returns 228 | ------- 229 | x 230 | target x ECEF coordinate 231 | y 232 | target y ECEF coordinate 233 | z 234 | target z ECEF coordinate 235 | 236 | """ 237 | if deg: 238 | lat0 = radians(lat0) 239 | lon0 = radians(lon0) 240 | 241 | x = -sin(lon0) * e - sin(lat0) * cos(lon0) * n + cos(lat0) * cos(lon0) * u 242 | y = cos(lon0) * e - sin(lat0) * sin(lon0) * n + cos(lat0) * sin(lon0) * u 243 | z = cos(lat0) * n + sin(lat0) * u 244 | 245 | return x, y, z 246 | -------------------------------------------------------------------------------- /src/pymap3d/haversine.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compute angular separation in the sky using haversine 3 | 4 | Note: 5 | decimal points on constants made 0 difference in `%timeit` execution time 6 | 7 | The Meeus algorithm is about 9.5% faster than Astropy/Vicenty on my PC, 8 | and gives virtually identical result 9 | within double precision arithmetic limitations 10 | """ 11 | 12 | try: 13 | from astropy.coordinates.angle_utilities import angular_separation 14 | except ImportError: 15 | pass 16 | 17 | from .mathfun import asin, cos, degrees, radians, sqrt 18 | 19 | __all__ = ["anglesep", "anglesep_meeus", "haversine"] 20 | 21 | 22 | def anglesep_meeus(lon0: float, lat0: float, lon1: float, lat1: float, deg: bool = True) -> float: 23 | """ 24 | Parameters 25 | ---------- 26 | 27 | lon0 : float 28 | longitude of first point 29 | lat0 : float 30 | latitude of first point 31 | lon1 : float 32 | longitude of second point 33 | lat1 : float 34 | latitude of second point 35 | deg : bool, optional 36 | degrees input/output (False: radians in/out) 37 | 38 | Returns 39 | ------- 40 | 41 | sep_rad : float 42 | angular separation 43 | 44 | 45 | Meeus p. 109 46 | 47 | from "Astronomical Algorithms" by Jean Meeus Ch. 16 p. 111 (16.5) 48 | gives angular distance in degrees between two rightAscension,Declination 49 | points in the sky. Neglecting atmospheric effects, of course. 50 | 51 | Meeus haversine method is stable all the way to exactly 0 deg. 52 | 53 | either the arrays must be the same size, or one of them must be a scalar 54 | """ 55 | 56 | if deg: 57 | lon0 = radians(lon0) 58 | lat0 = radians(lat0) 59 | lon1 = radians(lon1) 60 | lat1 = radians(lat1) 61 | 62 | sep_rad = 2 * asin( 63 | sqrt(haversine(lat0 - lat1) + cos(lat0) * cos(lat1) * haversine(lon0 - lon1)) 64 | ) 65 | 66 | return degrees(sep_rad) if deg else sep_rad 67 | 68 | 69 | def anglesep(lon0: float, lat0: float, lon1: float, lat1: float, deg: bool = True) -> float: 70 | """ 71 | Parameters 72 | ---------- 73 | 74 | lon0 : float 75 | longitude of first point 76 | lat0 : float 77 | latitude of first point 78 | lon1 : float 79 | longitude of second point 80 | lat1 : float 81 | latitude of second point 82 | deg : bool, optional 83 | degrees input/output (False: radians in/out) 84 | 85 | Returns 86 | ------- 87 | 88 | sep_rad : float 89 | angular separation 90 | 91 | For reference, this is from astropy astropy/coordinates/angle_utilities.py 92 | Angular separation between two points on a sphere. 93 | """ 94 | 95 | if deg: 96 | lon0 = radians(lon0) 97 | lat0 = radians(lat0) 98 | lon1 = radians(lon1) 99 | lat1 = radians(lat1) 100 | 101 | try: 102 | sep_rad = angular_separation(lon0, lat0, lon1, lat1) 103 | except NameError: 104 | sep_rad = anglesep_meeus(lon0, lat0, lon1, lat1, deg=False) 105 | 106 | return degrees(sep_rad) if deg else sep_rad 107 | 108 | 109 | def haversine(theta: float) -> float: 110 | """ 111 | Compute haversine 112 | 113 | Parameters 114 | ---------- 115 | 116 | theta : float 117 | angle (radians) 118 | 119 | Results 120 | ------- 121 | 122 | htheta : float 123 | haversine of `theta` 124 | 125 | https://en.wikipedia.org/wiki/Haversine 126 | Meeus p. 111 127 | """ 128 | return (1 - cos(theta)) / 2.0 129 | -------------------------------------------------------------------------------- /src/pymap3d/los.py: -------------------------------------------------------------------------------- 1 | """ Line of sight intersection of space observer to ellipsoid """ 2 | 3 | from __future__ import annotations 4 | 5 | from math import nan, pi 6 | 7 | try: 8 | from numpy import asarray 9 | except ImportError: 10 | pass 11 | 12 | from .aer import aer2enu 13 | from .ecef import ecef2geodetic, enu2uvw, geodetic2ecef 14 | from .ellipsoid import Ellipsoid 15 | from .mathfun import sqrt 16 | 17 | __all__ = ["lookAtSpheroid"] 18 | 19 | ELL = Ellipsoid.from_name("wgs84") 20 | 21 | 22 | def lookAtSpheroid( 23 | lat0, 24 | lon0, 25 | h0, 26 | az, 27 | tilt, 28 | ell: Ellipsoid = ELL, 29 | deg: bool = True, 30 | ) -> tuple: 31 | """ 32 | Calculates line-of-sight intersection with Earth (or other ellipsoid) surface from above surface / orbit 33 | 34 | Parameters 35 | ---------- 36 | 37 | lat0 : float 38 | observer geodetic latitude 39 | lon0 : float 40 | observer geodetic longitude 41 | h0 : float 42 | observer altitude (meters) Must be non-negative since this function doesn't consider terrain 43 | az : float 44 | azimuth angle of line-of-sight, clockwise from North 45 | tilt : float 46 | tilt angle of line-of-sight with respect to local vertical (nadir = 0) 47 | ell : Ellipsoid, optional 48 | reference ellipsoid 49 | deg : bool, optional 50 | degrees input/output (False: radians in/out) 51 | 52 | Results 53 | ------- 54 | 55 | lat : float 56 | geodetic latitude where the line-of-sight intersects with the Earth ellipsoid 57 | lon : float 58 | geodetic longitude where the line-of-sight intersects with the Earth ellipsoid 59 | d : float 60 | slant range (meters) from starting point to intersect point 61 | 62 | Values will be NaN if the line of sight does not intersect. 63 | 64 | Algorithm based on https://medium.com/@stephenhartzell/satellite-line-of-sight-intersection-with-earth-d786b4a6a9b6 Stephen Hartzell 65 | """ 66 | 67 | if ell is None: 68 | ell = ELL 69 | 70 | try: 71 | lat0 = asarray(lat0) 72 | lon0 = asarray(lon0) 73 | h0 = asarray(h0) 74 | az = asarray(az) 75 | tilt = asarray(tilt) 76 | if (h0 < 0).any(): 77 | raise ValueError("Intersection calculation requires altitude [0, Infinity)") 78 | except NameError: 79 | if h0 < 0: 80 | raise ValueError("Intersection calculation requires altitude [0, Infinity)") 81 | 82 | a = ell.semimajor_axis 83 | b = ell.semimajor_axis 84 | c = ell.semiminor_axis 85 | 86 | el = tilt - 90.0 if deg else tilt - pi / 2 87 | 88 | e, n, u = aer2enu(az, el, srange=1.0, deg=deg) 89 | # fixed 1 km slant range 90 | 91 | u, v, w = enu2uvw(e, n, u, lat0, lon0, deg=deg) 92 | x, y, z = geodetic2ecef(lat0, lon0, h0, deg=deg) 93 | 94 | value = -(a**2) * b**2 * w * z - a**2 * c**2 * v * y - b**2 * c**2 * u * x 95 | radical = ( 96 | a**2 * b**2 * w**2 97 | + a**2 * c**2 * v**2 98 | - a**2 * v**2 * z**2 99 | + 2 * a**2 * v * w * y * z 100 | - a**2 * w**2 * y**2 101 | + b**2 * c**2 * u**2 102 | - b**2 * u**2 * z**2 103 | + 2 * b**2 * u * w * x * z 104 | - b**2 * w**2 * x**2 105 | - c**2 * u**2 * y**2 106 | + 2 * c**2 * u * v * x * y 107 | - c**2 * v**2 * x**2 108 | ) 109 | 110 | magnitude = a**2 * b**2 * w**2 + a**2 * c**2 * v**2 + b**2 * c**2 * u**2 111 | 112 | # %% Return nan if radical < 0 or d < 0 because LOS vector does not point towards Earth 113 | try: 114 | radical[radical < 0] = nan 115 | except TypeError: 116 | if radical < 0: 117 | radical = nan 118 | 119 | d = (value - a * b * c * sqrt(radical)) / magnitude 120 | 121 | try: 122 | d[d < 0] = nan 123 | except TypeError: 124 | if d < 0: 125 | d = nan 126 | 127 | # %% cartesian to ellipsodal 128 | lat, lon, _ = ecef2geodetic(x + d * u, y + d * v, z + d * w, deg=deg) 129 | 130 | try: 131 | return lat.squeeze()[()], lon.squeeze()[()], d.squeeze()[()] 132 | except AttributeError: 133 | return lat, lon, d 134 | -------------------------------------------------------------------------------- /src/pymap3d/lox.py: -------------------------------------------------------------------------------- 1 | """ isometric latitude, meridian distance """ 2 | 3 | from __future__ import annotations 4 | 5 | try: 6 | from numpy import array, broadcast_arrays 7 | except ImportError: 8 | pass 9 | 10 | from math import pi, tau 11 | 12 | from . import rcurve, rsphere 13 | from .ellipsoid import Ellipsoid 14 | from .latitude import ( 15 | authalic2geodetic, 16 | geodetic2authalic, 17 | geodetic2isometric, 18 | geodetic2rectifying, 19 | rectifying2geodetic, 20 | ) 21 | from .mathfun import atan2, cos, degrees, radians, sign, tan 22 | from .utils import cart2sph, sph2cart 23 | 24 | __all__ = [ 25 | "loxodrome_inverse", 26 | "loxodrome_direct", 27 | "meridian_arc", 28 | "meridian_dist", 29 | "departure", 30 | "meanm", 31 | ] 32 | 33 | ELL = Ellipsoid.from_name("wgs84") 34 | COS_EPS = 1e-9 35 | 36 | 37 | def meridian_dist(lat, ell: Ellipsoid = ELL, deg: bool = True) -> float: 38 | """ 39 | Computes the ground distance on an ellipsoid from the equator to the input latitude. 40 | 41 | Parameters 42 | ---------- 43 | lat : float 44 | geodetic latitude 45 | ell : Ellipsoid, optional 46 | reference ellipsoid (default WGS84) 47 | deg : bool, optional 48 | degrees input/output (False: radians in/out) 49 | 50 | Results 51 | ------- 52 | dist : float 53 | distance (meters) 54 | """ 55 | return meridian_arc(0.0, lat, ell, deg) 56 | 57 | 58 | def meridian_arc(lat1, lat2, ell: Ellipsoid = ELL, deg: bool = True) -> float: 59 | """ 60 | Computes the ground distance on an ellipsoid between two latitudes. 61 | 62 | Parameters 63 | ---------- 64 | lat1, lat2 : float 65 | geodetic latitudes 66 | ell : Ellipsoid, optional 67 | reference ellipsoid (default WGS84) 68 | deg : bool, optional 69 | degrees input/output (False: radians in/out) 70 | 71 | Results 72 | ------- 73 | dist : float 74 | distance (meters) 75 | """ 76 | 77 | if deg: 78 | lat1, lat2 = radians(lat1), radians(lat2) 79 | 80 | rlat1 = geodetic2rectifying(lat1, ell, deg=False) 81 | rlat2 = geodetic2rectifying(lat2, ell, deg=False) 82 | 83 | return rsphere.rectifying(ell) * abs(rlat2 - rlat1) 84 | 85 | 86 | def loxodrome_inverse( 87 | lat1, 88 | lon1, 89 | lat2, 90 | lon2, 91 | ell: Ellipsoid = ELL, 92 | deg: bool = True, 93 | ) -> tuple[float, float]: 94 | """ 95 | computes the arc length and azimuth of the loxodrome 96 | between two points on the surface of the reference ellipsoid 97 | 98 | like Matlab distance('rh',...) and azimuth('rh',...) 99 | 100 | Parameters 101 | ---------- 102 | 103 | lat1 : float 104 | geodetic latitude of first point 105 | lon1 : float 106 | geodetic longitude of first point 107 | lat2 : float 108 | geodetic latitude of second point 109 | lon2 : float 110 | geodetic longitude of second point 111 | ell : Ellipsoid, optional 112 | reference ellipsoid (default WGS84) 113 | deg : bool, optional 114 | degrees input/output (False: radians in/out) 115 | 116 | Results 117 | ------- 118 | 119 | lox_s : float 120 | distance along loxodrome (meters) 121 | az12 : float 122 | azimuth of loxodrome (degrees/radians) 123 | 124 | Based on Deakin, R.E., 2010, 'The Loxodrome on an Ellipsoid', Lecture Notes, 125 | School of Mathematical and Geospatial Sciences, RMIT University, January 2010 126 | 127 | [1] Bowring, B.R., 1985, 'The geometry of the loxodrome on the 128 | ellipsoid', The Canadian Surveyor, Vol. 39, No. 3, Autumn 1985, 129 | pp.223-230. 130 | [2] Snyder, J.P., 1987, Map Projections-A Working Manual. U.S. 131 | Geological Survey Professional Paper 1395. Washington, DC: U.S. 132 | Government Printing Office, pp.15-16 and pp. 44-45. 133 | [3] Thomas, P.D., 1952, Conformal Projections in Geodesy and 134 | Cartography, Special Publication No. 251, Coast and Geodetic 135 | Survey, U.S. Department of Commerce, Washington, DC: U.S. 136 | Government Printing Office, p. 66. 137 | """ 138 | 139 | if deg: 140 | lat1, lon1, lat2, lon2 = radians(lat1), radians(lon1), radians(lat2), radians(lon2) 141 | 142 | try: 143 | lat1, lon1, lat2, lon2 = broadcast_arrays(lat1, lon1, lat2, lon2) 144 | 145 | except NameError: 146 | pass 147 | 148 | # compute changes in isometric latitude and longitude between points 149 | disolat = geodetic2isometric(lat2, deg=False, ell=ell) - geodetic2isometric( 150 | lat1, deg=False, ell=ell 151 | ) 152 | dlon = lon2 - lon1 153 | 154 | # compute azimuth 155 | az12 = atan2(dlon, disolat) 156 | aux = abs(cos(az12)) 157 | 158 | # compute distance along loxodromic curve 159 | dist = meridian_arc(lat2, lat1, deg=False, ell=ell) / aux 160 | 161 | # straight east or west 162 | i = aux < COS_EPS 163 | try: 164 | dist[i] = departure(lon2[i], lon1[i], lat1[i], ell, deg=False) 165 | except (AttributeError, TypeError): 166 | if i: 167 | dist = departure(lon2, lon1, lat1, ell, deg=False) 168 | 169 | if deg: 170 | az12 = degrees(az12) % 360.0 171 | 172 | try: 173 | return dist.squeeze()[()], az12.squeeze()[()] 174 | except AttributeError: 175 | return dist, az12 176 | 177 | 178 | def loxodrome_direct( 179 | lat1, 180 | lon1, 181 | rng, 182 | a12, 183 | ell: Ellipsoid = ELL, 184 | deg: bool = True, 185 | ) -> tuple: 186 | """ 187 | Given starting lat, lon with arclength and azimuth, compute final lat, lon 188 | 189 | like Matlab reckon('rh', ...) 190 | except that "rng" in meters instead of "arclen" degrees of arc 191 | 192 | Parameters 193 | ---------- 194 | lat1 : float 195 | inital geodetic latitude (degrees) 196 | lon1 : float 197 | initial geodetic longitude (degrees) 198 | rng : float 199 | ground distance (meters) 200 | a12 : float 201 | azimuth (degrees) clockwide from north. 202 | ell : Ellipsoid, optional 203 | reference ellipsoid 204 | deg : bool, optional 205 | degrees input/output (False: radians in/out) 206 | 207 | Results 208 | ------- 209 | lat2 : float 210 | final geodetic latitude (degrees) 211 | lon2 : float 212 | final geodetic longitude (degrees) 213 | """ 214 | 215 | if deg: 216 | lat1, lon1, a12 = radians(lat1), radians(lon1), radians(a12) 217 | 218 | a12 = a12 % tau 219 | 220 | try: 221 | lat1, rng, a12 = broadcast_arrays(lat1, rng, a12) 222 | if (abs(lat1) > pi / 2).any(): 223 | raise ValueError("-90 <= latitude <= 90") 224 | if (rng < 0).any(): 225 | raise ValueError("ground distance must be >= 0") 226 | except NameError: 227 | if abs(lat1) > pi / 2: 228 | raise ValueError("-90 <= latitude <= 90") 229 | if rng < 0: 230 | raise ValueError("ground distance must be >= 0") 231 | 232 | # compute rectifying sphere latitude and radius 233 | reclat = geodetic2rectifying(lat1, ell, deg=False) 234 | 235 | # compute the new points 236 | cosaz = cos(a12) 237 | lat2 = reclat + (rng / rsphere.rectifying(ell)) * cosaz # compute rectifying latitude 238 | lat2 = rectifying2geodetic(lat2, ell, deg=False) # transform to geodetic latitude 239 | 240 | newiso = geodetic2isometric(lat2, ell, deg=False) 241 | iso = geodetic2isometric(lat1, ell, deg=False) 242 | 243 | # stability near singularities 244 | i = abs(cos(a12)) < COS_EPS 245 | dlon = tan(a12) * (newiso - iso) 246 | 247 | try: 248 | dlon[i] = sign(pi - a12[i]) * rng[i] / rcurve.parallel(lat1[i], ell=ell, deg=False) 249 | except (AttributeError, TypeError): 250 | if i: # straight east or west 251 | dlon = sign(pi - a12) * rng / rcurve.parallel(lat1, ell=ell, deg=False) 252 | 253 | lon2 = lon1 + dlon 254 | 255 | if deg: 256 | lat2, lon2 = degrees(lat2), degrees(lon2) 257 | 258 | try: 259 | return lat2.squeeze()[()], lon2.squeeze()[()] 260 | except AttributeError: 261 | return lat2, lon2 262 | 263 | 264 | def departure(lon1, lon2, lat, ell: Ellipsoid = ELL, deg: bool = True) -> float: 265 | """ 266 | Computes the distance along a specific parallel between two meridians. 267 | 268 | like Matlab departure() 269 | 270 | Parameters 271 | ---------- 272 | lon1, lon2 : float 273 | geodetic longitudes (degrees) 274 | lat : float 275 | geodetic latitude (degrees) 276 | ell : Ellipsoid, optional 277 | reference ellipsoid 278 | deg : bool, optional 279 | degrees input/output (False: radians in/out) 280 | 281 | Returns 282 | ------- 283 | dist: float 284 | ground distance (meters) 285 | """ 286 | if deg: 287 | lon1, lon2, lat = radians(lon1), radians(lon2), radians(lat) 288 | 289 | return rcurve.parallel(lat, ell=ell, deg=False) * (abs(lon2 - lon1) % pi) 290 | 291 | 292 | def meanm(lat, lon, ell: Ellipsoid = ELL, deg: bool = True) -> tuple: 293 | """ 294 | Computes geographic mean for geographic points on an ellipsoid 295 | 296 | like Matlab meanm() 297 | 298 | Parameters 299 | ---------- 300 | lat : sequence of float 301 | geodetic latitude (degrees) 302 | lon : sequence of float 303 | geodetic longitude (degrees) 304 | ell : Ellipsoid, optional 305 | reference ellipsoid 306 | deg : bool, optional 307 | degrees input/output (False: radians in/out) 308 | 309 | Returns 310 | ------- 311 | latbar, lonbar: float 312 | geographic mean latitude, longitude 313 | """ 314 | 315 | if deg: 316 | lat, lon = radians(lat), radians(lon) 317 | 318 | lat = geodetic2authalic(lat, ell, deg=False) 319 | 320 | x, y, z = sph2cart(lon, lat, array(1.0)) 321 | lonbar, latbar, _ = cart2sph(x.sum(), y.sum(), z.sum()) 322 | latbar = authalic2geodetic(latbar, ell, deg=False) 323 | 324 | if deg: 325 | latbar, lonbar = degrees(latbar), degrees(lonbar) 326 | return latbar, lonbar 327 | -------------------------------------------------------------------------------- /src/pymap3d/mathfun.py: -------------------------------------------------------------------------------- 1 | """ 2 | import from Numpy, and if not available fallback to math stdlib 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | try: 8 | from numpy import arcsin as asin 9 | from numpy import arcsinh as asinh 10 | from numpy import arctan as atan 11 | from numpy import arctan2 as atan2 12 | from numpy import arctanh as atanh 13 | from numpy import ( 14 | cbrt, 15 | cos, 16 | degrees, 17 | exp, 18 | hypot, 19 | inf, 20 | isclose, 21 | isnan, 22 | linspace, 23 | log, 24 | power, 25 | radians, 26 | sign, 27 | sin, 28 | sqrt, 29 | tan, 30 | ) 31 | except ImportError: 32 | from math import ( # type: ignore 33 | asin, 34 | asinh, 35 | atan, 36 | atan2, 37 | atanh, 38 | cos, 39 | degrees, 40 | exp, 41 | hypot, 42 | inf, 43 | isclose, 44 | isnan, 45 | log, 46 | radians, 47 | sin, 48 | sqrt, 49 | tan, 50 | ) 51 | 52 | def linspace(start: float, stop: float, num: int) -> list[float]: # type: ignore 53 | """ 54 | create a list of "num" evenly spaced numbers using range and increment, 55 | including endpoint "stop" 56 | """ 57 | step = (stop - start) / (num - 1) 58 | return [start + i * step for i in range(num)] 59 | 60 | def power(x, y): # type: ignore 61 | return pow(x, y) 62 | 63 | def sign(x) -> float: # type: ignore 64 | """signum""" 65 | if x < 0: 66 | y = -1.0 67 | elif x > 0: 68 | y = 1.0 69 | else: 70 | y = 0.0 71 | 72 | return y 73 | 74 | try: 75 | import math.cbrt as cbrt # type: ignore 76 | except ImportError: 77 | 78 | def cbrt(x) -> float: # type: ignore 79 | return x ** (1 / 3) 80 | 81 | 82 | __all__ = [ 83 | "asin", 84 | "asinh", 85 | "atan", 86 | "atan2", 87 | "atanh", 88 | "cbrt", 89 | "cos", 90 | "degrees", 91 | "exp", 92 | "hypot", 93 | "inf", 94 | "isclose", 95 | "isnan", 96 | "log", 97 | "power", 98 | "radians", 99 | "sign", 100 | "sin", 101 | "sqrt", 102 | "tan", 103 | ] 104 | -------------------------------------------------------------------------------- /src/pymap3d/ned.py: -------------------------------------------------------------------------------- 1 | """ Transforms involving NED North East Down """ 2 | 3 | from __future__ import annotations 4 | 5 | from .ecef import ecef2enu, ecef2enuv, ecef2geodetic, enu2ecef 6 | from .ellipsoid import Ellipsoid 7 | from .enu import aer2enu, enu2aer, geodetic2enu 8 | 9 | __all__ = [ 10 | "aer2ned", 11 | "ned2aer", 12 | "ned2geodetic", 13 | "ned2ecef", 14 | "ecef2ned", 15 | "geodetic2ned", 16 | "ecef2nedv", 17 | ] 18 | 19 | ELL = Ellipsoid.from_name("wgs84") 20 | 21 | 22 | def aer2ned(az, elev, slantRange, deg: bool = True) -> tuple: 23 | """ 24 | converts azimuth, elevation, range to target from observer to North, East, Down 25 | 26 | Parameters 27 | ----------- 28 | 29 | az : float 30 | azimuth 31 | elev : float 32 | elevation 33 | slantRange : float 34 | slant range [meters] 35 | deg : bool, optional 36 | degrees input/output (False: radians in/out) 37 | 38 | Results 39 | ------- 40 | n : float 41 | North NED coordinate (meters) 42 | e : float 43 | East NED coordinate (meters) 44 | d : float 45 | Down NED coordinate (meters) 46 | """ 47 | e, n, u = aer2enu(az, elev, slantRange, deg=deg) 48 | 49 | return n, e, -u 50 | 51 | 52 | def ned2aer(n, e, d, deg: bool = True) -> tuple: 53 | """ 54 | converts North, East, Down to azimuth, elevation, range 55 | 56 | Parameters 57 | ---------- 58 | 59 | n : float 60 | North NED coordinate (meters) 61 | e : float 62 | East NED coordinate (meters) 63 | d : float 64 | Down NED coordinate (meters) 65 | deg : bool, optional 66 | degrees input/output (False: radians in/out) 67 | 68 | Results 69 | ------- 70 | 71 | az : float 72 | azimuth 73 | elev : float 74 | elevation 75 | slantRange : float 76 | slant range [meters] 77 | """ 78 | return enu2aer(e, n, -d, deg=deg) 79 | 80 | 81 | def ned2geodetic( 82 | n, 83 | e, 84 | d, 85 | lat0, 86 | lon0, 87 | h0, 88 | ell: Ellipsoid = ELL, 89 | deg: bool = True, 90 | ) -> tuple: 91 | """ 92 | Converts North, East, Down to target latitude, longitude, altitude 93 | 94 | Parameters 95 | ---------- 96 | 97 | n : float 98 | North NED coordinate (meters) 99 | e : float 100 | East NED coordinate (meters) 101 | d : float 102 | Down NED coordinate (meters) 103 | lat0 : float 104 | Observer geodetic latitude 105 | lon0 : float 106 | Observer geodetic longitude 107 | h0 : float 108 | observer altitude above geodetic ellipsoid (meters) 109 | ell : Ellipsoid, optional 110 | reference ellipsoid 111 | deg : bool, optional 112 | degrees input/output (False: radians in/out) 113 | 114 | Results 115 | ------- 116 | 117 | lat : float 118 | target geodetic latitude 119 | lon : float 120 | target geodetic longitude 121 | h : float 122 | target altitude above geodetic ellipsoid (meters) 123 | 124 | """ 125 | x, y, z = enu2ecef(e, n, -d, lat0, lon0, h0, ell, deg=deg) 126 | 127 | return ecef2geodetic(x, y, z, ell, deg=deg) 128 | 129 | 130 | def ned2ecef( 131 | n, 132 | e, 133 | d, 134 | lat0, 135 | lon0, 136 | h0, 137 | ell: Ellipsoid = ELL, 138 | deg: bool = True, 139 | ) -> tuple: 140 | """ 141 | North, East, Down to target ECEF coordinates 142 | 143 | Parameters 144 | ---------- 145 | 146 | n : float 147 | North NED coordinate (meters) 148 | e : float 149 | East NED coordinate (meters) 150 | d : float 151 | Down NED coordinate (meters) 152 | lat0 : float 153 | Observer geodetic latitude 154 | lon0 : float 155 | Observer geodetic longitude 156 | h0 : float 157 | observer altitude above geodetic ellipsoid (meters) 158 | ell : Ellipsoid, optional 159 | reference ellipsoid 160 | deg : bool, optional 161 | degrees input/output (False: radians in/out) 162 | 163 | Results 164 | ------- 165 | 166 | x : float 167 | ECEF x coordinate (meters) 168 | y : float 169 | ECEF y coordinate (meters) 170 | z : float 171 | ECEF z coordinate (meters) 172 | """ 173 | return enu2ecef(e, n, -d, lat0, lon0, h0, ell, deg=deg) 174 | 175 | 176 | def ecef2ned( 177 | x, 178 | y, 179 | z, 180 | lat0, 181 | lon0, 182 | h0, 183 | ell: Ellipsoid = ELL, 184 | deg: bool = True, 185 | ) -> tuple: 186 | """ 187 | Convert ECEF x,y,z to North, East, Down 188 | 189 | Parameters 190 | ---------- 191 | 192 | x : float 193 | ECEF x coordinate (meters) 194 | y : float 195 | ECEF y coordinate (meters) 196 | z : float 197 | ECEF z coordinate (meters) 198 | lat0 : float 199 | Observer geodetic latitude 200 | lon0 : float 201 | Observer geodetic longitude 202 | h0 : float 203 | observer altitude above geodetic ellipsoid (meters) 204 | ell : Ellipsoid, optional 205 | reference ellipsoid 206 | deg : bool, optional 207 | degrees input/output (False: radians in/out) 208 | 209 | Results 210 | ------- 211 | 212 | n : float 213 | North NED coordinate (meters) 214 | e : float 215 | East NED coordinate (meters) 216 | d : float 217 | Down NED coordinate (meters) 218 | 219 | """ 220 | e, n, u = ecef2enu(x, y, z, lat0, lon0, h0, ell, deg=deg) 221 | 222 | return n, e, -u 223 | 224 | 225 | def geodetic2ned( 226 | lat, 227 | lon, 228 | h, 229 | lat0, 230 | lon0, 231 | h0, 232 | ell: Ellipsoid = ELL, 233 | deg: bool = True, 234 | ) -> tuple: 235 | """ 236 | convert latitude, longitude, altitude of target to North, East, Down from observer 237 | 238 | Parameters 239 | ---------- 240 | 241 | lat : float 242 | target geodetic latitude 243 | lon : float 244 | target geodetic longitude 245 | h : float 246 | target altitude above geodetic ellipsoid (meters) 247 | lat0 : float 248 | Observer geodetic latitude 249 | lon0 : float 250 | Observer geodetic longitude 251 | h0 : float 252 | observer altitude above geodetic ellipsoid (meters) 253 | ell : Ellipsoid, optional 254 | reference ellipsoid 255 | deg : bool, optional 256 | degrees input/output (False: radians in/out) 257 | 258 | 259 | Results 260 | ------- 261 | 262 | n : float 263 | North NED coordinate (meters) 264 | e : float 265 | East NED coordinate (meters) 266 | d : float 267 | Down NED coordinate (meters) 268 | """ 269 | e, n, u = geodetic2enu(lat, lon, h, lat0, lon0, h0, ell, deg=deg) 270 | 271 | return n, e, -u 272 | 273 | 274 | def ecef2nedv(x, y, z, lat0, lon0, deg: bool = True) -> tuple[float, float, float]: 275 | """ 276 | for VECTOR between two points 277 | 278 | Parameters 279 | ---------- 280 | x : float 281 | ECEF x coordinate (meters) 282 | y : float 283 | ECEF y coordinate (meters) 284 | z : float 285 | ECEF z coordinate (meters) 286 | lat0 : float 287 | Observer geodetic latitude 288 | lon0 : float 289 | Observer geodetic longitude 290 | deg : bool, optional 291 | degrees input/output (False: radians in/out) 292 | 293 | Results 294 | ------- 295 | 296 | (Vector) 297 | 298 | n : float 299 | North NED coordinate (meters) 300 | e : float 301 | East NED coordinate (meters) 302 | d : float 303 | Down NED coordinate (meters) 304 | """ 305 | e, n, u = ecef2enuv(x, y, z, lat0, lon0, deg=deg) 306 | 307 | return n, e, -u 308 | -------------------------------------------------------------------------------- /src/pymap3d/rcurve.py: -------------------------------------------------------------------------------- 1 | """compute radii of curvature for an ellipsoid""" 2 | 3 | from __future__ import annotations 4 | 5 | from .ellipsoid import Ellipsoid 6 | from .mathfun import cos, sin, sqrt, radians 7 | 8 | __all__ = ["parallel", "meridian", "transverse", "geocentric_radius"] 9 | 10 | ELL = Ellipsoid.from_name("wgs84") 11 | 12 | 13 | def geocentric_radius(geodetic_lat, ell: Ellipsoid = ELL, deg: bool = True): 14 | """ 15 | compute geocentric radius at geodetic latitude 16 | 17 | https://en.wikipedia.org/wiki/Earth_radius#Geocentric_radius 18 | """ 19 | 20 | if deg: 21 | geodetic_lat = radians(geodetic_lat) 22 | 23 | return sqrt( 24 | ( 25 | (ell.semimajor_axis**2 * cos(geodetic_lat)) ** 2 26 | + (ell.semiminor_axis**2 * sin(geodetic_lat)) ** 2 27 | ) 28 | / ( 29 | (ell.semimajor_axis * cos(geodetic_lat)) ** 2 30 | + (ell.semiminor_axis * sin(geodetic_lat)) ** 2 31 | ) 32 | ) 33 | 34 | 35 | def parallel(lat, ell: Ellipsoid = ELL, deg: bool = True) -> float: 36 | """ 37 | computes the radius of the small circle encompassing the globe at the specified latitude 38 | 39 | like Matlab rcurve('parallel', ...) 40 | 41 | Parameters 42 | ---------- 43 | lat : float 44 | geodetic latitude (degrees) 45 | ell : Ellipsoid, optional 46 | reference ellipsoid 47 | deg : bool, optional 48 | degrees input/output (False: radians in/out) 49 | 50 | Returns 51 | ------- 52 | radius: float 53 | radius of ellipsoid (meters) 54 | """ 55 | 56 | if deg: 57 | lat = radians(lat) 58 | 59 | return cos(lat) * transverse(lat, ell, deg=False) 60 | 61 | 62 | def meridian(lat, ell: Ellipsoid = ELL, deg: bool = True): 63 | """computes the meridional radius of curvature for the ellipsoid 64 | 65 | like Matlab rcurve('meridian', ...) 66 | 67 | Parameters 68 | ---------- 69 | lat : float 70 | geodetic latitude (degrees) 71 | ell : Ellipsoid, optional 72 | reference ellipsoid 73 | deg : bool, optional 74 | degrees input/output (False: radians in/out) 75 | 76 | Returns 77 | ------- 78 | radius: float 79 | radius of ellipsoid 80 | """ 81 | 82 | if deg: 83 | lat = radians(lat) 84 | 85 | f1 = ell.semimajor_axis * (1 - ell.eccentricity**2) 86 | f2 = 1 - (ell.eccentricity * sin(lat)) ** 2 87 | return f1 / sqrt(f2**3) 88 | 89 | 90 | def transverse(lat, ell: Ellipsoid = ELL, deg: bool = True): 91 | """computes the radius of the curve formed by a plane 92 | intersecting the ellipsoid at the latitude which is 93 | normal to the surface of the ellipsoid 94 | 95 | like Matlab rcurve('transverse', ...) 96 | 97 | Parameters 98 | ---------- 99 | lat : float 100 | latitude (degrees) 101 | ell : Ellipsoid, optional 102 | reference ellipsoid 103 | deg : bool, optional 104 | degrees input/output (False: radians in/out) 105 | 106 | Returns 107 | ------- 108 | radius: float 109 | radius of ellipsoid (meters) 110 | """ 111 | 112 | if deg: 113 | lat = radians(lat) 114 | 115 | return ell.semimajor_axis / sqrt(1 - (ell.eccentricity * sin(lat)) ** 2) 116 | -------------------------------------------------------------------------------- /src/pymap3d/rsphere.py: -------------------------------------------------------------------------------- 1 | """ compute radii of auxiliary spheres""" 2 | 3 | from __future__ import annotations 4 | 5 | try: 6 | from numpy import asarray 7 | except ImportError: 8 | pass 9 | 10 | from . import rcurve 11 | from .ellipsoid import Ellipsoid 12 | from .mathfun import cos, degrees, log, radians, sin, sqrt 13 | from .vincenty import vdist 14 | 15 | __all__ = [ 16 | "eqavol", 17 | "authalic", 18 | "rectifying", 19 | "euler", 20 | "curve", 21 | "triaxial", 22 | "biaxial", 23 | ] 24 | 25 | ELL = Ellipsoid.from_name("wgs84") 26 | 27 | 28 | def eqavol(ell: Ellipsoid = ELL) -> float: 29 | """computes the radius of the sphere with equal volume as the ellipsoid 30 | 31 | Parameters 32 | ---------- 33 | ell : Ellipsoid, optional 34 | reference ellipsoid 35 | 36 | Returns 37 | ------- 38 | radius: float 39 | radius of sphere 40 | """ 41 | 42 | f = ell.flattening 43 | 44 | return ell.semimajor_axis * (1 - f / 3 - f**2 / 9) 45 | 46 | 47 | def authalic(ell: Ellipsoid = ELL) -> float: 48 | """computes the radius of the sphere with equal surface area as the ellipsoid 49 | 50 | Parameters 51 | ---------- 52 | ell : Ellipsoid, optional 53 | reference ellipsoid 54 | 55 | Returns 56 | ------- 57 | radius: float 58 | radius of sphere 59 | """ 60 | 61 | e = ell.eccentricity 62 | 63 | if e > 0: 64 | f1 = ell.semimajor_axis**2 / 2 65 | f2 = (1 - e**2) / (2 * e) 66 | f3 = log((1 + e) / (1 - e)) 67 | return sqrt(f1 * (1 + f2 * f3)) 68 | else: 69 | return ell.semimajor_axis 70 | 71 | 72 | def rectifying(ell: Ellipsoid = ELL) -> float: 73 | """computes the radius of the sphere with equal meridional distances as the ellipsoid 74 | 75 | Parameters 76 | ---------- 77 | ell : Ellipsoid, optional 78 | reference ellipsoid 79 | 80 | Returns 81 | ------- 82 | radius: float 83 | radius of sphere 84 | """ 85 | 86 | return ((ell.semimajor_axis ** (3 / 2) + ell.semiminor_axis ** (3 / 2)) / 2) ** (2 / 3) 87 | 88 | 89 | def euler( 90 | lat1, 91 | lon1, 92 | lat2, 93 | lon2, 94 | ell: Ellipsoid = ELL, 95 | deg: bool = True, 96 | ): 97 | """computes the Euler radii of curvature at the midpoint of the 98 | great circle arc defined by the endpoints (lat1,lon1) and (lat2,lon2) 99 | 100 | Parameters 101 | ---------- 102 | lat1, lat2 : float 103 | geodetic latitudes (degrees) 104 | lon1, lon2 : float 105 | geodetic longitudes (degrees) 106 | ell : Ellipsoid, optional 107 | reference ellipsoid 108 | deg : bool, optional 109 | degrees input/output (False: radians in/out) 110 | 111 | Returns 112 | ------- 113 | radius: float 114 | radius of sphere 115 | """ 116 | if not deg: 117 | lat1, lon1, lat2, lon2 = degrees(lat1), degrees(lon1), degrees(lat2), degrees(lon2) 118 | 119 | try: 120 | lat1, lat2 = asarray(lat1), asarray(lat2) 121 | except NameError: 122 | pass 123 | 124 | latmid = lat1 + (lat2 - lat1) / 2 # compute the midpoint 125 | 126 | # compute azimuth 127 | az = vdist(lat1, lon1, lat2, lon2, ell=ell)[1] 128 | 129 | # compute meridional and transverse radii of curvature 130 | rho = rcurve.meridian(latmid, ell, deg=True) 131 | nu = rcurve.transverse(latmid, ell, deg=True) 132 | 133 | az = radians(az) 134 | den = rho * sin(az) ** 2 + nu * cos(az) ** 2 135 | 136 | # compute radius of the arc from point 1 to point 2 137 | return rho * nu / den 138 | 139 | 140 | def curve(lat, ell: Ellipsoid = ELL, deg: bool = True, method: str = "mean"): 141 | """computes the arithmetic average of the transverse and meridional 142 | radii of curvature at a specified latitude point 143 | 144 | Parameters 145 | ---------- 146 | lat : float 147 | geodetic latitudes (degrees) 148 | ell : Ellipsoid, optional 149 | reference ellipsoid 150 | deg : bool, optional 151 | degrees input/output (False: radians in/out) 152 | method: str, optional 153 | "mean" or "norm" 154 | 155 | Returns 156 | ------- 157 | radius: float 158 | radius of sphere 159 | """ 160 | 161 | if deg: 162 | lat = radians(lat) 163 | 164 | rho = rcurve.meridian(lat, ell, deg=False) 165 | nu = rcurve.transverse(lat, ell, deg=False) 166 | 167 | if method == "mean": 168 | return (rho + nu) / 2 169 | elif method == "norm": 170 | return sqrt(rho * nu) 171 | else: 172 | raise ValueError("method must be mean or norm") 173 | 174 | 175 | def triaxial(ell: Ellipsoid = ELL, method: str = "mean") -> float: 176 | """computes triaxial average of the semimajor and semiminor axes of the ellipsoid 177 | 178 | Parameters 179 | ---------- 180 | ell : Ellipsoid, optional 181 | reference ellipsoid 182 | method: str, optional 183 | "mean" or "norm" 184 | 185 | Returns 186 | ------- 187 | radius: float 188 | radius of sphere 189 | """ 190 | 191 | if method == "mean": 192 | return (2 * ell.semimajor_axis + ell.semiminor_axis) / 3 193 | elif method == "norm": 194 | return (ell.semimajor_axis**2 * ell.semiminor_axis) ** (1 / 3) 195 | else: 196 | raise ValueError("method must be mean or norm") 197 | 198 | 199 | def biaxial(ell: Ellipsoid = ELL, method: str = "mean") -> float: 200 | """computes biaxial average of the semimajor and semiminor axes of the ellipsoid 201 | 202 | Parameters 203 | ---------- 204 | ell : Ellipsoid, optional 205 | reference ellipsoid 206 | method: str, optional 207 | "mean" or "norm" 208 | 209 | Returns 210 | ------- 211 | radius: float 212 | radius of sphere 213 | """ 214 | 215 | if method == "mean": 216 | return (ell.semimajor_axis + ell.semiminor_axis) / 2 217 | elif method == "norm": 218 | return sqrt(ell.semimajor_axis * ell.semiminor_axis) 219 | else: 220 | raise ValueError("method must be mean or norm") 221 | -------------------------------------------------------------------------------- /src/pymap3d/sidereal.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014-2018 Michael Hirsch, Ph.D. 2 | """ manipulations of sidereal time """ 3 | from datetime import datetime 4 | from math import tau 5 | 6 | from .timeconv import str2dt 7 | 8 | try: 9 | import astropy.units as u 10 | from astropy.coordinates import Longitude 11 | from astropy.time import Time 12 | except ImportError: 13 | pass 14 | 15 | 16 | __all__ = ["datetime2sidereal", "juliandate", "greenwichsrt"] 17 | 18 | 19 | def datetime2sidereal(time: datetime, lon_radians: float) -> float: 20 | """ 21 | Convert ``datetime`` to local sidereal time 22 | 23 | from D. Vallado "Fundamentals of Astrodynamics and Applications" 24 | 25 | 26 | time : datetime.datetime 27 | time to convert 28 | lon_radians : float 29 | longitude (radians) 30 | 31 | Results 32 | ------- 33 | 34 | tsr : float 35 | Local sidereal time 36 | """ 37 | if isinstance(time, (tuple, list)): 38 | return [datetime2sidereal(t, lon_radians) for t in time] 39 | 40 | try: 41 | return datetime2sidereal_astropy(time, lon_radians) 42 | except NameError: 43 | return datetime2sidereal_vallado(time, lon_radians) 44 | 45 | 46 | def datetime2sidereal_astropy(t: datetime, lon_radians: float) -> float: 47 | """datetime to sidereal time using astropy 48 | see datetime2sidereal() for description 49 | """ 50 | 51 | at = Time(t) 52 | tsr = at.sidereal_time(kind="apparent", longitude=Longitude(lon_radians, unit=u.radian)) 53 | return tsr.radian 54 | 55 | 56 | def datetime2sidereal_vallado(t: datetime, lon_radians: float) -> float: 57 | """datetime to sidereal time using Vallado methods 58 | see datetime2sidereal() for description 59 | """ 60 | 61 | jd = juliandate(str2dt(t)) 62 | # Greenwich Sidereal time RADIANS 63 | gst = greenwichsrt(jd) 64 | # Algorithm 15 p. 188 rotate GST to LOCAL SIDEREAL TIME 65 | return gst + lon_radians 66 | 67 | 68 | def juliandate(time: datetime) -> float: 69 | """ 70 | Python datetime to Julian time (days since Jan 1, 4713 BCE) 71 | 72 | from D.Vallado Fundamentals of Astrodynamics and Applications p.187 73 | and J. Meeus Astronomical Algorithms 1991 Eqn. 7.1 pg. 61 74 | 75 | Parameters 76 | ---------- 77 | 78 | time : datetime.datetime 79 | time to convert 80 | 81 | Results 82 | ------- 83 | 84 | jd : float 85 | Julian date (days since Jan 1, 4713 BCE) 86 | """ 87 | if isinstance(time, (tuple, list)): 88 | return list(map(juliandate, time)) 89 | 90 | if time.month < 3: 91 | year = time.year - 1 92 | month = time.month + 12 93 | else: 94 | year = time.year 95 | month = time.month 96 | 97 | A = int(year / 100.0) 98 | B = 2 - A + int(A / 4.0) 99 | C = (((time.second + time.microsecond / 1e6) / 60.0 + time.minute) / 60.0 + time.hour) / 24.0 100 | 101 | return int(365.25 * (year + 4716)) + int(30.6001 * (month + 1)) + time.day + B - 1524.5 + C 102 | 103 | 104 | def greenwichsrt(Jdate: float) -> float: 105 | """ 106 | Convert Julian time to sidereal time 107 | 108 | D. Vallado Ed. 4 109 | 110 | Parameters 111 | ---------- 112 | 113 | Jdate: float 114 | Julian date (since Jan 1, 4713 BCE) 115 | 116 | Results 117 | ------- 118 | 119 | tsr : float 120 | Sidereal time 121 | """ 122 | if isinstance(Jdate, (tuple, list)): 123 | return list(map(greenwichsrt, Jdate)) 124 | 125 | # %% Vallado Eq. 3-42 p. 184, Seidelmann 3.311-1 126 | tUT1 = (Jdate - 2451545.0) / 36525.0 127 | 128 | # Eqn. 3-47 p. 188 129 | gmst_sec = ( 130 | 67310.54841 131 | + (876600 * 3600 + 8640184.812866) * tUT1 132 | + 0.093104 * tUT1**2 133 | - 6.2e-6 * tUT1**3 134 | ) 135 | 136 | # 1/86400 and %(2*pi) implied by units of radians 137 | return gmst_sec * tau / 86400.0 % tau 138 | -------------------------------------------------------------------------------- /src/pymap3d/spherical.py: -------------------------------------------------------------------------------- 1 | """ 2 | Transformation of 3D coordinates between geocentric geodetic (latitude, 3 | longitude, height) and geocentric spherical (spherical latitude, longitude, 4 | radius). 5 | """ 6 | 7 | from __future__ import annotations 8 | 9 | from .ellipsoid import Ellipsoid 10 | from .mathfun import asin, atan2, cbrt, degrees, hypot, power, radians, sin, sqrt 11 | 12 | __all__ = [ 13 | "geodetic2spherical", 14 | "spherical2geodetic", 15 | ] 16 | 17 | ELL = Ellipsoid.from_name("wgs84") 18 | 19 | 20 | def geodetic2spherical( 21 | lat, 22 | lon, 23 | alt, 24 | ell: Ellipsoid = ELL, 25 | deg: bool = True, 26 | ) -> tuple: 27 | """ 28 | point transformation from Geodetic of specified ellipsoid (default WGS-84) 29 | to geocentric spherical of the same ellipsoid 30 | 31 | Parameters 32 | ---------- 33 | 34 | lat 35 | target geodetic latitude 36 | lon 37 | target geodetic longitude 38 | alt 39 | target altitude above geodetic ellipsoid (meters) 40 | ell : Ellipsoid, optional 41 | reference ellipsoid 42 | deg : bool, optional 43 | degrees input/output (False: radians in/out) 44 | 45 | 46 | Returns 47 | ------- 48 | 49 | Geocentric spherical (spherical latitude, longitude, radius 50 | 51 | lat 52 | target spherical latitude 53 | lon 54 | target longitude 55 | radius 56 | target distance to the geocenter (meters) 57 | 58 | based on: 59 | Vermeille, H., 2002. Direct transformation from geocentric coordinates to 60 | geodetic coordinates. Journal of Geodesy. 76. 451-454. 61 | doi:10.1007/s00190-002-0273-6 62 | """ 63 | 64 | if deg: 65 | lat = radians(lat) 66 | lon = radians(lon) 67 | 68 | # Pre-compute to avoid repeated trigonometric functions 69 | sinlat = sin(lat) 70 | coslat = sqrt(1 - sinlat**2) 71 | 72 | # radius of curvature of the prime vertical section 73 | N = ell.semimajor_axis**2 / hypot( 74 | ell.semimajor_axis * coslat, 75 | ell.semiminor_axis * sinlat, 76 | ) 77 | 78 | # Instead of computing X and Y, we only compute the projection on the XY 79 | # plane: xy_projection = sqrt( X**2 + Y**2 ) 80 | xy_projection = (alt + N) * coslat 81 | z_cartesian = (alt + (1 - ell.eccentricity**2) * N) * sinlat 82 | radius = hypot(xy_projection, z_cartesian) 83 | slat = asin(z_cartesian / radius) 84 | 85 | if deg: 86 | slat = degrees(slat) 87 | lon = degrees(lon) 88 | 89 | return slat, lon, radius 90 | 91 | 92 | def spherical2geodetic( 93 | lat, 94 | lon, 95 | radius, 96 | ell: Ellipsoid = ELL, 97 | deg: bool = True, 98 | ) -> tuple: 99 | """ 100 | point transformation from geocentric spherical of specified ellipsoid 101 | (default WGS-84) to geodetic of the same ellipsoid 102 | 103 | Parameters 104 | ---------- 105 | lat 106 | target spherical latitude 107 | lon 108 | target longitude 109 | radius 110 | target distance to the geocenter (meters) 111 | ell : Ellipsoid, optional 112 | reference ellipsoid 113 | deg : bool, optional 114 | degrees input/output (False: radians in/out) 115 | 116 | Returns 117 | ------- 118 | lat 119 | target geodetic latitude 120 | lon 121 | target geodetic longitude 122 | alt 123 | target altitude above geodetic ellipsoid (meters) 124 | 125 | based on: 126 | Vermeille, H., 2002. Direct transformation from geocentric coordinates to 127 | geodetic coordinates. Journal of Geodesy. 76. 451-454. 128 | doi:10.1007/s00190-002-0273-6 129 | """ 130 | 131 | if deg: 132 | lat = radians(lat) 133 | lon = radians(lon) 134 | 135 | # Pre-compute to avoid repeated trigonometric functions 136 | sinlat = sin(lat) 137 | coslat = sqrt(1 - sinlat**2) 138 | 139 | Z = radius * sinlat 140 | p_0 = power(radius, 2) * coslat**2 / ell.semimajor_axis**2 141 | q_0 = (1 - ell.eccentricity**2) / ell.semimajor_axis**2 * Z**2 142 | r_0 = (p_0 + q_0 - ell.eccentricity**4) / 6 143 | s_0 = ell.eccentricity**4 * p_0 * q_0 / 4 / r_0**3 144 | t_0 = cbrt(1 + s_0 + sqrt(2 * s_0 + s_0**2)) 145 | u_0 = r_0 * (1 + t_0 + 1 / t_0) 146 | v_0 = sqrt(u_0**2 + q_0 * ell.eccentricity**4) 147 | w_0 = ell.eccentricity**2 * (u_0 + v_0 - q_0) / 2 / v_0 148 | k = sqrt(u_0 + v_0 + w_0**2) - w_0 149 | D = k * radius * coslat / (k + ell.eccentricity**2) 150 | hypotDZ = hypot(D, Z) 151 | 152 | glat = 2 * atan2(Z, (D + hypotDZ)) 153 | alt = (k + ell.eccentricity**2 - 1) / k * hypotDZ 154 | 155 | if deg: 156 | glat = degrees(glat) 157 | lon = degrees(lon) 158 | 159 | return glat, lon, alt 160 | -------------------------------------------------------------------------------- /src/pymap3d/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/geospace-code/pymap3d/2e4d77fb180a900efc19a3fd446fa2be75bb3e69/src/pymap3d/tests/__init__.py -------------------------------------------------------------------------------- /src/pymap3d/tests/matlab_engine.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from pathlib import Path 3 | from datetime import datetime 4 | 5 | import matlab.engine 6 | 7 | 8 | @functools.cache 9 | def matlab_engine(): 10 | """ 11 | only cached because used by Pytest in multiple tests 12 | """ 13 | cwd = Path(__file__).parent 14 | eng = matlab.engine.start_matlab("-nojvm") 15 | eng.addpath(eng.genpath(str(cwd)), nargout=0) 16 | return eng 17 | 18 | 19 | def pydt2matdt(eng, utc: datetime): 20 | """ 21 | Python datetime.dateime to Matlab datetime 22 | """ 23 | return eng.datetime(utc.year, utc.month, utc.day, utc.hour, utc.minute, utc.second) 24 | 25 | 26 | @functools.cache 27 | def has_matmap3d(eng) -> bool: 28 | cwd = Path(__file__).parent 29 | d = cwd.parents[3] / "matmap3d" 30 | print(f"Looking in {d} for matmap3d") 31 | 32 | if d.is_dir(): 33 | eng.addpath(str(d), nargout=0) 34 | return True 35 | 36 | return False 37 | 38 | 39 | @functools.cache 40 | def has_aerospace(eng) -> bool: 41 | return eng.matlab_toolbox()["aerospace"] 42 | 43 | 44 | @functools.cache 45 | def has_mapping(eng) -> bool: 46 | return eng.matlab_toolbox()["mapping"] 47 | -------------------------------------------------------------------------------- /src/pymap3d/tests/matlab_toolbox.m: -------------------------------------------------------------------------------- 1 | function h = matlab_toolbox() 2 | 3 | h = struct(mapping=has_mapping(), aerospace=has_aerospace()); 4 | 5 | end 6 | 7 | 8 | function has_map = has_mapping() 9 | if usejava('jvm') 10 | addons = matlab.addons.installedAddons(); 11 | 12 | has_map = any(contains(addons.Name, 'Mapping Toolbox')); 13 | else 14 | has_map = ~isempty(ver("map")); 15 | end 16 | end 17 | 18 | 19 | function has_map = has_aerospace() 20 | if usejava('jvm') 21 | addons = matlab.addons.installedAddons(); 22 | 23 | has_map = any(contains(addons.Name, 'Aerospace Toolbox')); 24 | else 25 | has_map = ~isempty(ver("aero")); 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_aer.py: -------------------------------------------------------------------------------- 1 | from math import radians 2 | 3 | import pymap3d as pm 4 | import pytest 5 | from pytest import approx 6 | 7 | ELL = pm.Ellipsoid.from_name("wgs84") 8 | A = ELL.semimajor_axis 9 | B = ELL.semiminor_axis 10 | 11 | 12 | @pytest.mark.parametrize( 13 | "aer,lla,xyz", [((33, 70, 1000), (42, -82, 200), (660930.2, -4701424.0, 4246579.6))] 14 | ) 15 | def test_aer2ecef(aer, lla, xyz): 16 | # degrees 17 | xyz1 = pm.aer2ecef(*aer, *lla) 18 | assert xyz1 == approx(xyz) 19 | assert all(isinstance(n, float) for n in xyz1) 20 | # float includes np.float64 i.e. a scalar 21 | 22 | # radians 23 | raer = (radians(aer[0]), radians(aer[1]), aer[2]) 24 | rlla = (radians(lla[0]), radians(lla[1]), lla[2]) 25 | xyz1 = pm.aer2ecef(*raer, *rlla, deg=False) 26 | assert xyz1 == approx(xyz) 27 | assert all(isinstance(n, float) for n in xyz1) 28 | 29 | # bad input 30 | with pytest.raises(ValueError): 31 | pm.aer2ecef(aer[0], aer[1], -1, *lla) 32 | 33 | 34 | @pytest.mark.parametrize( 35 | "xyz, lla, aer", 36 | [ 37 | ((A - 1, 0, 0), (0, 0, 0), (0, -90, 1)), 38 | ((-A + 1, 0, 0), (0, 180, 0), (0, -90, 1)), 39 | ((0, A - 1, 0), (0, 90, 0), (0, -90, 1)), 40 | ((0, -A + 1, 0), (0, -90, 0), (0, -90, 1)), 41 | ((0, 0, B - 1), (90, 0, 0), (0, -90, 1)), 42 | ((0, 0, -B + 1), (-90, 0, 0), (0, -90, 1)), 43 | ((660930.19276, -4701424.22296, 4246579.60463), (42, -82, 200), (33, 70, 1000)), 44 | ], 45 | ) 46 | def test_ecef2aer(xyz, lla, aer): 47 | # degrees 48 | aer1 = pm.ecef2aer(*xyz, *lla) 49 | assert aer1 == approx(aer) 50 | assert all(isinstance(n, float) for n in aer1) 51 | 52 | # radians 53 | rlla = (radians(lla[0]), radians(lla[1]), lla[2]) 54 | raer = (radians(aer[0]), radians(aer[1]), aer[2]) 55 | aer1 = pm.ecef2aer(*xyz, *rlla, deg=False) 56 | assert aer1 == approx(raer) 57 | assert all(isinstance(n, float) for n in aer1) 58 | 59 | 60 | @pytest.mark.parametrize("aer,enu", [((33, 70, 1000), (186.2775, 286.8422, 939.6926))]) 61 | def test_aer_enu(aer, enu): 62 | # degrees 63 | enu1 = pm.aer2enu(*aer) 64 | assert enu1 == approx(enu) 65 | assert all(isinstance(n, float) for n in enu1) 66 | 67 | # radians 68 | raer = (radians(aer[0]), radians(aer[1]), aer[2]) 69 | enu1 = pm.aer2enu(*raer, deg=False) 70 | assert enu1 == approx(enu) 71 | assert all(isinstance(n, float) for n in enu1) 72 | 73 | # bad input 74 | with pytest.raises(ValueError): 75 | pm.aer2enu(aer[0], aer[1], -1) 76 | 77 | # degrees 78 | aer1 = pm.enu2aer(*enu) 79 | assert aer1 == approx(aer) 80 | assert all(isinstance(n, float) for n in aer1) 81 | 82 | # radians 83 | aer1 = pm.enu2aer(*enu, deg=False) 84 | assert aer1 == approx(raer) 85 | assert all(isinstance(n, float) for n in aer1) 86 | 87 | 88 | @pytest.mark.parametrize("aer,ned", [((33, 70, 1000), (286.8422, 186.2775, -939.6926))]) 89 | def test_aer_ned(aer, ned): 90 | ned1 = pm.aer2ned(*aer) 91 | assert ned1 == approx(ned) 92 | assert all(isinstance(n, float) for n in ned1) 93 | 94 | # bad value 95 | with pytest.raises(ValueError): 96 | pm.aer2ned(aer[0], aer[1], -1) 97 | 98 | aer1 = pm.ned2aer(*ned) 99 | assert aer1 == approx(aer) 100 | assert all(isinstance(n, float) for n in aer1) 101 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_eci.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import pymap3d as pm 4 | import pytest 5 | from pytest import approx 6 | 7 | try: 8 | import astropy 9 | except ImportError: 10 | astropy = None 11 | 12 | ECI = (-2981784, 5207055, 3161595) 13 | ECEF = [-5762640, -1682738, 3156028] 14 | UTC = datetime.datetime(2019, 1, 4, 12, tzinfo=datetime.timezone.utc) 15 | 16 | 17 | def test_eci2ecef(): 18 | pytest.importorskip("numpy") 19 | # this example from Matlab eci2ecef docs 20 | ecef = pm.eci2ecef(*ECI, UTC) 21 | 22 | assert isinstance(ecef[0], float) 23 | assert isinstance(ecef[1], float) 24 | assert isinstance(ecef[2], float) 25 | 26 | 27 | def test_eci2ecef_numpy(): 28 | pytest.importorskip("numpy") 29 | 30 | ecef = pm.eci2ecef(*ECI, UTC) 31 | 32 | rel = 0.025 33 | 34 | assert ecef == approx(ECEF, rel=rel) 35 | assert isinstance(ecef[0], float) 36 | assert isinstance(ecef[1], float) 37 | assert isinstance(ecef[2], float) 38 | 39 | 40 | def test_eci2ecef_astropy(): 41 | pytest.importorskip("astropy") 42 | 43 | ecef = pm.eci2ecef(*ECI, UTC) 44 | 45 | rel = 0.0001 46 | 47 | assert ecef == approx(ECEF, rel=rel) 48 | assert isinstance(ecef[0], float) 49 | assert isinstance(ecef[1], float) 50 | assert isinstance(ecef[2], float) 51 | 52 | 53 | def test_ecef2eci(): 54 | pytest.importorskip("numpy") 55 | # this example from Matlab ecef2eci docs 56 | eci = pm.ecef2eci(*ECEF, UTC) 57 | 58 | assert isinstance(eci[0], float) 59 | assert isinstance(eci[1], float) 60 | assert isinstance(eci[2], float) 61 | 62 | 63 | def test_ecef2eci_numpy(): 64 | pytest.importorskip("numpy") 65 | 66 | eci = pm.eci.ecef2eci_numpy(*ECEF, UTC) 67 | 68 | rel = 0.025 69 | 70 | assert eci == approx(ECI, rel=rel) 71 | assert isinstance(eci[0], float) 72 | assert isinstance(eci[1], float) 73 | assert isinstance(eci[2], float) 74 | 75 | 76 | def test_ecef2eci_astropy(): 77 | pytest.importorskip("astropy") 78 | 79 | eci = pm.eci.ecef2eci_astropy(*ECEF, UTC) 80 | 81 | rel = 0.0001 82 | 83 | assert eci == approx(ECI, rel=rel) 84 | assert isinstance(eci[0], float) 85 | assert isinstance(eci[1], float) 86 | assert isinstance(eci[2], float) 87 | 88 | 89 | def test_eci2geodetic(): 90 | pytest.importorskip("numpy") 91 | 92 | lla = pm.eci2geodetic(*ECI, UTC) 93 | 94 | rel = 0.01 if astropy is None else 0.0001 95 | 96 | assert lla == approx([27.880801, -163.722058, 408850.646], rel=rel) 97 | 98 | 99 | def test_geodetic2eci(): 100 | pytest.importorskip("numpy") 101 | 102 | lla = [27.880801, -163.722058, 408850.646] 103 | 104 | eci = pm.geodetic2eci(*lla, UTC) 105 | 106 | rel = 0.01 if astropy is None else 0.0001 107 | 108 | assert eci == approx([-2981784, 5207055, 3161595], rel=rel) 109 | 110 | 111 | def test_eci_aer(): 112 | # test coords from Matlab eci2aer 113 | pytest.importorskip("numpy") 114 | t = datetime.datetime(2022, 1, 2, 3, 4, 5, tzinfo=datetime.timezone.utc) 115 | 116 | eci = [4500000, -45000000, 3000000] 117 | lla = [28, -80, 100] 118 | 119 | aer = pm.eci2aer(*eci, *lla, t) 120 | 121 | rel = 0.01 if astropy is None else 0.0001 122 | 123 | assert aer == approx([314.9945, -53.0089, 5.026e7], rel=rel) 124 | 125 | eci2 = pm.aer2eci(*aer, *lla, t) 126 | 127 | rel = 0.1 if astropy is None else 0.001 128 | 129 | assert eci2 == approx(eci, rel=rel) 130 | 131 | with pytest.raises(ValueError): 132 | pm.aer2eci(aer[0], aer[1], -1, *lla, t) 133 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_ellipsoid.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest import approx 3 | import pymap3d as pm 4 | 5 | xyz0 = (660e3, -4700e3, 4247e3) 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "model,f", 10 | [ 11 | ("maupertuis", 0.005235602050865236), 12 | ("plessis", 0.003240020729165458), 13 | ("everest1830", 0.003324448922118313), 14 | ("everest1830m", 0.003324449295589469), 15 | ("everest1967", 0.003324449343845343), 16 | ("airy", 0.00334085067870327), 17 | ("bessel", 0.0033427731536659813), 18 | ("clarke1866", 0.0033900753039287908), 19 | ("clarke1878", 0.003407549790771363), 20 | ("clarke1860", 0.003407561308111843), 21 | ("helmert", 0.0033523298109184524), 22 | ("hayford", 0.003367003387062615), 23 | ("international1924", 0.003367003387062615), 24 | ("krassovsky1940", 0.0033523298336767685), 25 | ("wgs66", 0.0033528919458556804), 26 | ("australian", 0.003352891899858333), 27 | ("international1967", 0.003352896192983603), 28 | ("grs67", 0.0033529237272191623), 29 | ("sa1969", 0.003352891899858333), 30 | ("wgs72", 0.0033527794541680267), 31 | ("grs80", 0.0033528106811816882), 32 | ("wgs84", 0.0033528106647473664), 33 | ("wgs84_mean", 0.0), 34 | ("iers1989", 0.0033528131102879993), 35 | ("iers2003", 0.0033528131084554157), 36 | ("mercury", 0.0009014546199549272), 37 | ("venus", 0.0), 38 | ("moon", 0.0012082158679017317), 39 | ("mars", 0.006123875928193323), 40 | ("jupyter", 0.06604858798757626), 41 | ("io", 0.0075968738044488665), 42 | ("uranus", 0.022927344575296372), 43 | ("neptune", 0.01708124697141011), 44 | ("pluto", 0.0), 45 | ], 46 | ) 47 | def test_reference(model, f): 48 | assert pm.Ellipsoid.from_name(model).flattening == approx(f) 49 | 50 | 51 | def test_bad_name(): 52 | with pytest.raises(KeyError): 53 | pm.Ellipsoid.from_name("badname") 54 | 55 | 56 | def test_ellipsoid(): 57 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("maupertuis")) == approx( 58 | [42.123086280313906, -82.00647850636021, -13462.822154350226] 59 | ) 60 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("plessis")) == approx( 61 | [42.008184833614905, -82.00647850636021, 1566.9219075104988] 62 | ) 63 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("everest1830")) == approx( 64 | [42.01302648557789, -82.00647850636021, 1032.4153744896425] 65 | ) 66 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("everest1830m")) == approx( 67 | [42.0130266467127, -82.00647850636021, 1027.7254294115853] 68 | ) 69 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("everest1967")) == approx( 70 | [42.01302648557363, -82.00647850636021, 1033.2243733811288] 71 | ) 72 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("airy")) == approx( 73 | [42.01397060398504, -82.00647850636021, 815.5499438015993] 74 | ) 75 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("bessel")) == approx( 76 | [42.01407537004288, -82.00647850636021, 987.0246149983182] 77 | ) 78 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("clarke1866")) == approx( 79 | [42.01680003414445, -82.00647850636021, 313.90267925120395] 80 | ) 81 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("clarke1878")) == approx( 82 | [42.0177971504227, -82.00647850636021, 380.12002203958457] 83 | ) 84 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("clarke1860")) == approx( 85 | [42.017799612218326, -82.00647850636021, 321.0980872430816] 86 | ) 87 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("helmert")) == approx( 88 | [42.01464497456125, -82.00647850636021, 212.63680219872765] 89 | ) 90 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("hayford")) == approx( 91 | [42.01548834310426, -82.00647850636021, 66.77070154259877] 92 | ) 93 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("international1924")) == approx( 94 | [42.01548834310426, -82.00647850636021, 66.77070154259877] 95 | ) 96 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("krassovsky1940")) == approx( 97 | [42.01464632634865, -82.00647850636021, 167.7043859419633] 98 | ) 99 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("wgs66")) == approx( 100 | [42.014675415414274, -82.00647850636021, 269.1575142686737] 101 | ) 102 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("australian")) == approx( 103 | [42.01467586302664, -82.00647850636021, 254.17989315657786] 104 | ) 105 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("international1967")) == approx( 106 | [42.01467603307557, -82.00647850636021, 256.6883857005818] 107 | ) 108 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("grs67")) == approx( 109 | [42.01467768000789, -82.00647850636021, 254.27066653452297] 110 | ) 111 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("sa1969")) == approx( 112 | [42.01467586302664, -82.00647850636021, 254.17989315657786] 113 | ) 114 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("wgs72")) == approx( 115 | [42.01466869328149, -82.00647850636021, 278.8216763935984] 116 | ) 117 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("grs80")) == approx( 118 | [42.01467053601299, -82.00647850636021, 276.9137384511387] 119 | ) 120 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("wgs84")) == approx( 121 | [42.01467053507479, -82.00647850636021, 276.91369158042767] 122 | ) 123 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("wgs84_mean")) == approx( 124 | [41.823366301, -82.0064785, -2.13061272e3] 125 | ) 126 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("iers1989")) == approx( 127 | [42.01467064467172, -82.00647850636021, 277.9191657339711] 128 | ) 129 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("iers2003")) == approx( 130 | [42.01467066257621, -82.00647850636021, 277.320060889772] 131 | ) 132 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("mercury")) == approx( 133 | [41.8430384333997, -82.00647850636021, 3929356.5648451606] 134 | ) 135 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("venus")) == approx( 136 | [41.82336630167669, -82.00647850636021, 317078.15867127385] 137 | ) 138 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("moon")) == approx( 139 | [41.842147614909734, -82.00647850636021, 4631711.995926845] 140 | ) 141 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("mars")) == approx( 142 | [42.00945156056578, -82.00647850636021, 2981246.073616111] 143 | ) 144 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("jupyter")) == approx( 145 | [75.3013267078341, -82.00647850636021, -61782040.202975556] 146 | ) 147 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("io")) == approx( 148 | [41.82422244977044, -82.00647850636021, 6367054.626528843] 149 | ) 150 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("uranus")) == approx( 151 | [47.69837228395133, -82.00647850636021, -18904824.4361074] 152 | ) 153 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("neptune")) == approx( 154 | [45.931317431546425, -82.00647850636021, -18194050.781948525] 155 | ) 156 | assert pm.ecef2geodetic(*xyz0, ell=pm.Ellipsoid.from_name("pluto")) == approx( 157 | [41.82336630167669, -82.00647850636021, 5180878.158671274] 158 | ) 159 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_enu.py: -------------------------------------------------------------------------------- 1 | from math import radians 2 | 3 | import pymap3d as pm 4 | import pytest 5 | from pytest import approx 6 | 7 | ELL = pm.Ellipsoid.from_name("wgs84") 8 | A = ELL.semimajor_axis 9 | B = ELL.semiminor_axis 10 | 11 | 12 | @pytest.mark.parametrize("xyz", [(0, A, 50), ([0], [A], [50])], ids=("scalar", "list")) 13 | def test_scalar_enu(xyz): 14 | """ 15 | verify we can handle the wide variety of input data type users might use 16 | """ 17 | if isinstance(xyz[0], list): 18 | pytest.importorskip("numpy") 19 | 20 | enu = pm.ecef2enu(*xyz, 0, 90, -100) 21 | assert pm.enu2ecef(*enu, 0, 90, -100) == approx(xyz) 22 | 23 | 24 | def test_array_enu(): 25 | np = pytest.importorskip("numpy") 26 | 27 | xyz = (np.asarray(0), np.asarray(A), np.asarray(50)) 28 | llh = (np.asarray(0), np.asarray(90), np.asarray(-100)) 29 | enu = pm.ecef2enu(*xyz, *llh) 30 | assert pm.enu2ecef(*enu, *llh) == approx(xyz) 31 | 32 | xyz = (np.atleast_1d(0), np.atleast_1d(A), np.atleast_1d(50)) 33 | llh = (np.atleast_1d(0), np.atleast_1d(90), np.atleast_1d(-100)) 34 | enu = pm.ecef2enu(*xyz, *llh) 35 | assert pm.enu2ecef(*enu, *llh) == approx(xyz) 36 | 37 | 38 | @pytest.mark.parametrize( 39 | "enu,lla,xyz", [((0, 0, 0), (0, 0, 0), (A, 0, 0)), ((0, 0, 1000), (0, 0, 0), (A + 1000, 0, 0))] 40 | ) 41 | def test_enu_ecef(enu, lla, xyz): 42 | x, y, z = pm.enu2ecef(*enu, *lla) 43 | assert x == approx(xyz[0]) 44 | assert y == approx(xyz[1]) 45 | assert z == approx(xyz[2]) 46 | assert isinstance(x, float) 47 | assert isinstance(y, float) 48 | assert isinstance(z, float) 49 | 50 | rlla = (radians(lla[0]), radians(lla[1]), lla[2]) 51 | assert pm.enu2ecef(*enu, *rlla, deg=False) == approx(xyz) 52 | 53 | e, n, u = pm.ecef2enu(*xyz, *lla) 54 | assert e == approx(enu[0]) 55 | assert n == approx(enu[1]) 56 | assert u == approx(enu[2]) 57 | assert isinstance(e, float) 58 | assert isinstance(n, float) 59 | assert isinstance(u, float) 60 | 61 | e, n, u = pm.ecef2enu(*xyz, *rlla, deg=False) 62 | assert e == approx(enu[0]) 63 | assert n == approx(enu[1]) 64 | assert u == approx(enu[2]) 65 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_geodetic.py: -------------------------------------------------------------------------------- 1 | from math import isnan, nan, radians, sqrt 2 | 3 | import pymap3d as pm 4 | import pytest 5 | from pytest import approx 6 | 7 | lla0 = (42, -82, 200) 8 | rlla0 = (radians(lla0[0]), radians(lla0[1]), lla0[2]) 9 | 10 | xyz0 = (660675.2518247, -4700948.68316, 4245737.66222) 11 | 12 | ELL = pm.Ellipsoid.from_name("wgs84") 13 | A = ELL.semimajor_axis 14 | B = ELL.semiminor_axis 15 | 16 | xyzlla = [ 17 | ((A, 0, 0), (0, 0, 0)), 18 | ((A - 1, 0, 0), (0, 0, -1)), 19 | ((A + 1, 0, 0), (0, 0, 1)), 20 | ((0.1 * A, 0, 0), (0, 0, -0.9 * A)), 21 | ((0.001 * A, 0, 0), (0, 0, -0.999 * A)), 22 | ((0, A, 0), (0, 90, 0)), 23 | ((0, A - 1, 0), (0, 90, -1)), 24 | ((0, A + 1, 0), (0, 90, 1)), 25 | ((0, 0.1 * A, 0), (0, 90, -0.9 * A)), 26 | ((0, 0.001 * A, 0), (0, 90, -0.999 * A)), 27 | ((0, 0, B), (90, 0, 0)), 28 | ((0, 0, B + 1), (90, 0, 1)), 29 | ((0, 0, B - 1), (90, 0, -1)), 30 | ((0, 0, 0.1 * B), (90, 0, -0.9 * B)), 31 | ((0, 0, 0.001 * B), (90, 0, -0.999 * B)), 32 | ((0, 0, B - 1), (89.999999, 0, -1)), 33 | ((0, 0, B - 1), (89.99999, 0, -1)), 34 | ((0, 0, -B + 1), (-90, 0, -1)), 35 | ((0, 0, -B + 1), (-89.999999, 0, -1)), 36 | ((0, 0, -B + 1), (-89.99999, 0, -1)), 37 | ((-A + 1, 0, 0), (0, 180, -1)), 38 | ] 39 | 40 | llaxyz = [ 41 | ((0, 0, -1), (A - 1, 0, 0)), 42 | ((0, 90, -1), (0, A - 1, 0)), 43 | ((0, -90, -1), (0, -A + 1, 0)), 44 | ((90, 0, -1), (0, 0, B - 1)), 45 | ((90, 15, -1), (0, 0, B - 1)), 46 | ((-90, 0, -1), (0, 0, -B + 1)), 47 | ] 48 | 49 | 50 | atol_dist = 1e-6 # 1 micrometer 51 | 52 | 53 | @pytest.mark.parametrize("lla", [lla0, ([lla0[0]], [lla0[1]], [lla0[2]])], ids=("scalar", "list")) 54 | def test_scalar_geodetic2ecef(lla): 55 | """ 56 | verify we can handle the wide variety of input data type users might use 57 | """ 58 | 59 | if isinstance(lla[0], list): 60 | np = pytest.importorskip("numpy") 61 | scalar = False 62 | else: 63 | scalar = True 64 | 65 | xyz = pm.geodetic2ecef(*lla) 66 | lla1 = pm.ecef2geodetic(*xyz) 67 | 68 | try: 69 | np.testing.assert_allclose(lla1, lla, rtol=1e-4) 70 | except NameError: 71 | assert lla1 == approx(lla, rel=1e-4) 72 | 73 | if scalar: 74 | assert all(isinstance(n, float) for n in xyz) 75 | assert all(isinstance(n, float) for n in lla1) 76 | 77 | 78 | def test_array_geodetic2ecef(): 79 | np = pytest.importorskip("numpy") 80 | 81 | lla = (np.asarray(lla0[0]), np.asarray(lla0[1]), np.asarray(lla0[2])) 82 | xyz = pm.geodetic2ecef(*lla) 83 | np.testing.assert_allclose(pm.ecef2geodetic(*xyz), lla) 84 | 85 | lla = (np.atleast_1d(lla0[0]), np.atleast_1d(lla0[1]), np.atleast_1d(lla0[2])) 86 | xyz = pm.geodetic2ecef(*lla) 87 | np.testing.assert_allclose(pm.ecef2geodetic(*xyz), lla) 88 | 89 | 90 | @pytest.mark.parametrize("xyz", [xyz0, ([xyz0[0]], [xyz0[1]], [xyz0[2]])], ids=("scalar", "list")) 91 | def test_scalar_ecef2geodetic(xyz): 92 | """ 93 | verify we can handle the wide variety of input data type users might use 94 | """ 95 | 96 | if isinstance(xyz[0], list): 97 | np = pytest.importorskip("numpy") 98 | scalar = False 99 | else: 100 | scalar = True 101 | 102 | lla = pm.ecef2geodetic(*xyz) 103 | xyz1 = pm.geodetic2ecef(*lla) 104 | 105 | try: 106 | np.testing.assert_allclose(xyz1, xyz, rtol=1e-4) 107 | except NameError: 108 | assert xyz1 == approx(xyz, rel=1e-4) 109 | 110 | if scalar: 111 | assert all(isinstance(n, float) for n in xyz1) 112 | assert all(isinstance(n, float) for n in lla) 113 | 114 | 115 | def test_array_ecef2geodetic(): 116 | np = pytest.importorskip("numpy") 117 | 118 | xyz = (np.asarray(xyz0[0]), np.asarray(xyz0[1]), np.asarray(xyz0[2])) 119 | lla = pm.ecef2geodetic(*xyz) 120 | np.testing.assert_allclose(pm.geodetic2ecef(*lla), xyz) 121 | 122 | xyz = (np.atleast_1d(xyz0[0]), np.atleast_1d(xyz0[1]), np.atleast_1d(xyz0[2])) 123 | lla = pm.ecef2geodetic(*xyz) 124 | np.testing.assert_allclose(pm.geodetic2ecef(*lla), xyz) 125 | 126 | 127 | def test_inside_ecef2geodetic(): 128 | np = pytest.importorskip("numpy") 129 | # test values with no points inside ellipsoid 130 | lla0_array = ( 131 | np.array([lla0[0], lla0[0]]), 132 | np.array([lla0[1], lla0[1]]), 133 | np.array([lla0[2], lla0[2]]), 134 | ) 135 | xyz = pm.geodetic2ecef(*lla0_array) 136 | lats, lons, alts = pm.ecef2geodetic(*xyz) 137 | 138 | assert lats == approx(lla0_array[0]) 139 | assert lons == approx(lla0_array[1]) 140 | assert alts == approx(lla0_array[2]) 141 | 142 | # test values with some (but not all) points inside ellipsoid 143 | lla0_array_inside = ( 144 | np.array([lla0[0], lla0[0]]), 145 | np.array([lla0[1], lla0[1]]), 146 | np.array([lla0[2], -lla0[2]]), 147 | ) 148 | xyz = pm.geodetic2ecef(*lla0_array_inside) 149 | lats, lons, alts = pm.ecef2geodetic(*xyz) 150 | 151 | assert lats == approx(lla0_array_inside[0]) 152 | assert lons == approx(lla0_array_inside[1]) 153 | assert alts == approx(lla0_array_inside[2]) 154 | 155 | 156 | def test_xarray_ecef(): 157 | xarray = pytest.importorskip("xarray") 158 | 159 | lla = xarray.DataArray(list(lla0)) 160 | 161 | xyz = pm.geodetic2ecef(*lla) 162 | lla1 = pm.ecef2geodetic(*xyz) 163 | assert lla1 == approx(lla) 164 | 165 | 166 | def test_pandas_ecef(): 167 | pandas = pytest.importorskip("pandas") 168 | 169 | x, y, z = pm.geodetic2ecef( 170 | pandas.Series(lla0[0]), pandas.Series(lla0[1]), pandas.Series(lla0[2]) 171 | ) 172 | 173 | lat, lon, alt = pm.ecef2geodetic(pandas.Series(x), pandas.Series(y), pandas.Series(z)) 174 | assert lat == approx(lla0[0]) 175 | assert lon == approx(lla0[1]) 176 | assert alt == approx(lla0[2]) 177 | 178 | 179 | def test_ecef(): 180 | xyz = pm.geodetic2ecef(*lla0) 181 | 182 | assert xyz == approx(xyz0) 183 | x, y, z = pm.geodetic2ecef(*rlla0, deg=False) 184 | assert x == approx(xyz[0]) 185 | assert y == approx(xyz[1]) 186 | assert z == approx(xyz[2]) 187 | 188 | assert pm.ecef2geodetic(*xyz) == approx(lla0) 189 | assert pm.ecef2geodetic(*xyz, deg=False) == approx(rlla0) 190 | 191 | assert pm.ecef2geodetic((A - 1) / sqrt(2), (A - 1) / sqrt(2), 0) == approx([0, 45, -1]) 192 | 193 | 194 | @pytest.mark.parametrize("lla, xyz", llaxyz) 195 | def test_geodetic2ecef(lla, xyz): 196 | assert pm.geodetic2ecef(*lla) == approx(xyz, abs=atol_dist) 197 | 198 | 199 | @pytest.mark.parametrize("xyz, lla", xyzlla) 200 | def test_ecef2geodetic(xyz, lla): 201 | lat, lon, alt = pm.ecef2geodetic(*xyz) 202 | assert lat == approx(lla[0]) 203 | assert lon == approx(lla[1]) 204 | assert alt == approx(lla[2], abs=1e-9) 205 | 206 | 207 | @pytest.mark.parametrize( 208 | "aer,lla,lla0", 209 | [ 210 | ((33, 77, 1000), (42.0016981935, -81.99852, 1174.374035), (42, -82, 200)), 211 | ((0, 90, 10000), (0, 0, 10000), (0, 0, 0)), 212 | ], 213 | ) 214 | def test_aer_geodetic(aer, lla, lla0): 215 | lla1 = pm.aer2geodetic(*aer, *lla0) 216 | assert lla1 == approx(lla) 217 | assert all(isinstance(n, float) for n in lla1) 218 | 219 | raer = (radians(aer[0]), radians(aer[1]), aer[2]) 220 | rlla0 = (radians(lla0[0]), radians(lla0[1]), lla0[2]) 221 | lla1 = pm.aer2geodetic(*raer, *rlla0, deg=False) 222 | assert lla1 == approx((radians(lla[0]), radians(lla[1]), lla[2])) 223 | assert all(isinstance(n, float) for n in lla1) 224 | 225 | with pytest.raises(ValueError): 226 | pm.aer2geodetic(aer[0], aer[1], -1, *lla0) 227 | 228 | assert pm.geodetic2aer(*lla, *lla0) == approx(aer, rel=1e-3) 229 | assert pm.geodetic2aer(radians(lla[0]), radians(lla[1]), lla[2], *rlla0, deg=False) == approx( 230 | raer, rel=1e-3 231 | ) 232 | 233 | 234 | def test_scalar_nan(): 235 | aer = pm.geodetic2aer(nan, nan, nan, *lla0) 236 | assert all(isnan(n) for n in aer) 237 | 238 | llat = pm.aer2geodetic(nan, nan, nan, *lla0) 239 | assert all(isnan(n) for n in llat) 240 | 241 | 242 | def test_allnan(): 243 | np = pytest.importorskip("numpy") 244 | anan = np.empty((10, 10)) 245 | anan.fill(nan) 246 | assert np.isnan(pm.geodetic2aer(anan, anan, anan, *lla0)).all() 247 | assert np.isnan(pm.aer2geodetic(anan, anan, anan, *lla0)).all() 248 | 249 | 250 | def test_somenan(): 251 | np = pytest.importorskip("numpy") 252 | xyz = np.stack((xyz0, (nan, nan, nan))) 253 | 254 | lat, lon, alt = pm.ecef2geodetic(xyz[:, 0], xyz[:, 1], xyz[:, 2]) 255 | assert (lat[0], lon[0], alt[0]) == approx(lla0) 256 | 257 | 258 | @pytest.mark.parametrize("xyz, lla", xyzlla) 259 | def test_numpy_ecef2geodetic(xyz, lla): 260 | np = pytest.importorskip("numpy") 261 | lla1 = pm.ecef2geodetic( 262 | *np.array( 263 | [ 264 | [xyz], 265 | ], 266 | dtype=np.float32, 267 | ).T 268 | ) 269 | assert lla1 == approx(lla) 270 | 271 | 272 | @pytest.mark.parametrize("lla, xyz", llaxyz) 273 | def test_numpy_geodetic2ecef(lla, xyz): 274 | np = pytest.importorskip("numpy") 275 | xyz1 = pm.geodetic2ecef( 276 | *np.array( 277 | [ 278 | [lla], 279 | ], 280 | dtype=np.float32, 281 | ).T 282 | ) 283 | 284 | atol_dist = 1 # meters 285 | assert xyz1 == approx(xyz, abs=atol_dist) 286 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_latitude.py: -------------------------------------------------------------------------------- 1 | from math import inf, radians 2 | 3 | import pymap3d as pm 4 | import pymap3d.rcurve as rcurve 5 | import pytest 6 | from pytest import approx 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "geodetic_lat,alt_m,geocentric_lat", 11 | [(0, 0, 0), (90, 0, 90), (-90, 0, -90), (45, 0, 44.80757678), (-45, 0, -44.80757678)], 12 | ) 13 | def test_geodetic_alt_geocentric(geodetic_lat, alt_m, geocentric_lat): 14 | assert pm.geod2geoc(geodetic_lat, alt_m) == approx(geocentric_lat) 15 | 16 | r = rcurve.geocentric_radius(geodetic_lat) 17 | assert pm.geoc2geod(geocentric_lat, r) == approx(geodetic_lat) 18 | assert pm.geoc2geod(geocentric_lat, 1e5 + r) == approx( 19 | pm.geocentric2geodetic(geocentric_lat, 1e5 + alt_m) 20 | ) 21 | 22 | assert pm.geod2geoc(geodetic_lat, 1e5 + alt_m) == approx( 23 | pm.geodetic2geocentric(geodetic_lat, 1e5 + alt_m) 24 | ) 25 | 26 | 27 | @pytest.mark.parametrize( 28 | "geodetic_lat,geocentric_lat", 29 | [(0, 0), (90, 90), (-90, -90), (45, 44.80757678), (-45, -44.80757678)], 30 | ) 31 | def test_geodetic_geocentric(geodetic_lat, geocentric_lat): 32 | assert pm.geodetic2geocentric(geodetic_lat, 0) == approx(geocentric_lat) 33 | assert pm.geodetic2geocentric(radians(geodetic_lat), 0, deg=False) == approx( 34 | radians(geocentric_lat) 35 | ) 36 | 37 | assert pm.geocentric2geodetic(geocentric_lat, 0) == approx(geodetic_lat) 38 | assert pm.geocentric2geodetic(radians(geocentric_lat), 0, deg=False) == approx( 39 | radians(geodetic_lat) 40 | ) 41 | 42 | 43 | def test_numpy_geodetic_geocentric(): 44 | pytest.importorskip("numpy") 45 | assert pm.geodetic2geocentric([45, 0], 0) == approx([44.80757678, 0]) 46 | assert pm.geocentric2geodetic([44.80757678, 0], 0) == approx([45, 0]) 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "geodetic_lat, isometric_lat", 51 | [ 52 | (0, 0), 53 | (90, inf), 54 | (-90, -inf), 55 | (45, 50.227466), 56 | (-45, -50.227466), 57 | (89, 271.275), 58 | ], 59 | ) 60 | def test_geodetic_isometric(geodetic_lat, isometric_lat): 61 | isolat = pm.geodetic2isometric(geodetic_lat) 62 | assert isolat == approx(isometric_lat) 63 | assert isinstance(isolat, float) 64 | 65 | assert pm.geodetic2isometric(radians(geodetic_lat), deg=False) == approx( 66 | radians(isometric_lat) 67 | ) 68 | 69 | assert pm.isometric2geodetic(isometric_lat) == approx(geodetic_lat) 70 | assert pm.isometric2geodetic(radians(isometric_lat), deg=False) == approx( 71 | radians(geodetic_lat) 72 | ) 73 | 74 | 75 | def test_numpy_geodetic_isometric(): 76 | pytest.importorskip("numpy") 77 | assert pm.geodetic2isometric([45, 0]) == approx([50.227466, 0]) 78 | assert pm.isometric2geodetic([50.227466, 0]) == approx([45, 0]) 79 | 80 | 81 | @pytest.mark.parametrize( 82 | "geodetic_lat,conformal_lat", 83 | [(0, 0), (90, 90), (-90, -90), (45, 44.80768406), (-45, -44.80768406), (89, 88.99327)], 84 | ) 85 | def test_geodetic_conformal(geodetic_lat, conformal_lat): 86 | clat = pm.geodetic2conformal(geodetic_lat) 87 | assert clat == approx(conformal_lat) 88 | assert isinstance(clat, float) 89 | 90 | assert pm.geodetic2conformal(radians(geodetic_lat), deg=False) == approx( 91 | radians(conformal_lat) 92 | ) 93 | 94 | assert pm.conformal2geodetic(conformal_lat) == approx(geodetic_lat) 95 | assert pm.conformal2geodetic(radians(conformal_lat), deg=False) == approx( 96 | radians(geodetic_lat) 97 | ) 98 | 99 | 100 | def test_numpy_geodetic_conformal(): 101 | pytest.importorskip("numpy") 102 | assert pm.geodetic2conformal([45, 0]) == approx([44.80768406, 0]) 103 | assert pm.conformal2geodetic([44.80768406, 0]) == approx([45, 0]) 104 | 105 | 106 | @pytest.mark.parametrize( 107 | "geodetic_lat,rectifying_lat", 108 | [(0, 0), (90, 90), (-90, -90), (45, 44.855682), (-45, -44.855682)], 109 | ) 110 | def test_geodetic_rectifying(geodetic_lat, rectifying_lat): 111 | assert pm.geodetic2rectifying(geodetic_lat) == approx(rectifying_lat) 112 | assert pm.geodetic2rectifying(radians(geodetic_lat), deg=False) == approx( 113 | radians(rectifying_lat) 114 | ) 115 | 116 | assert pm.rectifying2geodetic(rectifying_lat) == approx(geodetic_lat) 117 | assert pm.rectifying2geodetic(radians(rectifying_lat), deg=False) == approx( 118 | radians(geodetic_lat) 119 | ) 120 | 121 | 122 | def test_numpy_geodetic_rectifying(): 123 | pytest.importorskip("numpy") 124 | assert pm.geodetic2rectifying([45, 0]) == approx([44.855682, 0]) 125 | assert pm.rectifying2geodetic([44.855682, 0]) == approx([45, 0]) 126 | 127 | 128 | @pytest.mark.parametrize( 129 | "geodetic_lat,authalic_lat", 130 | [(0, 0), (90, 90), (-90, -90), (45, 44.87170288), (-45, -44.87170288)], 131 | ) 132 | def test_geodetic_authalic(geodetic_lat, authalic_lat): 133 | assert pm.geodetic2authalic(geodetic_lat) == approx(authalic_lat) 134 | assert pm.geodetic2authalic(radians(geodetic_lat), deg=False) == approx( 135 | radians(authalic_lat) 136 | ) 137 | 138 | assert pm.authalic2geodetic(authalic_lat) == approx(geodetic_lat) 139 | assert pm.authalic2geodetic(radians(authalic_lat), deg=False) == approx( 140 | radians(geodetic_lat) 141 | ) 142 | 143 | 144 | def test_numpy_geodetic_authalic(): 145 | pytest.importorskip("numpy") 146 | assert pm.geodetic2authalic([45, 0]) == approx([44.87170288, 0]) 147 | assert pm.authalic2geodetic([44.87170288, 0]) == approx([45, 0]) 148 | 149 | 150 | @pytest.mark.parametrize( 151 | "geodetic_lat,parametric_lat", 152 | [(0, 0), (90, 90), (-90, -90), (45, 44.9037878), (-45, -44.9037878)], 153 | ) 154 | def test_geodetic_parametric(geodetic_lat, parametric_lat): 155 | assert pm.geodetic2parametric(geodetic_lat) == approx(parametric_lat) 156 | assert pm.geodetic2parametric(radians(geodetic_lat), deg=False) == approx( 157 | radians(parametric_lat) 158 | ) 159 | 160 | assert pm.parametric2geodetic(parametric_lat) == approx(geodetic_lat) 161 | assert pm.parametric2geodetic(radians(parametric_lat), deg=False) == approx( 162 | radians(geodetic_lat) 163 | ) 164 | 165 | 166 | def test_numpy_geodetic_parametric(): 167 | pytest.importorskip("numpy") 168 | assert pm.geodetic2parametric([45, 0]) == approx([44.9037878, 0]) 169 | assert pm.parametric2geodetic([44.9037878, 0]) == approx([45, 0]) 170 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_look_spheroid.py: -------------------------------------------------------------------------------- 1 | from math import nan 2 | 3 | import pymap3d.los as los 4 | import pytest 5 | from pytest import approx 6 | 7 | lla0 = (42, -82, 200) 8 | 9 | 10 | @pytest.mark.parametrize( 11 | "az,tilt,lat,lon,sr", 12 | [ 13 | (0, 0, 10, -20, 1e3), 14 | (0, 90, nan, nan, nan), 15 | (0, 45, 10.009041667, -20, 1.41432515e3), 16 | (45, 45, 10.00639336, -19.993549978, 1.414324795e3), 17 | (125, 45, 9.99481382, -19.992528, 1.414324671e3), 18 | ], 19 | ) 20 | def test_losint(az, tilt, lat, lon, sr): 21 | lla0 = (10, -20, 1e3) 22 | lat1, lon1, sr1 = los.lookAtSpheroid(*lla0, az, tilt=tilt) 23 | 24 | nan_ok = True if tilt == 90 else False 25 | 26 | assert lat1 == approx(lat, nan_ok=nan_ok) 27 | assert lon1 == approx(lon, nan_ok=nan_ok) 28 | assert sr1 == approx(sr, nan_ok=nan_ok) 29 | assert isinstance(lat1, float) 30 | assert isinstance(lon1, float) 31 | assert isinstance(sr1, float) 32 | 33 | 34 | def test_badval(): 35 | with pytest.raises(ValueError): 36 | los.lookAtSpheroid(0, 0, -1, 0, 0) 37 | 38 | 39 | def test_array_los(): 40 | np = pytest.importorskip("numpy") 41 | 42 | az = [0.0, 10.0, 125.0] 43 | tilt = [30.0, 45.0, 90.0] 44 | 45 | lat, lon, sr = los.lookAtSpheroid(*lla0, az, tilt) 46 | 47 | truth = np.array( 48 | [ 49 | [42.00103959, lla0[1], 230.9413173], 50 | [42.00177328, -81.9995808, 282.84715651], 51 | [nan, nan, nan], 52 | ] 53 | ) 54 | 55 | assert np.column_stack((lat, lon, sr)) == approx(truth, nan_ok=True) 56 | 57 | lat, lon, sr = los.lookAtSpheroid([lla0[0]] * 3, [lla0[1]] * 3, [lla0[2]] * 3, az, tilt) 58 | assert np.column_stack((lat, lon, sr)) == approx(truth, nan_ok=True) 59 | 60 | 61 | def test_xarray_los(): 62 | xarray = pytest.importorskip("xarray") 63 | 64 | lla = xarray.DataArray(list(lla0)) 65 | az = xarray.DataArray([0.0] * 2) 66 | tilt = xarray.DataArray([30.0] * 2) 67 | 68 | lat, lon, sr = los.lookAtSpheroid(*lla, az, tilt) 69 | assert lat == approx(42.00103959) 70 | assert lon == approx(lla0[1]) 71 | assert sr == approx(230.9413173) 72 | 73 | 74 | def test_pandas_los(): 75 | pandas = pytest.importorskip("pandas") 76 | 77 | lla = pandas.Series(lla0) 78 | az = pandas.Series([0.0] * 2) 79 | tilt = pandas.Series([30.0] * 2) 80 | 81 | lat, lon, sr = los.lookAtSpheroid(*lla, az, tilt) 82 | assert lat == approx(42.00103959) 83 | assert lon == approx(lla0[1]) 84 | assert sr == approx(230.9413173) 85 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_matlab_ecef2eci.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compare ecef2eci() with Matlab Aerospace Toolbox 3 | """ 4 | 5 | from __future__ import annotations 6 | from datetime import datetime 7 | 8 | import pytest 9 | from pytest import approx 10 | 11 | import pymap3d 12 | 13 | try: 14 | import numpy as np 15 | from .matlab_engine import matlab_engine, has_aerospace, has_matmap3d, pydt2matdt 16 | except ImportError: 17 | pytest.skip("Matlab Engine not found", allow_module_level=True) 18 | except RuntimeError: 19 | pytest.skip("Matlab Engine configuration error", allow_module_level=True) 20 | 21 | 22 | def ecef2eci(eng, matmap3d: bool, utc_m, ecef): 23 | if matmap3d: 24 | return eng.matmap3d.ecef2eci(utc_m, *ecef, nargout=3) 25 | 26 | return np.array(eng.ecef2eci(utc_m, np.asarray(ecef), nargout=1)).squeeze() 27 | 28 | 29 | def eci2ecef(eng, matmap3d: bool, utc_m, eci): 30 | if matmap3d: 31 | return eng.matmap3d.eci2ecef(utc_m, *eci, nargout=3) 32 | 33 | return np.array(eng.eci2ecef(utc_m, np.asarray(eci), nargout=1)).squeeze() 34 | 35 | 36 | @pytest.mark.parametrize("matmap3d", [False, True]) 37 | def test_compare_ecef2eci(matmap3d): 38 | eng = matlab_engine() 39 | 40 | if matmap3d: 41 | if not has_matmap3d(eng): 42 | pytest.skip("Matmap3d not found") 43 | else: 44 | if not has_aerospace(eng): 45 | pytest.skip("Aerospace Toolbox not found") 46 | 47 | ecef = [-5762640.0, -1682738.0, 3156028.0] 48 | utc = datetime(2019, 1, 4, 12) 49 | rtol = 0.01 50 | 51 | eci_py = pymap3d.ecef2eci(ecef[0], ecef[1], ecef[2], utc) 52 | 53 | eci_m = ecef2eci(eng, matmap3d, pydt2matdt(eng, utc), ecef) 54 | 55 | assert eci_py == approx(eci_m, rel=rtol) 56 | 57 | 58 | @pytest.mark.parametrize("matmap3d", [False, True]) 59 | def test_compare_eci2ecef(matmap3d): 60 | eng = matlab_engine() 61 | 62 | if matmap3d: 63 | if not has_matmap3d(eng): 64 | pytest.skip("Matmap3d not found") 65 | else: 66 | if not has_aerospace(eng): 67 | pytest.skip("Aerospace Toolbox not found") 68 | 69 | eci = [-3009680.518620539, 5194367.153184303, 3156028.0] 70 | utc = datetime(2019, 1, 4, 12) 71 | rtol = 0.02 72 | 73 | ecef_py = pymap3d.eci2ecef(eci[0], eci[1], eci[2], utc) 74 | 75 | ecef_m = eci2ecef(eng, matmap3d, pydt2matdt(eng, utc), eci) 76 | 77 | assert ecef_py == approx(ecef_m, rel=rtol) 78 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_matlab_lox.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Compare with Matlab Mapping toolbox reckon() 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import pytest 9 | from pytest import approx 10 | 11 | try: 12 | from .matlab_engine import matlab_engine, has_matmap3d, has_mapping 13 | except ImportError: 14 | pytest.skip("Matlab Engine not found", allow_module_level=True) 15 | except RuntimeError: 16 | pytest.skip("Matlab Engine configuration error", allow_module_level=True) 17 | 18 | 19 | from pymap3d.lox import loxodrome_direct 20 | 21 | 22 | def reckon( 23 | eng, matmap3d: bool, lat1: float, lon1: float, rng: float, az: float 24 | ) -> tuple[float, float]: 25 | """Using Matlab Engine to do same thing as Pymap3d""" 26 | 27 | if matmap3d: 28 | return eng.matmap3d.vreckon(lat1, lon1, rng, az, nargout=2) 29 | 30 | return eng.reckon("rh", lat1, lon1, rng, az, eng.wgs84Ellipsoid(), nargout=2) 31 | 32 | 33 | @pytest.mark.parametrize("matmap3d", [False, True]) 34 | def test_lox_stability(matmap3d): 35 | eng = matlab_engine() 36 | 37 | if matmap3d: 38 | if not has_matmap3d(eng): 39 | pytest.skip("Matmap3d not found") 40 | else: 41 | if not has_mapping(eng): 42 | pytest.skip("Matlab Toolbox not found") 43 | 44 | clat, clon, rng = 35.0, 140.0, 50000.0 # arbitrary 45 | 46 | for i in range(20): 47 | for azi in (90 + 10.0 ** (-i), -90 + 10.0 ** (-i), 270 + 10.0 ** (-i), -270 + 10.0 ** (-i)): 48 | lat, lon = loxodrome_direct(clat, clon, rng, azi) 49 | 50 | lat_matlab, lon_matlab = reckon(eng, matmap3d, clat, clon, rng, azi) 51 | 52 | assert lat == approx(lat_matlab, rel=0.005) 53 | assert lon == approx(lon_matlab, rel=0.001) 54 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_matlab_track2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Compare with Matlab Mapping toolbox reckon() 4 | """ 5 | 6 | from __future__ import annotations 7 | 8 | import pytest 9 | from pytest import approx 10 | 11 | try: 12 | import numpy as np 13 | from .matlab_engine import matlab_engine, has_mapping 14 | except ImportError: 15 | pytest.skip("Matlab Engine not found", allow_module_level=True) 16 | except RuntimeError: 17 | pytest.skip("Matlab Engine configuration error", allow_module_level=True) 18 | 19 | 20 | import pymap3d.vincenty 21 | 22 | 23 | def track2(eng, lat1: float, lon1: float, lat2: float, lon2: float, npts: int, deg: bool) -> tuple: 24 | """Using Matlab Engine to do same thing as Pymap3d""" 25 | d = "degrees" if deg else "radians" 26 | 27 | lats, lons = eng.track2( 28 | "gc", lat1, lon1, lat2, lon2, eng.wgs84Ellipsoid(), d, float(npts), nargout=2 29 | ) 30 | return np.array(lats).squeeze(), np.array(lons).squeeze() 31 | 32 | 33 | @pytest.mark.parametrize("deg", [True, False]) 34 | def test_track2_compare(deg): 35 | lat1, lon1 = 0.0, 80.0 36 | lat2, lon2 = 0.0, 81.0 37 | if not deg: 38 | lat1, lon1, lat2, lon2 = np.radians((lat1, lon1, lat2, lon2)) 39 | 40 | eng = matlab_engine() 41 | 42 | if not has_mapping(eng): 43 | pytest.skip("Matlab Toolbox not found") 44 | 45 | lats, lons = pymap3d.vincenty.track2(lat1, lon1, lat2, lon2, npts=4, deg=deg) 46 | 47 | lats_m, lons_m = track2(eng, lat1, lon1, lat2, lon2, npts=4, deg=deg) 48 | 49 | assert lats == approx(lats_m) 50 | assert lons == approx(lons_m) 51 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_matlab_vdist.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compare with Matlab Mapping Toolbox distance() 3 | """ 4 | 5 | from __future__ import annotations 6 | from math import nan 7 | 8 | import pytest 9 | from pytest import approx 10 | 11 | try: 12 | from .matlab_engine import matlab_engine, has_mapping, has_matmap3d 13 | except ImportError: 14 | pytest.skip("Matlab Engine not found", allow_module_level=True) 15 | except RuntimeError: 16 | pytest.skip("Matlab Engine configuration error", allow_module_level=True) 17 | 18 | 19 | from pymap3d.vincenty import vdist 20 | 21 | 22 | def distance(eng, matmap3d: bool, lat1, lon1, lat2, lon2) -> tuple[float, float]: 23 | """Using Matlab Engine to do same thing as Pymap3d""" 24 | 25 | if matmap3d: 26 | return eng.matmap3d.vdist(lat1, lon1, lat2, lon2, nargout=2) 27 | 28 | return eng.distance(lat1, lon1, lat2, lon2, eng.wgs84Ellipsoid(), nargout=2) 29 | 30 | 31 | @pytest.mark.parametrize("matmap3d", [False, True]) 32 | def test_matlab_stability(matmap3d): 33 | eng = matlab_engine() 34 | 35 | if matmap3d: 36 | if not has_matmap3d(eng): 37 | pytest.skip("Matmap3d not found") 38 | else: 39 | if not has_mapping(eng): 40 | pytest.skip("Matlab Toolbox not found") 41 | 42 | dlast, alast = nan, nan 43 | lon1, lon2 = 0.0, 1.0 44 | 45 | for i in range(20): 46 | lat1 = lat2 = 10.0 ** (-i) 47 | 48 | dist_m, az_deg = vdist(lat1, lon1, lat2, lon2) 49 | 50 | assert dist_m != dlast 51 | assert az_deg != alast 52 | dist_matlab, az_matlab = distance(eng, matmap3d, lat1, lon1, lat2, lon2) 53 | 54 | assert dist_m == approx(dist_matlab) 55 | assert az_deg == approx(az_matlab) 56 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_matlab_vreckon.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compare with Matlab Mapping Toolbox reckon() 3 | """ 4 | 5 | from __future__ import annotations 6 | from math import nan 7 | 8 | import pytest 9 | from pytest import approx 10 | 11 | try: 12 | from .matlab_engine import matlab_engine, has_mapping, has_matmap3d 13 | except ImportError: 14 | pytest.skip("Matlab Engine not found", allow_module_level=True) 15 | except RuntimeError: 16 | pytest.skip("Matlab Engine configuration error", allow_module_level=True) 17 | 18 | 19 | import pymap3d.vincenty 20 | 21 | 22 | def reckon( 23 | eng, matmap3d: bool, lat1: float, lon1: float, srng: float, az: float 24 | ) -> tuple[float, float]: 25 | """Using Matlab Engine to do same thing as Pymap3d""" 26 | 27 | if matmap3d: 28 | return eng.matmap3d.vreckon(lat1, lon1, srng, az, nargout=2) 29 | 30 | return eng.reckon("gc", lat1, lon1, srng, az, eng.wgs84Ellipsoid(), nargout=2) 31 | 32 | 33 | @pytest.mark.parametrize("matmap3d", [False, True]) 34 | def test_reckon_stability(matmap3d): 35 | eng = matlab_engine() 36 | 37 | if matmap3d: 38 | if not has_matmap3d(eng): 39 | pytest.skip("Matmap3d not found") 40 | else: 41 | if not has_mapping(eng): 42 | pytest.skip("Matlab Toolbox not found") 43 | 44 | dlast, alast = nan, nan 45 | lon1, lon2 = 0.0, 1.0 46 | for i in range(20): 47 | lat1 = lat2 = 10.0 ** (-i) 48 | 49 | dist_m, az_deg = pymap3d.vincenty.vreckon(lat1, lon1, lat2, lon2) 50 | 51 | assert dist_m != dlast 52 | assert az_deg != alast 53 | dist_matlab, az_matlab = reckon(eng, matmap3d, lat1, lon1, lat2, lon2) 54 | 55 | assert dist_m == approx(dist_matlab) 56 | 57 | assert az_deg == approx(az_matlab, rel=0.005) 58 | 59 | 60 | @pytest.mark.parametrize("matmap3d", [False, True]) 61 | def test_reckon_unit(matmap3d): 62 | """ 63 | Test various extrema and other values of interest 64 | """ 65 | 66 | eng = matlab_engine() 67 | 68 | if matmap3d: 69 | if not has_matmap3d(eng): 70 | pytest.skip("Matmap3d not found") 71 | else: 72 | if not has_mapping(eng): 73 | pytest.skip("Matlab Toolbox not found") 74 | 75 | latlon88 = 52.22610277777778, -1.2696583333333333 76 | srng88 = 839.63 77 | az88 = 63.02 78 | 79 | # issue 88 80 | lat_p, lon_p = pymap3d.vincenty.vreckon(*latlon88, srng88, az88) 81 | 82 | lat_m, lon_m = reckon(eng, matmap3d, *latlon88, srng88, az88) 83 | 84 | assert lat_p == approx(lat_m) 85 | assert lon_p == approx(lon_m) 86 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_ned.py: -------------------------------------------------------------------------------- 1 | import pymap3d as pm 2 | from pytest import approx 3 | 4 | lla0 = (42, -82, 200) 5 | aer0 = (33, 70, 1000) 6 | 7 | ELL = pm.Ellipsoid.from_name("wgs84") 8 | A = ELL.semimajor_axis 9 | B = ELL.semiminor_axis 10 | 11 | 12 | def test_ecef_ned(): 13 | enu = pm.aer2enu(*aer0) 14 | ned = (enu[1], enu[0], -enu[2]) 15 | xyz = pm.aer2ecef(*aer0, *lla0) 16 | 17 | ned1 = pm.ecef2ned(*xyz, *lla0) 18 | assert ned1 == approx(ned) 19 | 20 | assert pm.ned2ecef(*ned, *lla0) == approx(xyz) 21 | 22 | 23 | def test_enuv_nedv(): 24 | vx, vy, vz = (5, 3, 2) 25 | ve, vn, vu = (5.368859646588048, 3.008520763668120, -0.352347711524077) 26 | assert pm.ecef2enuv(vx, vy, vz, *lla0[:2]) == approx((ve, vn, vu)) 27 | assert pm.enu2ecefv(ve, vn, vu, *lla0[:2]) == approx((vx, vy, vz)) 28 | 29 | assert pm.ecef2nedv(vx, vy, vz, *lla0[:2]) == approx((vn, ve, -vu)) 30 | 31 | 32 | def test_ned_geodetic(): 33 | lla1 = pm.aer2geodetic(*aer0, *lla0) 34 | 35 | enu3 = pm.geodetic2enu(*lla1, *lla0) 36 | ned3 = (enu3[1], enu3[0], -enu3[2]) 37 | 38 | assert pm.geodetic2ned(*lla1, *lla0) == approx(ned3) 39 | 40 | lla2 = pm.enu2geodetic(*enu3, *lla0) 41 | assert lla2 == approx(lla1) 42 | assert all(isinstance(n, float) for n in lla2) 43 | 44 | lla2 = pm.ned2geodetic(*ned3, *lla0) 45 | assert lla2 == approx(lla1) 46 | assert all(isinstance(n, float) for n in lla2) 47 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_pyproj.py: -------------------------------------------------------------------------------- 1 | import pymap3d as pm 2 | import pytest 3 | from pymap3d.vincenty import vreckon 4 | from pytest import approx 5 | 6 | lla0 = [42, -82, 200] 7 | 8 | 9 | def test_compare_vicenty(): 10 | taz, tsr = 38, 3000 11 | pyproj = pytest.importorskip("pyproj") 12 | 13 | lat2, lon2 = vreckon(10, 20, tsr, taz) 14 | 15 | p4lon, p4lat, p4a21 = pyproj.Geod(ellps="WGS84").fwd(lon2, lat2, taz, tsr) 16 | assert p4lon == approx(lon2, rel=0.0025) 17 | assert p4lat == approx(lat2, rel=0.0025) 18 | 19 | p4az, p4a21, p4sr = pyproj.Geod(ellps="WGS84").inv(20, 10, lon2, lat2) 20 | assert (p4az, p4sr) == approx((taz, tsr)) 21 | 22 | 23 | def test_compare_geodetic(): 24 | pyproj = pytest.importorskip("pyproj") 25 | 26 | xyz = pm.geodetic2ecef(*lla0) 27 | 28 | ecef = pyproj.Proj(proj="geocent", ellps="WGS84", datum="WGS84") 29 | lla = pyproj.Proj(proj="latlong", ellps="WGS84", datum="WGS84") 30 | 31 | assert pyproj.transform(lla, ecef, lla0[1], lla0[0], lla0[2]) == approx(xyz) 32 | assert pyproj.transform(ecef, lla, *xyz) == approx((lla0[1], lla0[0], lla0[2])) 33 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_rcurve.py: -------------------------------------------------------------------------------- 1 | import pymap3d as pm 2 | import pytest 3 | from pytest import approx 4 | 5 | ell = pm.Ellipsoid.from_name("wgs84") 6 | A = ell.semimajor_axis 7 | 8 | 9 | @pytest.mark.parametrize( 10 | "lat,curvature", [(0, A), (90, 0), (-90, 0), (45.0, 4517590.87884893), (-45, 4517590.87884893)] 11 | ) 12 | def test_rcurve_parallel(lat, curvature): 13 | assert pm.parallel(lat) == approx(curvature, abs=1e-9, rel=1e-6) 14 | 15 | 16 | def test_numpy_parallel(): 17 | pytest.importorskip("numpy") 18 | assert pm.parallel([0, 90]) == approx([A, 0], abs=1e-9, rel=1e-6) 19 | 20 | 21 | @pytest.mark.parametrize( 22 | "lat,curvature", 23 | [ 24 | (0, 6335439.327), 25 | (90, 6399593.6258), 26 | (-90, 6399593.6258), 27 | (45.0, 6367381.8156), 28 | (-45, 6367381.8156), 29 | ], 30 | ) 31 | def test_rcurve_meridian(lat, curvature): 32 | assert pm.meridian(lat) == approx(curvature) 33 | 34 | 35 | def test_numpy_meridian(): 36 | pytest.importorskip("numpy") 37 | assert pm.meridian([0, 90]) == approx([6335439.327, 6399593.6258]) 38 | 39 | 40 | def test_numpy_transverse(): 41 | pytest.importorskip("numpy") 42 | assert pm.transverse([-90, 0, 90]) == approx([6399593.6258, A, 6399593.6258]) 43 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_rhumb.py: -------------------------------------------------------------------------------- 1 | import pymap3d.lox as lox 2 | import pytest 3 | from pytest import approx 4 | 5 | 6 | @pytest.mark.parametrize("lat,dist", [(0, 0), (90, 10001965.729)]) 7 | def test_meridian_dist(lat, dist): 8 | assert lox.meridian_dist(lat) == approx(dist) 9 | 10 | 11 | @pytest.mark.parametrize( 12 | "lat1,lat2,arclen", 13 | [ 14 | (0, 0, 0), 15 | (0, 90, 10001965.729), 16 | (0, -90, 10001965.729), 17 | (0, 40, 4429529.03035058), 18 | (40, 80, 4455610.84159), 19 | ], 20 | ) 21 | def test_meridian_arc(lat1, lat2, arclen): 22 | """ 23 | meridianarc(deg2rad(40), deg2rad(80), wgs84Ellipsoid) 24 | """ 25 | 26 | assert lox.meridian_arc(lat1, lat2) == approx(arclen) 27 | 28 | 29 | @pytest.mark.parametrize( 30 | "lon1,lon2,lat,dist", 31 | [ 32 | (0, 0, 0, 0), 33 | (0, 90, 0, 10018754.1714), 34 | (0, -90, 0, 10018754.1714), 35 | (90, 0, 0, 10018754.1714), 36 | (-90, 0, 0, 10018754.1714), 37 | ], 38 | ) 39 | def test_departure(lon1, lon2, lat, dist): 40 | assert lox.departure(lon1, lon2, lat) == approx(dist) 41 | 42 | 43 | @pytest.mark.parametrize( 44 | "lat1,lon1,lat2,lon2,arclen,az", 45 | [ 46 | (40, -80, 65, -148, 5248666.20853187, 302.0056736), 47 | (0, 0, 0, 1, 111319.49, 90), 48 | (0, 0, 0, -1, 111319.49, 270), 49 | (0, 1, 0, 0, 111319.49, 270), 50 | (0, -1, 0, 0, 111319.49, 90), 51 | (1, 0, 0, 0, 110574.4, 180), 52 | (-1, 0, 0, 0, 110574.4, 0), 53 | ], 54 | ) 55 | def test_loxodrome_inverse(lat1, lon1, lat2, lon2, arclen, az): 56 | """ 57 | distance('rh', 40, -80, 65, -148, wgs84Ellipsoid) 58 | azimuth('rh', 40, -80, 65, -148, wgs84Ellipsoid) 59 | """ 60 | rhdist, rhaz = lox.loxodrome_inverse(lat1, lon1, lat2, lon2) 61 | 62 | assert rhdist == approx(arclen) 63 | assert rhaz == approx(az) 64 | assert isinstance(rhdist, float) 65 | assert isinstance(rhaz, float) 66 | 67 | 68 | def test_numpy_loxodrome_inverse(): 69 | pytest.importorskip("numpy") 70 | d, a = lox.loxodrome_inverse([40, 40], [-80, -80], 65, -148) 71 | assert d == approx(5248666.209) 72 | assert a == approx(302.00567) 73 | 74 | d, a = lox.loxodrome_inverse([40, 40], [-80, -80], [65, 65], -148) 75 | d, a = lox.loxodrome_inverse([40, 40], [-80, -80], 65, [-148, -148]) 76 | 77 | 78 | def test_numpy_2d_loxodrome_inverse(): 79 | pytest.importorskip("numpy") 80 | d, a = lox.loxodrome_inverse([[40, 40], [40, 40]], [[-80, -80], [-80, -80]], 65, -148) 81 | assert d == approx(5248666.209) 82 | assert a == approx(302.00567) 83 | 84 | d, a = lox.loxodrome_inverse( 85 | [[40, 40], [40, 40]], [[-80, -80], [-80, -80]], [[65, 65], [65, 65]], -148 86 | ) 87 | d, a = lox.loxodrome_inverse( 88 | [[40, 40], [40, 40]], [[-80, -80], [-80, -80]], 65, [[-148, -148], [-148, -148]] 89 | ) 90 | d, a = lox.loxodrome_inverse(40, -80, [[65, 65], [65, 65]], [[-148, -148], [-148, -148]]) 91 | 92 | 93 | @pytest.mark.parametrize( 94 | "lat0,lon0,rng,az,lat1,lon1", 95 | [ 96 | (40, -80, 10000, 30, 40.0000779959676, -79.9999414477481), 97 | (35, 140, 50000, 90, 35, 140.548934481815), 98 | (35, 140, 50000, -270, 35, 140.548934481815), 99 | (35, 140, 50000, 270, 35, 139.451065518185), 100 | (35, 140, 50000, -90, 35, 139.451065518185), 101 | (0, 0, 0, 0, 0, 0), 102 | (0, 0, 10018754.17, 90, 0, 90), 103 | (0, 0, 10018754.17, -90, 0, -90), 104 | (0, 0, 110574.4, 180, -1, 0), 105 | (-1, 0, 110574.4, 0, 0, 0), 106 | ], 107 | ) 108 | def test_loxodrome_direct(lat0, lon0, rng, az, lat1, lon1): 109 | """ 110 | reckon('rh', 40, -80, 10, 30, wgs84Ellipsoid) 111 | """ 112 | lat2, lon2 = lox.loxodrome_direct(lat0, lon0, rng, az) 113 | assert lat2 == approx(lat1, rel=0.005, abs=1e-6) 114 | assert lon2 == approx(lon1, rel=0.001) 115 | assert isinstance(lat2, float) 116 | assert isinstance(lon2, float) 117 | 118 | 119 | def test_numpy_loxodrome_direct(): 120 | pytest.importorskip("numpy") 121 | lat, lon = lox.loxodrome_direct([40, 40], [-80, -80], [10, 10], [30, 30]) 122 | assert lat == approx(40.000078) 123 | assert lon == approx(-79.99994145) 124 | 125 | lat, lon = lox.loxodrome_direct([40, 40], [-80, -80], 10, 30) 126 | lat, lon = lox.loxodrome_direct([40, 40], [-80, -80], [10, 10], 30) 127 | lat, lon = lox.loxodrome_direct([40, 40], [-80, -80], 10, [30, 30]) 128 | 129 | 130 | @pytest.mark.parametrize("lat,lon", [([0, 45, 90], [0, 45, 90])]) 131 | def test_meanm(lat, lon): 132 | pytest.importorskip("numpy") 133 | assert lox.meanm(lat, lon) == approx([47.26967, 18.460557]) 134 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_rsphere.py: -------------------------------------------------------------------------------- 1 | import pymap3d as pm 2 | import pymap3d.rcurve as rcurve 3 | import pymap3d.rsphere as rsphere 4 | import pytest 5 | from pytest import approx 6 | 7 | ell = pm.Ellipsoid.from_name("wgs84") 8 | A = ell.semimajor_axis 9 | 10 | 11 | def test_geocentric_radius(): 12 | assert rcurve.geocentric_radius(0) == approx(ell.semimajor_axis) 13 | assert rcurve.geocentric_radius(90) == approx(ell.semiminor_axis) 14 | assert rcurve.geocentric_radius(45) == approx(6367490.0) 15 | assert rcurve.geocentric_radius(30) == approx(6372824.0) 16 | 17 | 18 | def test_rsphere_eqavol(): 19 | assert rsphere.eqavol() == approx(6371000.8049) 20 | 21 | 22 | def test_rsphere_authalic(): 23 | assert rsphere.authalic() == approx(6371007.1809) 24 | 25 | 26 | def test_rsphere_rectifying(): 27 | assert rsphere.rectifying() == approx(6367449.1458) 28 | 29 | 30 | def test_rsphere_biaxial(): 31 | assert rsphere.biaxial() == approx(6367444.657) 32 | 33 | 34 | def test_rsphere_triaxial(): 35 | assert rsphere.triaxial() == approx(6371008.77) 36 | 37 | 38 | def test_rsphere_euler(): 39 | assert rsphere.euler(42, 82, 44, 100) == approx(6386606.829131) 40 | 41 | 42 | def test_numpy_rsphere_euler(): 43 | pytest.importorskip("numpy") 44 | assert rsphere.euler([42, 0], [82, 0], 44, 100) == approx([6386606.829131, 6363111.70923164]) 45 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_sidereal.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from math import radians 3 | 4 | import pymap3d.haversine as pmh 5 | import pymap3d.sidereal as pmd 6 | import pytest 7 | from pytest import approx 8 | 9 | lon = -148 10 | t0 = datetime(2014, 4, 6, 8) 11 | sra = 2.90658 12 | ha = 45.482789587392013 13 | 14 | 15 | @pytest.mark.parametrize("time", [t0, [t0]]) 16 | def test_sidereal(time): 17 | # http://www.jgiesen.de/astro/astroJS/siderealClock/ 18 | tsr = pmd.datetime2sidereal(time, radians(lon)) 19 | if isinstance(tsr, list): 20 | tsr = tsr[0] 21 | assert tsr == approx(sra, rel=1e-5) 22 | assert isinstance(tsr, float) 23 | 24 | 25 | def test_sidereal_astropy(): 26 | pytest.importorskip("astropy") 27 | tsr = pmd.datetime2sidereal_astropy(t0, radians(lon)) 28 | assert tsr == approx(sra, rel=1e-5) 29 | assert isinstance(tsr, float) 30 | 31 | 32 | def test_sidereal_vallado(): 33 | tsr = pmd.datetime2sidereal_vallado(t0, radians(lon)) 34 | assert tsr == approx(sra, rel=1e-5) 35 | assert isinstance(tsr, float) 36 | 37 | 38 | def test_anglesep(): 39 | pytest.importorskip("astropy") 40 | assert pmh.anglesep(35, 23, 84, 20) == approx(ha) 41 | 42 | 43 | def test_anglesep_meeus(): 44 | # %% compare with astropy 45 | assert pmh.anglesep_meeus(35, 23, 84, 20) == approx(ha) 46 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_sky.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pymap3d as pm 4 | import pymap3d.vallado as pv 5 | import pytest 6 | from pytest import approx 7 | 8 | lat, lon = (65, -148) 9 | lla0 = (42, -82, 200) 10 | azel = (180.1, 80) 11 | t0 = datetime(2014, 4, 6, 8) 12 | radec = (166.5032081149338, 55.000011165405752) 13 | 14 | 15 | def test_azel2radec(): 16 | radec1 = pm.azel2radec(*azel, lat, lon, t0) 17 | assert radec1 == approx(radec, rel=0.01) 18 | assert isinstance(radec1[0], float) 19 | assert isinstance(radec1[1], float) 20 | 21 | 22 | def test_azel2radec_vallado(): 23 | radec1 = pv.azel2radec(*azel, lat, lon, t0) 24 | assert radec1 == approx(radec, rel=0.01) 25 | assert isinstance(radec1[0], float) 26 | assert isinstance(radec1[1], float) 27 | 28 | 29 | def test_numpy_azel2radec(): 30 | pytest.importorskip("numpy") 31 | radec1 = pm.azel2radec([180.1, 180.1], [80, 80], lat, lon, t0) 32 | assert radec1 == approx(radec, rel=0.01) 33 | 34 | 35 | def test_radec2azel(): 36 | azel1 = pm.radec2azel(*radec, lat, lon, t0) 37 | assert azel1 == approx(azel, rel=0.01) 38 | assert isinstance(azel1[0], float) 39 | assert isinstance(azel1[1], float) 40 | 41 | 42 | def test_radec2azel_vallado(): 43 | azel1 = pv.radec2azel(*radec, lat, lon, t0) 44 | assert azel1 == approx(azel, rel=0.01) 45 | assert isinstance(azel1[0], float) 46 | assert isinstance(azel1[1], float) 47 | 48 | 49 | def test_numpy_radec2azel(): 50 | pytest.importorskip("numpy") 51 | azel1 = pm.radec2azel([166.503208, 166.503208], [55, 55], lat, lon, t0) 52 | assert azel1 == approx(azel, rel=0.01) 53 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_spherical.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pytest import approx 3 | 4 | try: 5 | from numpy import asarray 6 | except ImportError: 7 | 8 | def asarray(*args): # type: ignore 9 | "dummy function to convert values to arrays" 10 | return args 11 | 12 | 13 | import pymap3d as pm 14 | 15 | ELL = pm.Ellipsoid.from_name("wgs84") 16 | A = ELL.semimajor_axis 17 | B = ELL.semiminor_axis 18 | 19 | llrlla = [ 20 | ((0, 0, A - 1), (0, 0, -1)), 21 | ((0, 90, A - 1), (0, 90, -1)), 22 | ((0, -90, A + 1), (0, -90, 1)), 23 | ((44.807576814237606, 270, 6367490.543857), (45, 270, 1)), 24 | ((90, 0, B + 1), (90, 0, 1)), 25 | ((90, 15, B - 1), (90, 15, -1)), 26 | ((-90, 0, B + 1), (-90, 0, 1)), 27 | ] 28 | llallr = [ 29 | ((0, 0, -1), (0, 0, A - 1)), 30 | ((0, 90, -1), (0, 90, A - 1)), 31 | ((0, -90, 1), (0, -90, A + 1)), 32 | ((45, 270, 1), (44.807576814237606, 270, 6367490.543857)), 33 | ((90, 0, 1), (90, 0, B + 1)), 34 | ((90, 15, -1), (90, 15, B - 1)), 35 | ((-90, 0, 1), (-90, 0, B + 1)), 36 | ] 37 | llallr_list = [([[i] for i in lla], llr) for lla, llr in llallr] 38 | llrlla_list = [([[i] for i in llr], lla) for llr, lla in llrlla] 39 | llallr_array = [([asarray(i) for i in lla], llr) for lla, llr in llallr] 40 | llrlla_array = [([asarray(i) for i in llr], lla) for llr, lla in llrlla] 41 | 42 | atol_dist = 1e-6 # 1 micrometer 43 | 44 | 45 | @pytest.mark.parametrize("lla, llr", llallr) 46 | def test_geodetic2spherical(lla, llr): 47 | coords = pm.geodetic2spherical(*lla) 48 | assert coords[:2] == approx(llr[:2]) 49 | assert coords[2] == approx(llr[2], abs=atol_dist) 50 | 51 | 52 | @pytest.mark.parametrize("llr, lla", llrlla) 53 | def test_spherical2geodetic(llr, lla): 54 | coords = pm.spherical2geodetic(*llr) 55 | assert coords[:2] == approx(lla[:2]) 56 | assert coords[2] == approx(lla[2], abs=atol_dist) 57 | 58 | 59 | @pytest.mark.parametrize("lla, llr", llallr_list) 60 | def test_geodetic2spherical_list(lla, llr): 61 | pytest.importorskip("numpy") 62 | coords = pm.geodetic2spherical(*lla) 63 | assert coords[:2] == approx(llr[:2]) 64 | assert coords[2] == approx(llr[2], abs=atol_dist) 65 | 66 | 67 | @pytest.mark.parametrize("llr, lla", llrlla_list) 68 | def test_spherical2geodetic_list(llr, lla): 69 | pytest.importorskip("numpy") 70 | coords = pm.spherical2geodetic(*llr) 71 | assert coords[:2] == approx(lla[:2]) 72 | assert coords[2] == approx(lla[2], abs=atol_dist) 73 | 74 | 75 | @pytest.mark.parametrize("lla, llr", llallr_array) 76 | def test_geodetic2spherical_array(lla, llr): 77 | pytest.importorskip("numpy") 78 | coords = pm.geodetic2spherical(*lla) 79 | assert coords[:2] == approx(llr[:2]) 80 | assert coords[2] == approx(llr[2], abs=atol_dist) 81 | 82 | 83 | @pytest.mark.parametrize("llr, lla", llrlla_array) 84 | def test_spherical2geodetic_array(llr, lla): 85 | pytest.importorskip("numpy") 86 | coords = pm.spherical2geodetic(*llr) 87 | assert coords[:2] == approx(lla[:2]) 88 | assert coords[2] == approx(lla[2], abs=atol_dist) 89 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_time.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pymap3d.sidereal as pms 4 | import pytest 5 | from pymap3d.timeconv import str2dt 6 | from pytest import approx 7 | 8 | t0 = datetime(2014, 4, 6, 8) 9 | 10 | 11 | def test_juliantime(): 12 | assert pms.juliandate(t0) == approx(2.456753833333e6) 13 | 14 | 15 | def test_types(): 16 | np = pytest.importorskip("numpy") 17 | assert str2dt(t0) == t0 # passthrough 18 | assert str2dt("2014-04-06T08:00:00") == t0 19 | ti = [str2dt("2014-04-06T08:00:00"), str2dt("2014-04-06T08:01:02")] 20 | to = [t0, datetime(2014, 4, 6, 8, 1, 2)] 21 | assert ti == to # even though ti is numpy array of datetime and to is list of datetime 22 | 23 | t1 = [t0, t0] 24 | assert (np.asarray(str2dt(t1)) == t0).all() 25 | 26 | 27 | def test_datetime64(): 28 | np = pytest.importorskip("numpy") 29 | t1 = np.datetime64(t0) 30 | assert str2dt(t1) == t0 31 | 32 | t1 = np.array([np.datetime64(t0), np.datetime64(t0)]) 33 | assert (str2dt(t1) == t0).all() 34 | 35 | 36 | def test_xarray_time(): 37 | xarray = pytest.importorskip("xarray") 38 | 39 | t = {"time": t0} 40 | ds = xarray.Dataset(t) 41 | assert str2dt(ds["time"]) == t0 42 | 43 | t2 = {"time": [t0, t0]} 44 | ds = xarray.Dataset(t2) 45 | assert (str2dt(ds["time"]) == t0).all() 46 | 47 | 48 | def test_pandas_time(): 49 | pandas = pytest.importorskip("pandas") 50 | 51 | t = pandas.Series(t0) 52 | assert (str2dt(t) == t0).all() 53 | 54 | t = pandas.Series([t0, t0]) 55 | assert (str2dt(t) == t0).all() 56 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_vincenty.py: -------------------------------------------------------------------------------- 1 | import pymap3d.vincenty as vincenty 2 | 3 | import pytest 4 | from pytest import approx 5 | 6 | 7 | @pytest.mark.parametrize("deg", [True, False]) 8 | def test_track2_unit(deg): 9 | np = pytest.importorskip("numpy") 10 | 11 | lat1, lon1 = 0.0, 80.0 12 | lat2, lon2 = 0.0, 81.0 13 | lat0 = [0.0, 0.0, 0.0, 0.0] 14 | lon0 = [80.0, 80.33333, 80.66666, 81.0] 15 | if not deg: 16 | lat1, lon1, lat2, lon2 = np.radians((lat1, lon1, lat2, lon2)) 17 | lat0 = np.radians(lat0) 18 | lon0 = np.radians(lon0) 19 | 20 | lats, lons = vincenty.track2(lat1, lon1, lat2, lon2, npts=4, deg=deg) 21 | 22 | assert lats == approx(lat0) 23 | assert lons == approx(lon0) 24 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_vincenty_dist.py: -------------------------------------------------------------------------------- 1 | import pymap3d.vincenty as vincenty 2 | import pytest 3 | from pytest import approx 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "lat,lon,lat1,lon1,srange,az", 8 | [ 9 | (0, 0, 0, 0, 0, 0), 10 | (0, 0, 0, 90, 1.001875e7, 90), 11 | (0, 0, 0, -90, 1.001875e7, 270), 12 | (0, 0, 0, 180, 2.00375e7, 90), 13 | (0, 0, 0, -180, 2.00375e7, 90), 14 | (0, 0, 0, 4, 445277.96, 90), 15 | (0, 0, 0, 5, 556597.45, 90), 16 | (0, 0, 0, 6, 667916.94, 90), 17 | (0, 0, 0, -6, 667916.94, 270), 18 | (0, 0, 0, 7, 779236.44, 90), 19 | (1e-16, 1e-16, 1e-16, 1, 111319.49, 90), 20 | (90, 0, 0, 0, 1.00019657e7, 180), 21 | (90, 0, -90, 0, 2.000393145e7, 180), 22 | ], 23 | ) 24 | def test_unit_vdist(lat, lon, lat1, lon1, srange, az): 25 | dist, az1 = vincenty.vdist(lat, lon, lat1, lon1) 26 | assert dist == approx(srange, rel=0.005) 27 | assert az1 == approx(az) 28 | 29 | assert isinstance(dist, float) 30 | assert isinstance(az1, float) 31 | 32 | 33 | def test_vector(): 34 | pytest.importorskip("numpy") 35 | asr, aaz = vincenty.vdist(10, 20, [10.02137267, 10.01917819], [20.0168471, 20.0193493]) 36 | 37 | assert 3e3 == approx(asr) 38 | assert aaz == approx([38, 45]) 39 | 40 | 41 | @pytest.mark.parametrize("lat,lon,slantrange,az", [(10, 20, 3e3, 38), (0, 0, 0, 0)]) 42 | def test_identity(lat, lon, slantrange, az): 43 | lat1, lon1 = vincenty.vreckon(lat, lon, slantrange, az) 44 | 45 | dist, az1 = vincenty.vdist(lat, lon, lat1, lon1) 46 | 47 | assert dist == approx(slantrange) 48 | assert az1 == approx(az) 49 | -------------------------------------------------------------------------------- /src/pymap3d/tests/test_vincenty_vreckon.py: -------------------------------------------------------------------------------- 1 | from math import radians 2 | 3 | import pymap3d.vincenty as vincenty 4 | 5 | import pytest 6 | from pytest import approx 7 | 8 | ll0 = [10, 20] 9 | lat2 = [10.02137267, 10.01917819] 10 | lon2 = [20.0168471, 20.0193493] 11 | az2 = [218.00292856, 225.00336316] 12 | 13 | sr1 = [3e3, 1e3] 14 | az1 = [38, 45] 15 | lat3 = (10.02137267, 10.00639286) 16 | lon3 = (20.0168471, 20.00644951) 17 | az3 = (218.00292856, 225.0011203) 18 | 19 | 20 | @pytest.mark.parametrize("deg", [True, False]) 21 | @pytest.mark.parametrize( 22 | "lat,lon,srange,az,lato,lono", 23 | [ 24 | (0, 0, 0, 0, 0, 0), 25 | (0, 0, 1.001875e7, 90, 0, 90), 26 | (0, 0, 1.001875e7, 270, 0, 270), 27 | (0, 0, 1.001875e7, -90, 0, 270), 28 | (0, 0, 2.00375e7, 90, 0, 180), 29 | (0, 0, 2.00375e7, 270, 0, 180), 30 | (0, 0, 2.00375e7, -90, 0, 180), 31 | ], 32 | ) 33 | def test_vreckon_unit(deg, lat, lon, srange, az, lato, lono): 34 | if not deg: 35 | lat, lon, az, lato, lono = map(radians, (lat, lon, az, lato, lono)) 36 | 37 | lat1, lon1 = vincenty.vreckon(lat, lon, srange, az, deg=deg) 38 | 39 | assert lat1 == approx(lato) 40 | assert isinstance(lat1, float) 41 | 42 | assert lon1 == approx(lono, rel=0.001) 43 | assert isinstance(lon1, float) 44 | 45 | 46 | def test_az_vector(): 47 | pytest.importorskip("numpy") 48 | a, b = vincenty.vreckon(*ll0, sr1[0], az1) 49 | assert a == approx(lat2) 50 | assert b == approx(lon2) 51 | 52 | 53 | def test_both_vector(): 54 | pytest.importorskip("numpy") 55 | a, b = vincenty.vreckon(10, 20, sr1, az1) 56 | assert a == approx(lat3) 57 | assert b == approx(lon3) 58 | -------------------------------------------------------------------------------- /src/pymap3d/timeconv.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2014-2018 Michael Hirsch, Ph.D. 2 | """ convert strings to datetime """ 3 | 4 | from __future__ import annotations 5 | 6 | from datetime import datetime 7 | 8 | try: 9 | import dateutil.parser 10 | except ImportError: 11 | pass 12 | 13 | __all__ = ["str2dt"] 14 | 15 | 16 | def str2dt(time: str | datetime) -> datetime: 17 | """ 18 | Converts times in string or list of strings to datetime(s) 19 | 20 | Parameters 21 | ---------- 22 | 23 | time : str or datetime.datetime or numpy.datetime64 24 | 25 | Results 26 | ------- 27 | 28 | t : datetime.datetime 29 | 30 | """ 31 | if isinstance(time, datetime): 32 | return time 33 | elif isinstance(time, str): 34 | try: 35 | return dateutil.parser.parse(time) 36 | except NameError: 37 | raise ImportError("pip install python-dateutil") 38 | 39 | # some sort of iterable 40 | try: 41 | if isinstance(time[0], datetime): 42 | return time 43 | elif isinstance(time[0], str): 44 | return [dateutil.parser.parse(t) for t in time] 45 | except IndexError: 46 | pass 47 | except NameError: 48 | raise ImportError("pip install python-dateutil") 49 | 50 | # pandas/xarray 51 | try: 52 | return time.values.astype("datetime64[us]").astype(datetime) 53 | except AttributeError: 54 | pass 55 | 56 | # Numpy.datetime64 57 | try: 58 | return time.astype(datetime) 59 | except AttributeError: 60 | pass 61 | 62 | return time 63 | -------------------------------------------------------------------------------- /src/pymap3d/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions 2 | 3 | all assume radians""" 4 | 5 | from __future__ import annotations 6 | 7 | from .mathfun import atan2, cos, hypot, sin 8 | 9 | __all__ = ["cart2pol", "pol2cart", "cart2sph", "sph2cart"] 10 | 11 | 12 | def cart2pol(x, y) -> tuple: 13 | """Transform Cartesian to polar coordinates""" 14 | return atan2(y, x), hypot(x, y) 15 | 16 | 17 | def pol2cart(theta, rho) -> tuple: 18 | """Transform polar to Cartesian coordinates""" 19 | return rho * cos(theta), rho * sin(theta) 20 | 21 | 22 | def cart2sph(x, y, z) -> tuple: 23 | """Transform Cartesian to spherical coordinates""" 24 | hxy = hypot(x, y) 25 | r = hypot(hxy, z) 26 | el = atan2(z, hxy) 27 | az = atan2(y, x) 28 | return az, el, r 29 | 30 | 31 | def sph2cart(az, el, r) -> tuple: 32 | """Transform spherical to Cartesian coordinates""" 33 | rcos_theta = r * cos(el) 34 | x = rcos_theta * cos(az) 35 | y = rcos_theta * sin(az) 36 | z = r * sin(el) 37 | return x, y, z 38 | -------------------------------------------------------------------------------- /src/pymap3d/vallado.py: -------------------------------------------------------------------------------- 1 | """ 2 | converts right ascension, declination to azimuth, elevation and vice versa. 3 | Normally do this via AstroPy. 4 | These functions are fallbacks for those without AstroPy. 5 | 6 | Michael Hirsch implementation of algorithms from D. Vallado 7 | """ 8 | 9 | from __future__ import annotations 10 | 11 | from datetime import datetime 12 | 13 | from .mathfun import asin, atan2, cos, degrees, radians, sin 14 | from .sidereal import datetime2sidereal 15 | 16 | __all__ = ["azel2radec", "radec2azel"] 17 | 18 | 19 | def azel2radec( 20 | az_deg: float, 21 | el_deg: float, 22 | lat_deg: float, 23 | lon_deg: float, 24 | time: datetime, 25 | ) -> tuple[float, float]: 26 | """ 27 | converts azimuth, elevation to right ascension, declination 28 | 29 | Parameters 30 | ---------- 31 | 32 | az_deg : float 33 | azimuth (clockwise) to point [degrees] 34 | el_deg : float 35 | elevation above horizon to point [degrees] 36 | lat_deg : float 37 | observer WGS84 latitude [degrees] 38 | lon_deg : float 39 | observer WGS84 longitude [degrees] 40 | time : datetime.datetime 41 | time of observation 42 | 43 | 44 | Results 45 | ------- 46 | 47 | ra_deg : float 48 | right ascension to target [degrees] 49 | dec_deg : float 50 | declination of target [degrees] 51 | 52 | from D.Vallado Fundamentals of Astrodynamics and Applications 53 | p.258-259 54 | """ 55 | 56 | if abs(lat_deg) > 90: 57 | raise ValueError("-90 <= lat <= 90") 58 | 59 | az = radians(az_deg) 60 | el = radians(el_deg) 61 | lat = radians(lat_deg) 62 | lon = radians(lon_deg) 63 | # %% Vallado "algorithm 28" p 268 64 | dec = asin(sin(el) * sin(lat) + cos(el) * cos(lat) * cos(az)) 65 | 66 | lha = atan2( 67 | -(sin(az) * cos(el)) / cos(dec), (sin(el) - sin(lat) * sin(dec)) / (cos(dec) * cos(lat)) 68 | ) 69 | 70 | lst = datetime2sidereal(time, lon) # lon, ra in RADIANS 71 | 72 | """ by definition right ascension [0, 360) degrees """ 73 | return degrees(lst - lha) % 360, degrees(dec) 74 | 75 | 76 | def radec2azel( 77 | ra_deg: float, 78 | dec_deg: float, 79 | lat_deg: float, 80 | lon_deg: float, 81 | time: datetime, 82 | ) -> tuple[float, float]: 83 | """ 84 | converts right ascension, declination to azimuth, elevation 85 | 86 | Parameters 87 | ---------- 88 | 89 | ra_deg : float 90 | right ascension to target [degrees] 91 | dec_deg : float 92 | declination to target [degrees] 93 | lat_deg : float 94 | observer WGS84 latitude [degrees] 95 | lon_deg : float 96 | observer WGS84 longitude [degrees] 97 | time : datetime.datetime 98 | time of observation 99 | 100 | Results 101 | ------- 102 | 103 | az_deg : float 104 | azimuth clockwise from north to point [degrees] 105 | el_deg : float 106 | elevation above horizon to point [degrees] 107 | 108 | 109 | from D. Vallado "Fundamentals of Astrodynamics and Applications " 110 | 4th Edition Ch. 4.4 pg. 266-268 111 | """ 112 | if abs(lat_deg) > 90: 113 | raise ValueError("-90 <= lat <= 90") 114 | 115 | ra = radians(ra_deg) 116 | dec = radians(dec_deg) 117 | lat = radians(lat_deg) 118 | lon = radians(lon_deg) 119 | 120 | lst = datetime2sidereal(time, lon) # RADIANS 121 | # %% Eq. 4-11 p. 267 LOCAL HOUR ANGLE 122 | lha = lst - ra 123 | # %% #Eq. 4-12 p. 267 124 | el = asin(sin(lat) * sin(dec) + cos(lat) * cos(dec) * cos(lha)) 125 | # %% combine Eq. 4-13 and 4-14 p. 268 126 | az = atan2( 127 | -sin(lha) * cos(dec) / cos(el), (sin(dec) - sin(el) * sin(lat)) / (cos(el) * cos(lat)) 128 | ) 129 | 130 | return degrees(az) % 360.0, degrees(el) 131 | --------------------------------------------------------------------------------