What we already know:
Earth is locally flat.
This is true not only for the (surface of) the Earth, but for any so-called Riemannian manifold, a generalization of surfaces to any number of dimensions. Even though there is curvature, if one zooms enough into a point, curvature disappears and their neighborhood will look flat. This is why some individuals in our planet, with a very literal narrow view, think the world is flat: they extrapolate local apparent flatness to the whole surface of the Earth.
For every point in a manifold, there is a special coordinate system called Riemann normal coordinates. These coordinates translate the fact above: they represent a space that is flat — in the sense that distances are measured via the Pythagorean theorem — at least in a neighborhood of that point.
To be concrete, suppose we have Riemann normal coordinates centered on a point . If we have two points with Riemann normal coordinates and , then their distance will be very well approximated by
This is simply not true in other coordinate systems. As a counterexample: let us consider that and have latitudes/longitudes and . If is the Earth’s radius, and we consider the Earth to be a sphere, then the exact distance between the two points is given by the famous haversine formula:
This is evidently nothing like the Pythagorean theorem.
Very well: how do we build Riemann normal coordinates? Building them is, in fact, a very interesting exercise from a mathematical perspective — hence we will do it below.
For data science: distances and neighbors
Having a localized coordinate system is useful to data science problems: we commonly deal with geospatial data, where one would like to use nearest-neighbor methods. Using the Haversine formula of course works, but is slow; it would be nice if we could just use Euclidean distances.
Since there is nothing new under the Sun, such a coordinate system already exists: it is the azimuthal equidistant projection in cartography, which might date back to ancient Egyptians. It is a projection used for maps, as are other more famous ones such as the Peter’s projection or the Mercator projection.
The azimuthal equidistant projection requires a central point. See below an example centered in Denver, Colorado (from matplotlib):

Clearly, as we get further from the center of the coordinate system, we get more distortion. Some observations:
- The distances from any point to the center are exact;
- The distance between any two points can be approximated by the Pythagorean theorem; however, this approximation gets worse the further they are from each other. In the map above, for example, it seems calculating distances within North America works well, but I wouldn’t use this map beyond that.
Goal
Clearly, normal coordinates look nice. Two questions naturally arise:
- Are they strictly necessary?
- Are they much better than simpler coordinates?
Our main competing system is that given by “naive” projection of a point on the sphere to a plane tangent to the sphere at some central point.
Our goal here is then threefold:
- Build normal coordinates (also providing a nice Python implementation)
- Compare to (conceptually / computationally) simpler coordinate systems
- Provide a recommendation of what coordinates to use.
It is important to notice that our analysis is very Brazil-centric; however, it is fully generalizable to other geographies.
TL;DR: for Brazil, we don’t need to go as far as building normal coordinates — direct projection of points onto the plane tangent to latitude -10.80677, longitude -53.05434 is enough. For a point of latitude and longitude this becomes
See the Conclusion for general results.
Setup
In all that follows, we consider a sphere of unit radius (which we can do with no loss of generality; we need only multiply all results by a physical radius later if necessary).
I employ the physicists’ convention for spherical coordinates, where denotes the longitude and is sometimes called the colatitude:
where we have used the atan2 function.
These notations vary wildly across literature, so be careful to adapt to your convention if necessary.
import numpy as np
from scipy.linalg import norm
def vector_from_spherical_coords(theta, phi):
"""Calculate Cartesian coordinates from longitude phi and colatitude theta"""
return np.array([np.sin(theta) * np.cos(phi),
np.sin(theta) * np.sin(phi),
np.cos(theta)])
Exponential maps: approximating surfaces by tangent planes
As a former student of General Relativity, I am a big fan of differential geometry, the area of mathematics behind all the concepts we are discussing. I will leave the technical derivation of the exponential map (and its inverse) to the Appendix, but will explain their main idea here.

