Skip to content

Commit a355465

Browse files
Fix #842 by changing geographic height formula
1 parent 2e89cb5 commit a355465

File tree

4 files changed

+75
-78
lines changed

4 files changed

+75
-78
lines changed

CHANGELOG.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ Changelog
55
.. TODO After finding how to test TIRS reference frame, add it to changelog.
66
And double-check the constellation boundaries array.
77
8+
v1.46 — 2022 April ?
9+
--------------------
10+
11+
* Bugfix: Skyfield was giving values several kilometers off when
12+
computing the elevation above ground level of a target directly above
13+
the Earth’s north or south pole.
14+
815
v1.45 — 2022 September 15
916
-------------------------
1017

design/geographic_height.py

Lines changed: 41 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,55 +4,41 @@
44
# as pointed out in GitHub issue #842. As we 'add nines' to 89.0°, here
55
# is how the quantities behave:
66
#
7-
# aC: this looks to be the 'local radius of curvature', of all things,
8-
# and it converges quickly to 6399593.625758493 m at 89.99999°, and
9-
# stays there, rock-solid, all the way to 90°.
10-
#
11-
# R / cos(lat): this is our problem, the value that is going sideways.
12-
# Converted to meters (* AU_M), it starts at a value very close to
13-
# aC, then begins to diverge - with `R / cos(lat) - aC` growing by
14-
# 10× for each step from 89.99 through 89.999999999 before its
15-
# growth slows - but by that point the error in elevation is already
16-
# 40 m. So let's investigate the behavior of its components, then
17-
# return to this value below.
7+
# aC: this is the 'local radius of curvature', which converges quickly
8+
# to 6399593.625758493 m at 89.99999°, and stays there, rock-solid,
9+
# all the way to 90°.
1810
#
1911
# R: The radius of the line in the xy-plane connecting the pole with
20-
# the end of the distance vector.
21-
#
22-
# cos(lat): The scaling factor, at the angle `lat`, between the radius
23-
# of the unit circle, and a line drawn orthogonally from the pole's
24-
# line over to the tip of the position vector.
12+
# the end of the distance vector, which (obviously) becomes very
13+
# very small as we approach the pole, though machine precision seems
14+
# to prevent its ever becoming zero.
2515
#
26-
# R / cos(lat): So, we return to our ratio, now with the understanding
27-
# that it's trying to rebuild a kind-of-length for the position
28-
# vector, but using the computed `lat` instead of the simple true
29-
# angle between the position vector and the pole's vector. So how
30-
# does this compare with the vector's true length?
16+
# cos(lat): Alas, fairly imprecise, as the only way it can tell exactly
17+
# how far we are from the pole is to look at the very last digits of
18+
# angles like 89.9999999, which only have a few bits left beneath
19+
# them to hold their precision.
3120
#
32-
# R / cos(lat) - length_of(xyz_au): This difference starts to converge
33-
# on 42841.31 before it starts to diverge at around 89.999999. So
34-
# `R / cos(lat)` exceeds the actual vector length, because .
21+
# R / cos(lat): So this is our problem, the value that is going
22+
# sideways. For the Earth's surface at the North Pole, converted to
23+
# meters (* AU_M), it starts at a value very close to aC, then
24+
# begins to diverge - with `R / cos(lat) - aC` growing by 10× for
25+
# each step from 89.99 through 89.999999999 before its growth slows
26+
# - but by that point the error in elevation is already 40 m.
3527
#
36-
# Looking inside the _compute_latitude() routine, here are a few values:
28+
# How can we compute the height without recourse to the cosine of the
29+
# latitude? Drawing a diagram, we have a triangle with one leg that's
30+
# the axis through the poles; one leg that's R, sticking out from the
31+
# axis into the xy-plane; and a hypotenuse that's the radius of
32+
# curvature plus the height above-ground-level of our target.
3733
#
38-
# z: The literal, unchanged z-coordinate of the position vector, giving
39-
# height above the xy-plane of the equator.
34+
# R / cos(lat) is one way of computing the hypotenuse, yes.
4035
#
41-
# aC * e2_sin_lat: The distance in the z-direction between the
42-
# position's actual z-coordinate, and the z-coordinate that the
43-
# position would have if it were on a sphere whose radius, the whole
44-
# way around, was the local ellipsoid's radius at the Earth latitude
45-
# under consideration.
36+
# But we can also just use Pythagoras. We already know one leg is R.
37+
# The other? It turns out that it's the quantity passed to arctan2() to
38+
# compute the latitude! Thus, `z + aC * e2_sin_lat`, which we can give
39+
# a name to and pass as an additional return value.
4640
#
47-
# z + aC * e2_sin_lat: Per the previous two descriptions, this is the
48-
# position's z-coordinate adjusted 'upward' (north along the z-axis
49-
# for the northern hemisphere) to simulate the shape the vector
50-
# would have if the whole Earth had the radius of curvature of this
51-
# position on the ellipsoid. And so it's this artificial z-value
52-
# that can be tossed into arctan2() with the literal xy-radius `R`.
53-
#
54-
# A few quick constants that will come up in various print() calls you
55-
# might add to the code:
41+
# Some common values:
5642
#
5743
# 6399.6 km - Earth radius of curvature at pole
5844
# 6378 km - Earth radius at equator
@@ -62,7 +48,6 @@
6248
from numpy import arctan2, cos, sin, sqrt
6349
from skyfield.api import load, wgs84, tau
6450
from skyfield.constants import AU_M, DEG2RAD, RAD2DEG
65-
from skyfield.functions import length_of
6651

