Repository: sunpy/sunpy

Issue:
Erroneous structures found in Helioprojective to HeliocentricStonyhurst coordinate transformations when using arrays
<!--
We know asking good questions takes effort, and we appreciate your time.
Thank you.

Please be aware that everyone has to follow our code of conduct:
https://github.com/sunpy/sunpy/blob/master/CODE_OF_CONDUCT.rst

These comments are hidden when you submit this github issue.

Please have a search on our GitHub repository to see if a similar issue has already been posted.
If a similar issue is closed, have a quick look to see if you are satisfied by the resolution.
If not please go ahead and open an issue!
-->

### Description
<!--
Provide a general description of the bug.
-->

I am working with Hinode SOT/SP slit-scan Level-2 data.  The FITS files provide per-pixel coordinates in helioprojective (X, Y) coordinates.  I would like compute the physical area on the Sun for each pixel.  I do this by using the formula `area = Rsun**2 * (lon_right- lon_left) * (np.cos(lat_bot) - np.cos(lat_top)`, where `lat_*` and `lon_*` are HeliocentricStonyhurst coordinates derived from a SunPy coordinate transformation, and "left", "right", "top", "bottom" refer to the coordinates of a pixel edge, defined using the pixel coordinates and scale.  I have found strange horizontal and vertical structures in the longitude and latitude transformation arrays that result in incorrect (sometimes negative) pixel areas.  The coordinate transformations were done using the following function:

```python
def hproj2hgraph(x, y, t):
    hproj = astropy.coordinates.SkyCoord(x*u.arcsec, y*u.arcsec,
                                     frame=sunpy.coordinates.frames.Helioprojective,
                                     observer='earth', obstime=t)
    hgraph = hproj.transform_to(sunpy.coordinates.frames.HeliographicStonyhurst)
    lon = hgraph.lon.deg
    lat = hgraph.lat.deg
    return lon, lat
```

The attached image area_debug.png illustrates the steps of the calculation and the (lon, lat) structures at the root of the problem.  Each panel shows a map in (X, Y) indices of the following values:

 - log10(|x_left|) :  left side of pixel X in arcsec.  Looks normal (line is central meridian)
 - log10(|y_top|) : top side of pixel Y in arcsec.  Looks normal.
 - log10(|lon_tl|) : longitude of pixel top-left corner.  Looks normal at this scale, but actually has features.
 - log10(|lat_tl|) : latitude of pixel top-left corner.  Contains strange horizontal structures.
 - log10(|lon_br|) : longitude of pixel bottom-right corner.
 - log10(|lat_br|) : latitude of pixel bottom-right corner.
 - dlon : difference `lon_br - lon_tl` (right - left).  Clearly shows strange structures not present in `x`.
 - dlat : difference `lat_tl - lat_br` (top - bottom).  Clearly shows strange structures not present in `y`.
 - sign of dlon, black is negative (should be impossible)
 - sign of dlat, black is negative (should be impossible)
 - sign of dcoslat, black is negative (should be impossible)
 - area map in cm^2, shows stange large value structures (yellow) and negative values (white)

However, I put in the work to find a simpler case to debug and the data and scripts to reproduce are given in the attached coord_bug.zip.  I need to be clear that the dlon and dlat differences are **not** differences of different pixels in the coordinate arrays; they are differences of the **same** pixel shifted by the (xscale, yscale).  Every pixel coordinate and area should be independent of the others.

### Expected vs Actual behavior
<!--
What did you expect to happen.

What actually happened.
Was the output confusing or poorly described?
-->

I expected more-or-less smooth arrays of longitude, latitude to come out of the `hproj2hgraph()` function above.  However, I found there to be sharp edges in the longitude, latitude maps.  When taking the difference of two of these arrays the sharp edges lead to pixels with unexpectedly large scales or negative values that produced erroneous areas.

Furthermore, in my debugging efforts I can report the following:

- The problem doesn't occur when the input (x, y) to `hproj2hgraph()` are monotonically increasing at regular intervals in the expected directions.  In the actual data, there are small jitters in (x, y), e.g. `y[y_ix + 1, x_ix] - y[y_ix, x_ix]` may not be the same as y[y_ix + 2, x_ix] - y[y_ix + 1, x_ix], or sometimes large steps in the x-direction (slit-scan direction) where data was lost.  When I eliminate these features by redefining my own (x, y) grids, `hproj2hgraph()` produces smooth results without unexpected steps.
 - I have found that for the problematic inputs,`hproj2hgraph()` produces different results at a given coordinate when (x, y) are passed as 2D arrays versus when scalars from those same arrays are passed in.  I expected that results from the data as 2D arrays should be no different than iterating over each coordinate individually and calling `hproj2hgraph()` on them one by one.

### Steps to Reproduce
<!--
Please include code that reproduces the issue whenever possible.
The best reproductions are self-contained scripts with minimal dependencies.
-->

I attach code and data to reproduce the issues described above.