In the figure above, we have a manifold (which you can think of as a smooth curved surface) and a point on it. We build a tangent space (here, just a tangent plane) to at , called , and ask the question: close to , is there a 1-1 way for us to match points in the tangent space with points on the manifold?
Tangent spaces naturally have a vector space structure: in particular, this means we can measure distances in them with the Pythagorean theorem. Thus, if we can find a smart way to connect points on the surface with points in the tangent space, we can use a simpler geometry to perform calculations.
The exponential map provides a way to do exactly that: map any point in the tangent space to the manifold in a 1-1 manner.
First, don’t let the name “exponential” fool you: unless you constantly deal with Lie groups in your daily life, this map has nothing to do with taking exponentials of numbers.
The exponential map goes one step further: distances between the central point and points in the manifold are preserved under this map.
To better illustrate these concepts, I actually prefer to start not from the exponential map, but its inverse, which is more intuitive in the context of world maps.
Start with a manifold (the sphere, in our case) and consider two points, and . will be chosen as the center. The points have a distance between them, measured on the surface of the sphere.

The inverse exponential map provides as way to find an equivalent point to in the tangent space, such that its distance to the origin is the same as ‘s distance to !

The exponential map is just the inverse: it takes points in the tangent space and projects them onto the surface, preserving distances.
This is why I would rather call it the “proportional shadow” map: it projects the tangent space onto the manifold (like a shadow), but respecting distances from the origin.
The (inverse) exponential map for a sphere
We show in the Appendix how exponential maps are defined in general, and derive the expressions for the sphere. Here, we lay out the results.
Any point on the unit sphere can be associated with a vector , satisfying . Let be another point, with a related vector . Then, the inverse exponential map, which we denote as , is given by
This formula looks complex, so let us break this down into parts:
- The first term is just a cumbersome way to write the distance between the two points, as measured on the sphere’s surface;
- is the component of which is orthogonal to . We divide it by its norm so that it is a unit vector.
In other words, this gets the component of perpendicular to and scales it so that it has the right magnitude, which is exactly the distance between the points. Schematically,
Natural coordinates around a point on Earth’s surface
Assume we used the inverse exponential map to find . What do we do next?
This is a vector in the tangent space . We would like to find a nice basis to write it in. A nice choice are local tangent plane coordinates, in particular east, north, up (ENU) coordinates, a right-handed system where the basis vectors point east, north, and towards the sky from a given reference point.

We can easily construct this coordinate system from some analytical geometry. We let be the unit vector pointing from the center of the Earth towards the North Pole, ie. the “z-direction”.
From our reference point and , we can build the ENU basis as follows. Define
where the last equality follows from using the triple product. Then are, by construction, an orthonormal basis for the whole space.
You might want to convince yourself that points eastward, and that points northward.
Any vector in the tangent space will then have zero component along the direction, and “eastward” and “northward” components
These are our Riemann normal coordinates, oriented so that our axis is nicely parallel to lines of constant latitude, and so that is aligned to lines of constant longitude.
Let us summarize the steps so far:
From points on the sphere to Riemann normal coordinates:
Inputs: a reference point and any point on the unit sphere
- Calculate the inverse exponential map of via
- Construct the basis vectors
- Calculate , ;
- Return
There is actually a nice analytical outcome of all of this: these two formulas derived in Appendix II. If we write the 3D components of and as
then
where
Here, is the arc-cosine of the distance between the two points; note that, if one takes the limit we have .
These are equivalent to the formulas given in this Wikipedia article and are derived in the Appendices.
Example application