6752
ts = load.timescale()
6853
t = ts.utc(2023, 3, 2, 4, 13, 0)
@@ -76,33 +61,23 @@
7661
# latitude = 1.0 - 1.0 / 10.0 ** nines # To test behavior at equator.
7762

7863
position = wgs84.latlon(latitude, 0.0, 0.0).at(t)
79-
xyz_au, x, y, aC, R, lat = self._compute_latitude(position)
64+
xyz_au, x, y, R, aC, hyp, lat = self._compute_latitude(position)
8065
_, _, z = xyz_au
8166

82-
# Official formula.
83-
# Jumps around, because aC is jumping around.
67+
# Official formula: jumps around, because R / cos(lat) is jumping
68+
# around, as both R and cos(lat) are becoming very small.
8469
height_au = R / cos(lat) - aC
85-
86-
# So, an alternative?
87-
R2 = R
88-
lat2 = lat
89-
90-
R2 = max(R, 11 / AU_M)
91-
lat2 = arctan2(z + aC * self._e2 * sin(lat2), R2)
92-
#lat2 = arctan2(z + aC * self._e2 * sin(lat2), R2)
93-
94-
height_au = R2 / cos(lat2) - aC
9570
height_m = height_au * AU_M
9671

97-
out = lat * RAD2DEG
98-
print(
99-
'{:<17s} {:<17s} {:20.12f}'.format(str(latitude), str(out), height_m),
100-
# aC * AU_M, # Converges quickly on 6399 km, the radius of curvature.
101-
# R * AU_M, # Dives towards zero.
102-
# cos(lat), # Dives towards zero.
103-
(R / cos(lat)) * AU_M, # Goes all wonky and jumps around.
104-
z * AU_M, # Converges quickly on 6357 km, the actual polar radius.
72+
# So, new formula, building the hypotenuse with Pythagoras.
73+
thing = sqrt(hyp * hyp + R * R)
74+
height2_au = thing - aC
75+
height2_m = height2_au * AU_M
10576

106-
# Difference should be around 6399 - 6357 = ~42 km.
107-
# (R / cos(lat) - length_of(xyz_au)) * AU_M,
108-
)
77+
out = lat * RAD2DEG
78+
print('{:<17s} {:<17s} {:20.12f} {:20.12f}'.format(
79+
str(latitude),
80+
str(out),
81+
height_m,
82+
height2_m,
83+
))

skyfield/tests/test_topos.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,18 @@ def test_wgs84_subpoint(ts, angle):
181181
error_mas = 60.0 * 60.0 * 1000.0 * error_degrees
182182
assert error_mas < 0.1
183183

184+
def test_wgs84_subpoint_at_pole(ts):
185+
# The `height` previously suffered from very low precision at the pole.
186+
t = ts.utc(2023, 4, 7, 12, 44)
187+
p = wgs84.latlon(90, 0, elevation_m=10.0).at(t)
188+
micrometer = 1e-6
189+
190+
h = wgs84.height_of(p)
191+
assert abs(h.m - 10.0) < micrometer
192+
193+
g = wgs84.geographic_position_of(p)
194+
assert abs(g.elevation.m - 10.0) < micrometer
195+
184196
def test_wgs84_round_trip_with_polar_motion(ts, angle):
185197
t = ts.utc(2018, 1, 19, 14, 37, 55)
186198
ts.polar_motion_table = [0.0], [0.003483], [0.358609]

