Skip to content

Commit 30d8abb

Browse files
StuartLittlefaireteq
authored andcommitted
Merge pull request #10666 from StuartLittlefair/replace_ldsun
Include a realistic treatment of solar light deflection for nearby objects
1 parent 635f8ef commit 30d8abb

5 files changed

Lines changed: 122 additions & 81 deletions

File tree

‎CHANGES.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,10 @@ astropy.convolution
685685
astropy.coordinates
686686
^^^^^^^^^^^^^^^^^^^
687687

688+
- Fix a bug where for light deflection by the Sun it was always assumed that the
689+
source was at infinite distance, which in the (rare and) absolute worst-case
690+
scenario could lead to errors up to 3 arcsec. [#10666]
691+
688692
astropy.cosmology
689693
^^^^^^^^^^^^^^^^^
690694

‎astropy/coordinates/builtin_frames/icrs_cirs_transforms.py‎

Lines changed: 16 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,8 @@ def icrs_to_cirs(icrs_coo, cirs_frame):
3535

3636
if icrs_coo.data.get_name() == 'unitspherical' or icrs_coo.data.to_cartesian().x.unit == u.one:
3737
# if no distance, just do the infinite-distance/no parallax calculation
38-
usrepr = icrs_coo.represent_as(UnitSphericalRepresentation)
39-
i_ra = usrepr.lon.to_value(u.radian)
40-
i_dec = usrepr.lat.to_value(u.radian)
41-
cirs_ra, cirs_dec = atciqz(i_ra, i_dec, astrom)
38+
srepr = icrs_coo.spherical
39+
cirs_ra, cirs_dec = atciqz(srepr.without_differentials(), astrom)
4240

4341
newrep = UnitSphericalRepresentation(lat=u.Quantity(cirs_dec, u.radian, copy=False),
4442
lon=u.Quantity(cirs_ra, u.radian, copy=False),
@@ -51,11 +49,8 @@ def icrs_to_cirs(icrs_coo, cirs_frame):
5149
astrom_eb = CartesianRepresentation(astrom['eb'], unit=u.au,
5250
xyz_axis=-1, copy=False)
5351
newcart = icrs_coo.cartesian - astrom_eb
54-
5552
srepr = newcart.represent_as(SphericalRepresentation)
56-
i_ra = srepr.lon.to_value(u.radian)
57-
i_dec = srepr.lat.to_value(u.radian)
58-
cirs_ra, cirs_dec = atciqz(i_ra, i_dec, astrom)
53+
cirs_ra, cirs_dec = atciqz(srepr.without_differentials(), astrom)
5954

6055
newrep = SphericalRepresentation(lat=u.Quantity(cirs_dec, u.radian, copy=False),
6156
lon=u.Quantity(cirs_ra, u.radian, copy=False),
@@ -66,18 +61,11 @@ def icrs_to_cirs(icrs_coo, cirs_frame):
6661

6762
@frame_transform_graph.transform(FunctionTransformWithFiniteDifference, CIRS, ICRS)
6863
def cirs_to_icrs(cirs_coo, icrs_frame):
69-
srepr = cirs_coo.represent_as(SphericalRepresentation)
70-
cirs_ra = srepr.lon.to_value(u.radian)
71-
cirs_dec = srepr.lat.to_value(u.radian)
72-
7364
# set up the astrometry context for ICRS<->cirs and then convert to
7465
# astrometric coordinate direction
75-
jd1, jd2 = get_jd12(cirs_coo.obstime, 'tt')
76-
x, y, s = get_cip(jd1, jd2)
77-
earth_pv, earth_heliocentric = prepare_earth_position_vel(cirs_coo.obstime)
78-
# erfa.apci requests TDB but TT can be used instead of TDB without any significant impact on accuracy
79-
astrom = erfa.apci(jd1, jd2, earth_pv, earth_heliocentric, x, y, s)
80-
i_ra, i_dec = aticq(cirs_ra, cirs_dec, astrom)
66+
astrom = erfa_astrom.get().apci(cirs_coo)
67+
srepr = cirs_coo.represent_as(SphericalRepresentation)
68+
i_ra, i_dec = aticq(srepr.without_differentials(), astrom)
8169

8270
if cirs_coo.data.get_name() == 'unitspherical' or cirs_coo.data.to_cartesian().x.unit == u.one:
8371
# if no distance, just use the coordinate direction to yield the
@@ -121,27 +109,13 @@ def cirs_to_cirs(from_coo, to_frame):
121109

122110
@frame_transform_graph.transform(FunctionTransformWithFiniteDifference, ICRS, GCRS)
123111
def icrs_to_gcrs(icrs_coo, gcrs_frame):
124-
# first set up the astrometry context for ICRS<->GCRS. There are a few steps...
125-
# get the position and velocity arrays for the observatory. Need to
126-
# have xyz in last dimension, and pos/vel in one-but-last.
127-
# (Note could use np.stack once our minimum numpy version is >=1.10.)
128-
obs_pv = erfa.pav2pv(
129-
gcrs_frame.obsgeoloc.get_xyz(xyz_axis=-1).to_value(u.m),
130-
gcrs_frame.obsgeovel.get_xyz(xyz_axis=-1).to_value(u.m/u.s))
131-
132-
# find the position and velocity of earth
133-
jd1, jd2 = get_jd12(gcrs_frame.obstime, 'tdb')
134-
earth_pv, earth_heliocentric = prepare_earth_position_vel(gcrs_frame.obstime)
135-
136-
# get astrometry context object, astrom.
137-
astrom = erfa.apcs(jd1, jd2, obs_pv, earth_pv, earth_heliocentric)
112+
# first set up the astrometry context for ICRS<->GCRS.
113+
astrom = erfa_astrom.get().apcs(gcrs_frame)
138114

139115
if icrs_coo.data.get_name() == 'unitspherical' or icrs_coo.data.to_cartesian().x.unit == u.one:
140116
# if no distance, just do the infinite-distance/no parallax calculation
141-
usrepr = icrs_coo.represent_as(UnitSphericalRepresentation)
142-
i_ra = usrepr.lon.to_value(u.radian)
143-
i_dec = usrepr.lat.to_value(u.radian)
144-
gcrs_ra, gcrs_dec = atciqz(i_ra, i_dec, astrom)
117+
srepr = icrs_coo.represent_as(SphericalRepresentation)
118+
gcrs_ra, gcrs_dec = atciqz(srepr.without_differentials(), astrom)
145119

146120
newrep = UnitSphericalRepresentation(lat=u.Quantity(gcrs_dec, u.radian, copy=False),
147121
lon=u.Quantity(gcrs_ra, u.radian, copy=False),
@@ -156,9 +130,7 @@ def icrs_to_gcrs(icrs_coo, gcrs_frame):
156130
newcart = icrs_coo.cartesian - astrom_eb
157131

158132
srepr = newcart.represent_as(SphericalRepresentation)
159-
i_ra = srepr.lon.to_value(u.radian)
160-
i_dec = srepr.lat.to_value(u.radian)
161-
gcrs_ra, gcrs_dec = atciqz(i_ra, i_dec, astrom)
133+
gcrs_ra, gcrs_dec = atciqz(srepr.without_differentials(), astrom)
162134

163135
newrep = SphericalRepresentation(lat=u.Quantity(gcrs_dec, u.radian, copy=False),
164136
lon=u.Quantity(gcrs_ra, u.radian, copy=False),
@@ -170,10 +142,6 @@ def icrs_to_gcrs(icrs_coo, gcrs_frame):
170142
@frame_transform_graph.transform(FunctionTransformWithFiniteDifference,
171143
GCRS, ICRS)
172144
def gcrs_to_icrs(gcrs_coo, icrs_frame):
173-
srepr = gcrs_coo.represent_as(SphericalRepresentation)
174-
gcrs_ra = srepr.lon.to_value(u.radian)
175-
gcrs_dec = srepr.lat.to_value(u.radian)
176-
177145
# set up the astrometry context for ICRS<->GCRS and then convert to BCRS
178146
# coordinate direction
179147
obs_pv = erfa.pav2pv(
@@ -185,7 +153,8 @@ def gcrs_to_icrs(gcrs_coo, icrs_frame):
185153
earth_pv, earth_heliocentric = prepare_earth_position_vel(gcrs_coo.obstime)
186154
astrom = erfa.apcs(jd1, jd2, obs_pv, earth_pv, earth_heliocentric)
187155

188-
i_ra, i_dec = aticq(gcrs_ra, gcrs_dec, astrom)
156+
srepr = gcrs_coo.represent_as(SphericalRepresentation)
157+
i_ra, i_dec = aticq(srepr.without_differentials(), astrom)
189158

190159
if gcrs_coo.data.get_name() == 'unitspherical' or gcrs_coo.data.to_cartesian().x.unit == u.one:
191160
# if no distance, just use the coordinate direction to yield the
@@ -231,21 +200,11 @@ def gcrs_to_hcrs(gcrs_coo, hcrs_frame):
231200
frameattrs['obstime'] = hcrs_frame.obstime
232201
gcrs_coo = gcrs_coo.transform_to(GCRS(**frameattrs))
233202

234-
srepr = gcrs_coo.represent_as(SphericalRepresentation)
235-
gcrs_ra = srepr.lon.to_value(u.radian)
236-
gcrs_dec = srepr.lat.to_value(u.radian)
237-
238203
# set up the astrometry context for ICRS<->GCRS and then convert to ICRS
239204
# coordinate direction
240-
obs_pv = erfa.pav2pv(
241-
gcrs_coo.obsgeoloc.get_xyz(xyz_axis=-1).to_value(u.m),
242-
gcrs_coo.obsgeovel.get_xyz(xyz_axis=-1).to_value(u.m/u.s))
243-
244-
jd1, jd2 = get_jd12(hcrs_frame.obstime, 'tdb')
245-
earth_pv, earth_heliocentric = prepare_earth_position_vel(gcrs_coo.obstime)
246-
astrom = erfa.apcs(jd1, jd2, obs_pv, earth_pv, earth_heliocentric)
247-
248-
i_ra, i_dec = aticq(gcrs_ra, gcrs_dec, astrom)
205+
astrom = erfa_astrom.get().apcs(gcrs_coo)
206+
srepr = gcrs_coo.represent_as(SphericalRepresentation)
207+
i_ra, i_dec = aticq(srepr.without_differentials(), astrom)
249208

250209
# convert to Quantity objects
251210
i_ra = u.Quantity(i_ra, u.radian, copy=False)

‎astropy/coordinates/builtin_frames/utils.py‎

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from astropy.time import Time
1515
from astropy.utils import iers
1616
from astropy.utils.exceptions import AstropyWarning
17+
from ..representation import CartesianRepresentation
1718

1819

1920
# We use tt as the time scale for this equinoxes, primarily because it is the
@@ -153,13 +154,16 @@ def get_cip(jd1, jd2):
153154
return x, y, s
154155

155156

156-
def aticq(ri, di, astrom):
157+
def aticq(srepr, astrom):
157158
"""
158159
A slightly modified version of the ERFA function ``eraAticq``.
159160
160161
``eraAticq`` performs the transformations between two coordinate systems,
161162
with the details of the transformation being encoded into the ``astrom`` array.
162163
164+
There are two issues with the version of aticq in ERFA. Both are associated
165+
with the handling of light deflection.
166+
163167
The companion function ``eraAtciqz`` is meant to be its inverse. However, this
164168
is not true for directions close to the Solar centre, since the light deflection
165169
calculations are numerically unstable and therefore not reversible.
@@ -169,22 +173,30 @@ def aticq(ri, di, astrom):
169173
same approach used by the ERFA functions above, except that they use a threshold of
170174
9 arcseconds.
171175
176+
In addition, ERFA's aticq assumes a distant source, so there is no difference between
177+
the object-Sun vector and the observer-Sun vector. This can lead to errors of up to a
178+
few arcseconds in the worst case (e.g a Venus transit).
179+
172180
Parameters
173181
----------
174-
ri : float or `~numpy.ndarray`
175-
right ascension, radians
176-
di : float or `~numpy.ndarray`
177-
declination, radians
182+
srepr : `~astropy.coordinates.SphericalRepresentation`
183+
Astrometric GCRS or CIRS position of object from observer
178184
astrom : eraASTROM array
179185
ERFA astrometry context, as produced by, e.g. ``eraApci13`` or ``eraApcs13``
180186
181187
Returns
182188
--------
183189
rc : float or `~numpy.ndarray`
190+
Right Ascension in radians
184191
dc : float or `~numpy.ndarray`
192+
Declination in radians
185193
"""
194+
# ignore parallax effects if no distance, or far away
195+
srepr_distance = srepr.distance
196+
ignore_distance = srepr_distance.unit == u.one
197+
186198
# RA, Dec to cartesian unit vectors
187-
pos = erfa.s2c(ri, di)
199+
pos = erfa.s2c(srepr.lon.radian, srepr.lat.radian)
188200

189201
# Bias-precession-nutation, giving GCRS proper direction.
190202
ppr = erfa.trxp(astrom['bpn'], pos)
@@ -201,7 +213,24 @@ def aticq(ri, di, astrom):
201213
d = np.zeros_like(pnat)
202214
for j in range(5):
203215
before = norm(pnat-d)
204-
after = erfa.ld(1.0, before, before, astrom['eh'], astrom['em'], 5e-8)
216+
if ignore_distance:
217+
# No distance to object, assume a long way away
218+
q = before
219+
else:
220+
# Find BCRS direction of Sun to object.
221+
# astrom['eh'] and astrom['em'] contain Sun to observer unit vector,
222+
# and distance, respectively.
223+
eh = astrom['em'][..., np.newaxis] * astrom['eh']
224+
# unit vector from Sun to object
225+
q = eh + srepr_distance[..., np.newaxis].to_value(u.au) * before
226+
sundist = np.linalg.norm(q, axis=-1, keepdims=True)
227+
q /= sundist
228+
# calculation above is extremely unstable very close to the sun
229+
# in these situations, default back to ldsun-style behaviour,
230+
# since this is reversible and drops to zero within stellar limb
231+
q = np.where(sundist > 1.0e-10, q, before)
232+
233+
after = erfa.ld(1.0, before, q, astrom['eh'], astrom['em'], 1e-6)
205234
d = after - before
206235
pco = norm(pnat-d)
207236

@@ -210,13 +239,16 @@ def aticq(ri, di, astrom):
210239
return erfa.anp(rc), dc
211240

212241

213-
def atciqz(rc, dc, astrom):
242+
def atciqz(srepr, astrom):
214243
"""
215244
A slightly modified version of the ERFA function ``eraAtciqz``.
216245
217246
``eraAtciqz`` performs the transformations between two coordinate systems,
218247
with the details of the transformation being encoded into the ``astrom`` array.
219248
249+
There are two issues with the version of atciqz in ERFA. Both are associated
250+
with the handling of light deflection.
251+
220252
The companion function ``eraAticq`` is meant to be its inverse. However, this
221253
is not true for directions close to the Solar centre, since the light deflection
222254
calculations are numerically unstable and therefore not reversible.
@@ -226,25 +258,51 @@ def atciqz(rc, dc, astrom):
226258
same approach used by the ERFA functions above, except that they use a threshold of
227259
9 arcseconds.
228260
261+
In addition, ERFA's atciqz assumes a distant source, so there is no difference between
262+
the object-Sun vector and the observer-Sun vector. This can lead to errors of up to a
263+
few arcseconds in the worst case (e.g a Venus transit).
264+
229265
Parameters
230266
----------
231-
rc : float or `~numpy.ndarray`
232-
right ascension, radians
233-
dc : float or `~numpy.ndarray`
234-
declination, radians
267+
srepr : `~astropy.coordinates.SphericalRepresentation`
268+
Astrometric ICRS position of object from observer
235269
astrom : eraASTROM array
236270
ERFA astrometry context, as produced by, e.g. ``eraApci13`` or ``eraApcs13``
237271
238272
Returns
239273
--------
240274
ri : float or `~numpy.ndarray`
275+
Right Ascension in radians
241276
di : float or `~numpy.ndarray`
277+
Declination in radians
242278
"""
279+
# ignore parallax effects if no distance, or far away
280+
srepr_distance = srepr.distance
281+
ignore_distance = srepr_distance.unit == u.one
282+
243283
# BCRS coordinate direction (unit vector).
244-
pco = erfa.s2c(rc, dc)
284+
pco = erfa.s2c(srepr.lon.radian, srepr.lat.radian)
285+
286+
# Find BCRS direction of Sun to object
287+
if ignore_distance:
288+
# No distance to object, assume a long way away
289+
q = pco
290+
else:
291+
# Find BCRS direction of Sun to object.
292+
# astrom['eh'] and astrom['em'] contain Sun to observer unit vector,
293+
# and distance, respectively.
294+
eh = astrom['em'][..., np.newaxis] * astrom['eh']
295+
# unit vector from Sun to object
296+
q = eh + srepr_distance[..., np.newaxis].to_value(u.au) * pco
297+
sundist = np.linalg.norm(q, axis=-1, keepdims=True)
298+
q /= sundist
299+
# calculation above is extremely unstable very close to the sun
300+
# in these situations, default back to ldsun-style behaviour,
301+
# since this is reversible and drops to zero within stellar limb
302+
q = np.where(sundist > 1.0e-10, q, pco)
245303

246304
# Light deflection by the Sun, giving BCRS natural direction.
247-
pnat = erfa.ld(1.0, pco, pco, astrom['eh'], astrom['em'], 5e-8)
305+
pnat = erfa.ld(1.0, pco, q, astrom['eh'], astrom['em'], 1e-6)
248306

249307
# Aberration, giving GCRS proper direction.
250308
ppr = erfa.ab(pnat, astrom['v'], astrom['em'], astrom['bm1'])

‎astropy/coordinates/tests/test_atc_replacements.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from astropy import _erfa as erfa
1313
from .utils import randomly_sample_sphere
1414
from astropy.coordinates.builtin_frames.utils import get_jd12, atciqz, aticq
15+
from astropy.coordinates import SphericalRepresentation
1516

1617
times = [Time("2014-06-25T00:00"), Time(["2014-06-25T00:00", "2014-09-24"])]
1718
ra, dec, _ = randomly_sample_sphere(2)
@@ -27,7 +28,8 @@ def test_atciqz_aticq(st):
2728
astrom, _ = erfa.apci13(jd1, jd2)
2829

2930
ra, dec = pos
31+
srepr = SphericalRepresentation(ra, dec, 1)
3032
ra = ra.value
3133
dec = dec.value
32-
assert_allclose(erfa.atciqz(ra, dec, astrom), atciqz(ra, dec, astrom))
33-
assert_allclose(erfa.aticq(ra, dec, astrom), aticq(ra, dec, astrom))
34+
assert_allclose(erfa.atciqz(ra, dec, astrom), atciqz(srepr, astrom))
35+
assert_allclose(erfa.aticq(ra, dec, astrom), aticq(srepr, astrom))

‎astropy/coordinates/tests/test_regression.py‎

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@
1414

1515

1616
from astropy import units as u
17-
from astropy.coordinates import (AltAz, EarthLocation, SkyCoord, get_sun, ICRS,
18-
GeocentricMeanEcliptic, Longitude, Latitude, GCRS, HCRS, CIRS,
19-
get_moon, FK4, FK4NoETerms, BaseCoordinateFrame, ITRS,
20-
QuantityAttribute, UnitSphericalRepresentation,
21-
SphericalRepresentation, CartesianRepresentation,
22-
FunctionTransform,
23-
CylindricalRepresentation, CylindricalDifferential,
24-
CartesianDifferential)
17+
from astropy.coordinates import (
18+
AltAz, EarthLocation, SkyCoord, get_sun, ICRS,
19+
GeocentricMeanEcliptic, Longitude, Latitude, GCRS, HCRS, CIRS,
20+
get_moon, FK4, FK4NoETerms, BaseCoordinateFrame, ITRS,
21+
QuantityAttribute, UnitSphericalRepresentation,
22+
SphericalRepresentation, CartesianRepresentation,
23+
FunctionTransform, get_body,
24+
CylindricalRepresentation, CylindricalDifferential,
25+
CartesianDifferential)
2526
from astropy.coordinates.sites import get_builtin_sites
2627
from astropy.utils.exceptions import ErfaWarning
2728
from astropy.time import Time
@@ -672,3 +673,20 @@ def test_regression_10422(mjd):
672673
loc = EarthLocation(88258.0 * u.m, -4924882.2 * u.m, 3943729.0 * u.m)
673674
p, v = loc.get_gcrs_posvel(obstime=t)
674675
assert p.shape == v.shape == t.shape
676+
677+
678+
@pytest.mark.remote_data
679+
def test_regression_10291():
680+
"""
681+
According to https://eclipse.gsfc.nasa.gov/OH/transit12.html,
682+
the minimum separation between Venus and the Sun during the 2012
683+
transit is 554 arcseconds for an observer at the Geocenter.
684+
685+
If light deflection from the Sun is incorrectly applied, this increases
686+
to 557 arcseconds.
687+
"""
688+
t = Time('2012-06-06 01:29:36')
689+
sun = get_body('sun', t)
690+
venus = get_body('venus', t)
691+
assert_quantity_allclose(venus.separation(sun),
692+
554.427*u.arcsecond, atol=0.001*u.arcsecond)

0 commit comments

Comments
 (0)