Let us choose coordinates centered in São Paulo, Brazil (red dot in the map above). It has coordinates 23.5558° S, 46.6396° W, which in our coordinates become
def theta_phi_from_angular_lat_lon(lat_angle, lon_angle):
factor = np.pi/180.0
theta = (90-lat_angle)*factor
phi = lon_angle * factor
return theta, phi
p = vector_from_spherical_coords(*theta_phi_from_angular_lat_lon(-23.5558, -46.6396))
print("Spherical coordinates of Sao Paulo:", p.round(3))
Spherical coordinates of Sao Paulo: [ 0.629 -0.666 -0.4 ]
Let us build the local basis . We follow the protocol established above:
k = np.array([0,0,1])
## x_hat calculation
x = np.cross(k, p)
x = x/norm(x)
print("x_hat basis vector:", x.round(3))
x_hat basis vector: [ 0.727 0.687 -0. ]
# y_hat calculation
y = np.cross(p, x)
print("y_hat basis vector:", y.round(3))
y_hat basis vector: [ 0.274 -0.291 0.917]
Below, we will write these inside a function that wraps everything together.
Getting coordinates on the tangent space for any point on the sphere
Let us study the coordinates of two cities:
- São Luís, located at 2.5307° S, 44.2989° W
- Rio Branco, located at 9.9740° S, 67.8098° W
q_SL = vector_from_spherical_coords(*theta_phi_from_angular_lat_lon(-2.5307, -44.2989))
q_RB = vector_from_spherical_coords(*theta_phi_from_angular_lat_lon(-9.9740, -67.8098))
def local_tangent_plane_coordinates(p):
"""
p: three-dimensional array containing the spherical coordinates of the reference point
"""
import numpy as np
from scipy.linalg import norm
k = np.array([0,0,1])
x = np.cross(k, p)
x = x/norm(x)
y = np.cross(p, x)
def normal_coordinates(q):
pq = np.dot(p, q)
proj = q - pq * p
q_ = np.arccos(pq) * proj/norm(proj)
return np.array([q_@x, q_@y])
return normal_coordinates
Let us calculate their normal coordinates, and then compare the result to that we would have obtained by naive projection onto the tangent plane.
São Luís:
normal_coords = local_tangent_plane_coordinates(p)
q_SL_normal_coords = normal_coords(q_SL)
print("Sao Luis normal coordinates:")
print(q_SL_normal_coords.round(4))
print("Sao Luis vector projected onto tangent plane coordinates:")
print([round(q_SL@x,4), round(q_SL@y,4)])
Sao Luis normal coordinates:
[0.0417 0.3667]
Sao Luis vector projected onto tangent plane coordinates:
[0.0408, 0.3584]
They are definitely close, but there is already a perceivable difference in the coordinate values; since a sphere has positive curvature, the true (great circle) distance is greater than the straight line distance. In particular, one would expect components of normal coordinates to be larger than their “flat” counterparts, and we see this phenomenon here.
Rio Branco:
q_RB_normal_coords = normal_coords(q_RB)
print("Rio Branco normal coordinates:")
print(q_RB_normal_coords.round(4))
print("Rio Branco vector projected onto tangent plane coordinates:")
print([round(q_RB@x,4), round(q_RB@y,4)])
Rio Branco normal coordinates:
[-0.3666 0.2147]
Rio Branco vector projected onto tangent plane coordinates:
[-0.3557, 0.2083]
Qualitatively, this result is in line with the previous one.
Comparing distances
Let us use these vectors to calculate distances and see how normal coordinates are superior on that aspect.
We can obtain the great circle distance between the two points as:
Of course, we should multiply by the Earth’s radius afterwards. We are ignoring that here.
def true_distance(p, q):
return np.arccos(p@q)
Distance of São Paulo to either city:
Since São Paulo is the reference point for our normal coordinates, its distance to them in normal coordinates, calculated via the Pythagorean theorem, should be exact.
d_true = true_distance(p, q_SL)
d_approx = norm(q_SL_normal_coords) # this calculates qx^2 + qy^2
d_naive = norm([q_SL@x, q_SL@y])
print("True distance Sao Paulo - Sao Luis :", round(d_true,4))
print("Approximate distance Sao Paulo - Sao Luis :", round(d_approx,4))
print("Naive Cartesian distance Sao Paulo - Sao Luis:", round(d_naive,4))
True distance Sao Paulo - Sao Luis : 0.3691
Approximate distance Sao Paulo - Sao Luis : 0.3691
Naive Cartesian distance Sao Paulo - Sao Luis: 0.3608
d_true = true_distance(p, q_RB)
d_approx = norm(q_RB_normal_coords) # this calculates qx^2 + qy^2
d_naive = norm([q_RB@x, q_RB@y])
print("True distance Sao Paulo - Sao Luis :", round(d_true,4))
print("Approximate distance Sao Paulo - Sao Luis :", round(d_approx,4))
print("Naive Cartesian distance Sao Paulo - Sao Luis:", round(d_naive,4))
True distance Sao Paulo - Sao Luis : 0.4248
Approximate distance Sao Paulo - Sao Luis : 0.4248
Naive Cartesian distance Sao Paulo - Sao Luis: 0.4122
In both cases, precise equality.
Distance between São Luís and Rio Branco
d_true = true_distance(q_RB, q_SL)
d_approx = norm(q_SL_normal_coords - q_RB_normal_coords)
d_naive = norm([(q_RB-q_SL)@x, (q_RB-q_SL)@y])
print("True distance Rio Branco - Sao Luis :", round(d_true,4))
print("Approximate distance Rio Branco - Sao Luis :", round(d_approx,4))
print("Naive Cartesian distance Rio Branco - Sao Luis:", round(d_naive,4))
True distance Rio Branco - Sao Luis : 0.4278
Approximate distance Rio Branco - Sao Luis : 0.4357
Naive Cartesian distance Rio Branco - Sao Luis: 0.424
Oops. In this particular case, the naive approximation is actually better!
This happens since São Paulo, the center of our coordinates, is actually pretty far from those cities (it’s a bit to the Southeast). It also show that the “naive” method can still be pretty good.
Comparison of naive projection & exponential methods over Brazil
We want to compare two 2D vectors: the one obtained from the tangent plane coordinates of , and the other from itself. More precisely, we want to compare them as vectors in the tangent space,
Both are parallel and differ by a factor of as defined above. The naive projection has components
and the inverse exponential map is .
Below, we plot both coordinates for Brazil, using GeoPandas:
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
from shapely.geometry import Point
from itertools import product
# exctract brazil shape data
world_filepath = gpd.datasets.get_path('naturalearth_lowres')
world = gpd.read_file(world_filepath)
brazil = world.loc[world['name'] == 'Brazil']
# build a dataframe containing N points inside Brazil
N = 200
shape = brazil['geometry'].iloc[0]
xmin, xmax, ymin, ymax = shape.bounds
pointdf = gpd.GeoDataFrame([{"geometry": Point(x, y)} for x, y in product(
np.arange(xmin, xmax, (xmax - xmin) / N),
np.arange(ymin, ymax, (ymax - ymin) / N))])
pointdf.crs = brazil.crs
pointdf = pointdf.sjoin(brazil, how="inner")
pointdf['geometry'].plot(marker='.')
print(f"{pointdf.shape[0]} points inside Brazil")
17683 points inside Brazil