skyfield/toposlib.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,15 @@ def latlon(self, latitude_degrees, longitude_degrees, elevation_m=0.0,
181181

182182
# At equator: 6378 km, the Earth's actual radius at the equator.
183183
# At the pole: 6399 km, the Earth's radius of curvature at the pole.
184-
radius_xy = radius_au * c + elevation_au
185-
xy = radius_xy * cosphi
184+
radius_xy = radius_au * c
185+
xy = (radius_xy + elevation_au) * cosphi
186186
x = xy * cos(lon)
187187
y = xy * sin(lon)
188188

189189
# At equator: 6335 km, the Earth's radius of curvature at the equator.
190190
# At the pole: 6357 km, the Earth's actual radius at the pole.
191-
radius_z = radius_au * s + elevation_au
192-
z = radius_z * sinphi
191+
radius_z = radius_au * s
192+
z = (radius_z + elevation_au) * sinphi
193193

194194
r = array((x, y, z))
195195
return cls(self, latitude, longitude, elevation, Distance(r))
@@ -202,7 +202,7 @@ def latlon_of(self, position):
202202
:class:`~skyfield.units.Angle` objects.
203203
204204
"""
205-
xyz_au, x, y, aC, R, lat = self._compute_latitude(position)
205+
xyz_au, x, y, R, aC, hyp, lat = self._compute_latitude(position)
206206
lon = (arctan2(y, x) - pi) % tau - pi
207207
return Angle(radians=lat), Angle(radians=lon)
208208

@@ -214,8 +214,8 @@ def height_of(self, position):
214214
position’s geodetic height above the Earth’s surface.
215215
216216
"""
217-
xyz_au, x, y, aC, R, lat = self._compute_latitude(position)
218-
height_au = R / cos(lat) - aC
217+
xyz_au, x, y, R, aC, hyp, lat = self._compute_latitude(position)
218+
height_au = sqrt(hyp * hyp + R * R) - aC
219219
return Distance(height_au)
220220

221221
def geographic_position_of(self, position):
@@ -227,9 +227,9 @@ def geographic_position_of(self, position):
227227
above or below the surface of the ellipsoid.
228228
229229
"""
230-
xyz_au, x, y, aC, R, lat = self._compute_latitude(position)
230+
xyz_au, x, y, R, aC, hyp, lat = self._compute_latitude(position)
231231
lon = (arctan2(y, x) - pi) % tau - pi
232-
height_au = R / cos(lat) - aC
232+
height_au = sqrt(hyp * hyp + R * R) - aC
233233
return GeographicPosition(
234234
latitude=Angle(radians=lat),
235235
longitude=Angle(radians=lon),
@@ -247,7 +247,7 @@ def subpoint_of(self, position):
247247
and an ``elevation`` above the ellipsoid of zero.
248248
249249
"""
250-
xyz_au, x, y, aC, R, lat = self._compute_latitude(position)
250+
xyz_au, x, y, R, aC, hyp, lat = self._compute_latitude(position)
251251
lon = (arctan2(y, x) - pi) % tau - pi
252252
return self.latlon(lat * RAD2DEG, lon * RAD2DEG)
253253

@@ -267,9 +267,12 @@ def _compute_latitude(self, position):
267267
for iteration in 0,1,2:
268268
sin_lat = sin(lat)
269269
e2_sin_lat = e2 * sin_lat
270+
# At 0°, aC = 6378 km, Earth's actual radius at the equator.
271+
# At 90°, aC = 6399 km, Earth's radius of curvature at the pole.
270272
aC = a / sqrt(1.0 - e2_sin_lat * sin_lat)
271-
lat = arctan2(z + aC * e2_sin_lat, R)
272-
return xyz_au, x, y, aC, R, lat
273+
hyp = z + aC * e2_sin_lat
274+
lat = arctan2(hyp, R)
275+
return xyz_au, x, y, R, aC, hyp, lat
273276

274277
subpoint = geographic_position_of # deprecated method name
275278

0 commit comments

Comments
 (0)