```python
def test1():
    print("=== Test 1 ===")
    # Load Data
    bug_tl = np.load('bug_tl.npz') # top left of pixel
    bug_br = np.load('bug_br.npz') # bottom right of pixel
    
    # Show differences in x and y: these are constant across the map and are 
    # equal to the pixel scale in arcsec
    print('dx:', bug_br['x'] - bug_tl['x']) # right - left
    print('dy:', bug_tl['y'] - bug_br['y']) # top - bottom
    print('t:', bug_tl['t'], bug_br['t']) # time is the same
    
    # Show differences in lon and lat: these are not expected to be constant 
    # across the map, but there is a order-of-magnitude step discontinuity!
    lon_tl, lat_tl = hproj2hgraph(bug_tl['x'], bug_tl['y'], bug_tl['t'])
    lon_br, lat_br = hproj2hgraph(bug_br['x'], bug_br['y'], bug_br['t'])
    print('dlon:', lon_br - lon_tl) # right - left
    print('dlat:', lat_tl - lat_br) # top - bottom
    print()

def test2(loadfile='bug_tl.npz'):
    print("=== Test 2 ===")
    data = np.load(loadfile)
    x = data['x']
    y = data['y']
    t = data['t']
    
    # Print x, y (arcsecs) and t arrays
    print("x:", x)
    print("y:", y)
    print("t:", t)
    
    # Print lon, lat (degree) arrays
    lon, lat = hproj2hgraph(x, y, t)
    print('lon:', lon)
    print('lat:', lat)
    print("--- Individual coordinate recalculations ---")
    
    # Iterate through each individual element and recompute (lon, lat)
    # Difference to the element in the above computation should be zero!
    for i in range(3):
        for j in range(3):
            coord = (i, j)
            x0 = x[coord]
            y0 = y[coord]
            lon0, lat0 = hproj2hgraph(x0, y0, t)
            print(coord)
            print("lon:", lon[coord], lon0, lon[coord] - lon0)
            print("lat:", lat[coord], lat0, lat[coord] - lat0)

test1()
test2()
```

When I run I see the following output:

```
=== Test 1 ===
dx: [[0.29714966 0.29714966 0.29714966]
 [0.29714966 0.29714966 0.29714966]
 [0.29714966 0.29714966 0.29714966]]
dy: [[0.32000732 0.32000732 0.32000732]
 [0.32000732 0.32000732 0.32000732]
 [0.32000732 0.32000732 0.32000732]]
t: 2017-07-03T21:45:58.675 2017-07-03T21:45:58.675
dlon: [[0.01824825 0.02723916 0.02724259]
 [0.12148871 0.13034178 0.13020939]
 [0.01838927 0.02744849 0.02745208]]
dlat: [[0.01437498 0.01342599 0.01342776]
 [0.21170406 0.21075943 0.21080128]
 [0.01438311 0.01341117 0.01341791]]

=== Test 2 ===
x: [[-171.42561 -171.06349 -170.85597]
 [-171.42561 -171.06349 -170.85597]
 [-171.42561 -171.06349 -170.85597]]
y: [[505.956   505.802   505.80096]
 [506.27597 506.12198 506.12094]
 [506.59595 506.44196 506.44092]]
t: 2017-07-03T21:45:58.675
lon: [[-12.73713593 -12.71903098 -12.70105477]
 [-12.84065255 -12.82240925 -12.80429682]
 [-12.84093355 -12.82268988 -12.80457708]]
lat: [[35.23322586 35.22730589 35.22916921]
 [35.44684721 35.44093759 35.44283902]
 [35.46318348 35.45727719 35.45917911]]
--- Individual coordinate recalculations ---
(0, 0)
lon: -12.737135925013035 -12.826888024850234 0.08975209983719878
lat: 35.233225858573896 35.40479788401918 -0.17157202544528616
(0, 1)
lon: -12.719030979395711 -12.806103386453117 0.0870724070574056
lat: 35.22730588947344 35.39399686669582 -0.16669097722238035
(0, 2)
lon: -12.701054773486192 -12.78709688646228 0.08604211297608799
lat: 35.22916921121895 35.39413871816952 -0.16496950695056967
(1, 0)
lon: -12.840652547891636 -12.83060892057506 -0.01004362731657693
lat: 35.446847213171445 35.42769512833623 0.01915208483521269
(1, 1)
lon: -12.822409248730992 -12.809816128780085 -0.01259311995090684
lat: 35.44093758617655 35.41689142649636 0.024046159680189305
(1, 2)
lon: -12.804296823258824 -12.790803964799295 -0.013492858459528634
lat: 35.44283902337261 35.41703337296047 0.025805650412145553
(2, 0)
lon: -12.840933546417887 -12.834335028364194 -0.006598518053692715
lat: 35.463183479533114 35.45059819269044 0.012585286842671906
(2, 1)
lon: -12.822689876042773 -12.81353407064148 -0.009155805401292127
lat: 35.457277185643775 35.43979180282091 0.01748538282286205
(2, 2)
lon: -12.804577081027048 -12.794516234223693 -0.010060846803355616
lat: 35.45917911161526 35.4399338437032 0.019245267912062047
```