We have then almost 18000 points to work with.
The function below builds exponential map & naive projection coordinates centered on different cities: São Paulo, Rio Branco, Brasilia and Palmas:
def compare_projection_and_exponential_coords(p, pointdf, ax):
import warnings
warnings.filterwarnings('ignore', category=UserWarning, append=True)
normal_coords = local_tangent_plane_coordinates(p)
# also building local tangent plane basis for later comparison
k = np.array([0,0,1])
x = np.cross(k, p)
x = x/norm(x)
y = np.cross(p, x)
coords = pd.DataFrame({'lat': pointdf['geometry'].apply(lambda aux: aux.y),
'lon': pointdf['geometry'].apply(lambda aux: aux.x)})
qs = coords.apply(lambda row: vector_from_spherical_coords(np.pi/180 * (90-row[0]), np.pi/180 * row[1]), axis=1)
qs = pd.DataFrame(qs.values.tolist(), columns=['x', 'y ', 'z'])
# Projection coordinates
naive_coords = pd.DataFrame({'x': qs@x, 'y': qs@y})
naive_coords.plot.scatter(x='x', y='y', ax=ax, s=0.5)
# Normal coordinates
normal_coords_brazil = pd.DataFrame(qs.apply(normal_coords, axis=1).values.tolist(), columns=['x', 'y'])
normal_coords_brazil.plot.scatter(x='x', y='y', ax=ax, s=0.5, color='orange', alpha=0.6)
ax.set_aspect('equal')
ax.scatter([0],[0], color= 'black')
ax.legend(['Projection', 'Normal coordinates'])
fig, ax = plt.subplots(ncols=2, nrows=2, figsize=(10,10))
ax = ax.flatten()
ax[0].set_title('Rio Branco')
p = vector_from_spherical_coords(*theta_phi_from_angular_lat_lon(-9.9740, -67.8098)) # Rio Branco
compare_projection_and_exponential_coords(p, pointdf, ax[0])
ax[1].set_title('Sao Paulo')
p = vector_from_spherical_coords(*theta_phi_from_angular_lat_lon(-23.5558, -46.6396)) # Sao Paulo
compare_projection_and_exponential_coords(p, pointdf, ax[1])
ax[2].set_title("Brasilia")
p = vector_from_spherical_coords(*theta_phi_from_angular_lat_lon(-15.7975, -47.8919)) # Brasilia
compare_projection_and_exponential_coords(p, pointdf, ax[2])
ax[3].set_title("Palmas")
p = vector_from_spherical_coords(*theta_phi_from_angular_lat_lon(-10.2491, -48.3243)) # Palmas
compare_projection_and_exponential_coords(p, pointdf, ax[3])
plt.show()