Note that:

 - In Test 1, the x and y difference arrays are uniform, but the lon and lat difference arrays have a horizontal feature that is an order of magnitude larger than the other elements.
 - In Test 2, the third element printed in the individual coordinate recalculations is expected to be 0.

The problem can be reproduced with the attached data and code simply by running:

```
python coord_bug.py
```

### System Details
<!--
We need to know the the package version you are using.
We provide a short function in sunpy (``sunpy.util.system_info()``) that will provide most of the below information.
-->

python: 3.8.6 (default, Sep 28 2020, 04:41:02) 
[Clang 11.0.3 (clang-1103.0.32.62)]
sunpy: 2.0.1
astropy: 4.0
numpy: 1.19.4

![area_debug](https://user-images.githubusercontent.com/8008662/101842883-3a5dcb00-3b06-11eb-8c32-34461403b448.png)

[coord_bug.zip](https://github.com/sunpy/sunpy/files/5676249/coord_bug.zip)



Discussion:
Thanks to @rickyegeland for the very thorough bug report, and the associated discussion on the Element channel!

In short, this behavior is occurring because the source data arrays are 32-bit float data arrays.  That bit depth gets preserved in the `SkyCoord` as well, so all of the transformation calculations are performed on 32-bit floats.  Unfortunately, `Helioprojective.make_3d()` – which calculates the distance from the observer to the surface of the Sun – uses the law of cosines, and cosines of the small angles of HPC coordinates on the solar disk lose accuracy in 32-bit floats.

When the individual values are plucked out of the array, they become normal (64-bit) Python floats.  Then, all of the calculations are accurate.

To help users not get tripped up by this behavior, we can add a check to `Helioprojective.make_3d()` that would raise a warning if it sees that it's working with 32-bit floats.  I'm not keen on an automatic upgrade to 64-bit floats since the user may not want or need that.

Since the solar disk is the predominant use case for Helioprojective coordinates, `Helioprojective.make_3d()` could – in principle – be rewritten to avoid taking cosines of small angles, at the expense of code readability.

Resolving patch:
diff --git a/sunpy/coordinates/frames.py b/sunpy/coordinates/frames.py
--- a/sunpy/coordinates/frames.py
+++ b/sunpy/coordinates/frames.py
@@ -24,6 +24,7 @@
 from sunpy.sun.constants import radius as _RSUN
 from sunpy.time.time import _variables_for_parse_time_docstring
 from sunpy.util.decorators import add_common_docstring
+from sunpy.util.exceptions import SunpyUserWarning
 from .frameattributes import ObserverCoordinateAttribute, TimeFrameAttributeSunPy
 
 _J2000 = Time('J2000.0', scale='tt')
@@ -474,6 +475,16 @@ def make_3d(self):
 
         rep = self.represent_as(UnitSphericalRepresentation)
         lat, lon = rep.lat, rep.lon
+
+        # Check for the use of floats with lower precision than the native Python float
+        if not set([lon.dtype.type, lat.dtype.type]).issubset([float, np.float64, np.longdouble]):
+            raise SunpyUserWarning("The Helioprojective component values appear to be lower "
+                                   "precision than the native Python float: "
+                                   f"Tx is {lon.dtype.name}, and Ty is {lat.dtype.name}. "
+                                   "To minimize precision loss, you may want to cast the values to "
+                                   "`float` or `numpy.float64` via the NumPy method `.astype()`.")
+
+        # Calculate the distance to the surface of the Sun using the law of cosines
         cos_alpha = np.cos(lat) * np.cos(lon)
         c = self.observer.radius**2 - self.rsun**2
         b = -2 * self.observer.radius * cos_alpha


Test patch:
diff --git a/sunpy/coordinates/tests/test_frames.py b/sunpy/coordinates/tests/test_frames.py
--- a/sunpy/coordinates/tests/test_frames.py
+++ b/sunpy/coordinates/tests/test_frames.py
@@ -14,6 +14,7 @@
 from astropy.tests.helper import assert_quantity_allclose
 
 from sunpy.time import parse_time
+from sunpy.util.exceptions import SunpyUserWarning
 from ... import sun
 from ..frames import Heliocentric, HeliographicCarrington, HeliographicStonyhurst, Helioprojective
 
@@ -216,6 +217,15 @@ def test_hpc_default_observer():
     assert not hpc.is_frame_attr_default('observer')
 
 
+def test_hpc_low_precision_float_warning():
+    hpc = Helioprojective(u.Quantity(0, u.deg, dtype=np.float32),
+                          u.Quantity(0, u.arcsec, dtype=np.float16),
+                          observer=HeliographicStonyhurst(0*u.deg, 0*u.deg, 1*u.AU))
+
+    with pytest.raises(SunpyUserWarning, match="Tx is float32, and Ty is float16"):
+        hpc.make_3d()
+
+
 # ==============================================================================
 # ## Heliographic Tests
 # ==============================================================================