We see that, as we get closer to Brazil’s center of mass — aka the centroid — the projection and the exponential map get closer to each other.
We can, in fact, extract the centroid coordinates directly from GeoPandas:
print("Lat, lon:", brazil['geometry'].centroid.iloc[0])
Lat, lon: POINT (-53.05434003576711 -10.806773643498916)
Fun fact: this is the municipality of São José do Xingu, in Mato Grosso.
Plotting coordinates centered on this guy:
fig, ax = plt.subplots(figsize=(5,5))
p = vector_from_spherical_coords(*theta_phi_from_angular_lat_lon(-10.80677, -53.05434)) # Brazil's centroid
compare_projection_and_exponential_coords(p, pointdf, ax)
plt.show()

We see that using coordinates centered on Brazil’s centroid essentially renders the exponential map unnecessary. Hence, for Brazil, we can use naive projection as the best Cartesian coordinate system, without needing to compute the full exponential map.
This is useful because we can just use the components described above:
where refer to Brazil’s centroid:
print("theta_0:", (90+10.80677)*np.pi/180)
print("phi_0: ", -53.05434*np.pi/180)
theta_0: 1.7594100448006442
phi_0: -0.9259729154725285
Computing the trigonometric functions and changing colatitude to latitude, we get:
Brazil-centric Cartesian coordinates in terms of latitude and longitude :
def brazil_centric_coordinates(lat, lon):
"""
Cartesian Coordinates centered on Brazil's barycenter, built from projection of vector onto tangent plane seen in ENU coordinates.
Parameters:
---------------
lat: float or numpy array
Latitude in radians
lon: float or numpy array
Longitude in radians
"""
y = 0.982265 * np.sin(lat) + 0.18749 * np.cos(lat) * np.cos(lon+0.92597)
x = np.cos(lat) * np.sin(lon+0.92597)
return np.array([x,y])
Conclusion
- We introduced the concept of the exponential map from differential geometry;
- We showed how to build coordinates from the inverse exponential map of the sphere, which is approximately Cartesian;
with
- We showed that, for Brazil, we don’t need to go as far as building the exponential map — direct projection of points onto the plane tangent to latitude -10.80677, longitude -53.05434 is enough. For a point of latitude and longitude this becomes
References
-
F. Alimisis, P. Davies, B. Vandereycken, and D. Alistarh, Distributed Principal Component Analysis with Limited Communication, Supplemental, Advances in Neural Information Processing Systems (34) (2021). Available here
-
do Carmo, Manfredo P. (1992), Riemannian Geometry, Birkhäuser, ISBN 0-8176-3490-8. See Chapter 3.
Appendix I: the exponential map of the sphere
Riemannian definition
The exponential map in a general Riemannian manifold is defined as follows.
Let and take any . Let be the unique affinely-parameterized geodesic starting at with velocity , ie. the solution to
where is the Levi-Civita connection associated with the metric . Then, we define the exponential map by walking one unit along that geodesic:
More precisely, the exponential map is only defined on a neighborhood of the origin in , since it might be true that, for some vectors , is ill-defined. This might happen since the geodesic equation will have an unique solution only on an open interval.
Back to the sphere
The explicit formula for the exponential map on a sphere can be found if we embed the sphere in . Then, any point in its surface can be identified by a vector with , where denotes the Euclidean norm.
Under such characterization, we can identify with the set of all vectors that are orthogonal to , ie. .
Consider then a vector in . We may visualize it as follows: since and span a plane, we slice the sphere through its center, parallel to that plane. By doing so, we can convert the setup to a plane figure which is easier to work with.

We use without proof the fact that geodesics on a sphere are great circles. Hence, the solution to the geodesic equation is the circle we see in Figure A. More precisely, it is the curve starting at and rotating clockwise with constant speed . If we call the angle spanned by this curve(with being point ), then
The point given by will then be that at , at an angular distance . In other words, it will be a rotation of by an angle of around an axis which is perpendicular to the planed spanned by and .
We are in place to use Rodrigues’ rotation formula:
where:
- is the vector we want to rotate;
- is the unit vector defining the axis of rotation around which we want to rotate;
- is the angle of rotation.
In our case, and ; the unit vector can be found by clever use of the cross product, This can be seen to be unit-length since . Then, by straightforward application of the vector triple product, we find
and
The inverse exponential map
We can also go the other way around. Fix the same as above, and consider any other point in the sphere. One might ask: what is the vector such that ?
We can illustrate this configuration with a figure very similar to Figure A. Here, we slice the sphere through a plane parallel to and . Since the angular distance between them is just , we immediately conclude that .
This is well-defined as a norm, since takes values in , all positive.

If is not parallel to , we can find its projection onto by finding its component orthogonal to . Since is the orthogonal projector to , it follows that
is the orthogonal projection (we use a parallel mark because it is parallel to the tangent plane). Hence, if we define
then this vector has the right length and direction, and is the inverse exponential map.
More precisely,
This is the main result used in the text.
Appendix II: deriving analytical expression for normal coordinates
In this section, unit vectors and have Cartesian coordinates
We want to derive the equations given in this Wikipedia article. First, we change their variables to match ours: substitution of latitude by colatitude () and a renaming of the longitude variable(), their equations read
and
and the coordinates are defined as , .
A) The distance between two points
The first important result is the following:
This can be derived by straightforward substitution of the Cartesian components above, plus a fair amount of trigonometric massaging.
The distance between two points on the sphere is simply the arc-cosine of this expression.
B) Coordinate expression of projecting vectors onto the tangent plane
First, notice that any vector can be projected onto the tangent plane via the projection operation:
which identically satisfies .
In local coordinates, it will have components
where, as usual,
and is the unit vector along the direction.
We will prove that
(below, we drop the arrows to avoid notational clutter)
First, the expression is just . Writing out the expression for , we have
using the cyclical property of the triple product in the last line. Then, we need just the -component of , which we can write explicitly as
A similar calculation can be done for . Since we can write
it follows that
which is just the component of the term between brackets. For , we use the expression in Appendix II:
This completes the proof.
C) Projection and exponential map share the same angle
We are ready to derive the expressions and for the exponential map.
First: notice that, if we fix the reference point , then the projection of a vector and its inverse exponential map are parallel; in fact this was used in Appendix I to derive the formula for the inverse exponential map.
Hence, the angle they make with the basis vectors are equal, and using the expressions in Appendix II.B we have
Formula is proven.
Formula is obvious since is exactly equal to the great circle distance between and , which is given by , ie. the formula in Appendix II.A.