Repository: sunpy/sunpy

Issue:
`.rotate()` does not correctly de-rotate maps with a PCij matrix that is not a pure rotation
`GenericMap.rotate()` is currently bugged when it uses a rotation matrix that is not a pure rotation.  While we could simply slap a warning to dissuade a user from explicitly providing such a matrix, a user can implicitly encounter this issue when de-rotating a map (i.e., calling `.rotate()` without any arguments) that has a PCij matrix that is not a pure rotation.  One example of a map with such a PCij matrix is one with rectangular pixels that are not aligned with the coordinate axes.

```python
import numpy as np
import matplotlib.pyplot as plt
from skimage.data import camera

import sunpy.map

data = np.flipud(camera().astype('float'))[::2]
header = {
    'CRVAL1': 0,
    'CRVAL2': 0,
    'CRPIX1': 0.5,
    'CRPIX2': 0.5,
    'NAXIS1': data.shape[0],
    'NAXIS2': data.shape[1],
    'CUNIT1': 'arcsec',
    'CUNIT2': 'arcsec',
    'CTYPE1': 'HPLN-TAN',
    'CTYPE2': 'HPLT-TAN',
    'CDELT1': 1,
    'CDELT2': 2,  # note != CDELT1, so rectangular pixels
    'CROTA2': 30,  # CROTA != 0 with rectangular pixels results in a PCij matrix that is not a pure rotation
    'DATE-OBS': '2020-01-01 00:00:00'
}
base = sunpy.map.Map(data, header)
rot = base.rotate()  # uses the PCij matrix that is not a pure rotation

plt.figure()
base.plot()
plt.gca().coords.grid(color='red', lw=1)
```
![base](https://user-images.githubusercontent.com/991759/148321906-0a9085d0-1d97-48b4-a2d1-a0a7c49c0513.png)

```python
plt.figure()
rot.plot()
plt.gca().coords.grid(color='red', lw=1)
```
![rot](https://user-images.githubusercontent.com/991759/148321919-e1fe0fb4-5cb4-4004-b90e-b9f15744966c.png)
There are a couple of problems with this output map.
map.rotate doesn't take scale into account when padding image
<!-- This comments are hidden when you submit the issue so you do not need to remove them!
Please be sure to check out our contributing guidelines: https://github.com/sunpy/sunpy/blob/master/CONTRIBUTING.rst
Please be sure to check out our code of conduct:
https://github.com/sunpy/sunpy/blob/master/CODE_OF_CONDUCT.rst -->

<!-- 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. -->

Scale is not taking into account when padding the image, only rotation.  This results on cropped images when using magnification.   The opposite doesn't happen if you reduce the image.  The image retains its original dimension, even though the Sun is tiny.

### Expected behavior
<!-- What did you expect to happen. -->

The image should be padded to accommodate the entire scaled image when upscaling. 

### Actual behavior
<!-- What actually happened. -->
<!-- Was the output confusing or poorly described? -->

Using a magnification in which the Sun's radius ends up larger than the original field of view keeps the original image dimension and crops anything that doesn't fit the new field of view.

This is because the padding is determined using:

```
        # Calculate the shape in pixels to contain all of the image data
        extent = np.max(np.abs(np.vstack((self.data.shape @ rmatrix,
                                          self.data.shape @ rmatrix.T))), axis=0)
```

It may be necessary to pad the image based on scale, prior to rotation

### Steps to Reproduce
<!-- Ideally a code example could be provided so we can run it ourselves. -->
<!-- If you are pasting code, use tripe backticks (```) around your code snippet. -->

Open any test map and use map.rotate(scale=4)   The resulting image is visibly cropped.



Discussion:

I can work on this!
Please let me know if I have understood this correctly,
Currently, only the central region in the image is upscaled. This is done so that whatever area of the upscaled image fits the original dimensions of the image is taken. This results in cropping. What is required is that the entire image be upscaled according to the scaling factor. This will increase the dimensions of the image dramatically. But the user will be able to view any part of the original image. 

Hi! Currently the image is only padded to account for rotation and rotation only.   But it should be also padded, if the user is upscaling the size. As to wether this will result in a dramatic increase of dimensions, it depends on how dramatic the user is feeling that day. Note that a clever user, with little appetite for drama, could make a submap to crop the image first and _then_ upscale. But the way the function is currently implemented would not enable that either.
Could you post a short and self contained example that shows what is going wrong?
Of course.  In fact here is how I deal with it.  I think it makes the problem pretty obvious:  
- you have to make a dummy image big enough to accommodate the rescale
- you have to manipulate the header
- you have to re-create the map
- then you can do the rotate
- and finally you have to crop the additional unwanted pixels and modify the header again

If you don't do this, instead of ending with an image that is 4k by 4k, you end up with whatever padding the angle forces on 1k and you loose all the rest.

```
scale_factor = 4

MDI_fits = fits.open(hr_path.replace('HR','raw') + hr_hmi_set.loc[inx, 'filepath_hr'].replace('_HR',''), cache=False)
MDI_fits.verify('fix')


MDImap = Map(MDI_fits[1].data, MDI_fits[1].header)
MDI_fits.close()


# Pad image, if necessary
target_shape = int(4096)

# Reform map to new size if original shape is too small


new_fov = np.zeros((target_shape, target_shape)) * np.nan
new_meta = MDImap.meta

new_meta['crpix1'] = new_meta['crpix1'] - MDImap.data.shape[0] / 2 + new_fov.shape[0] / 2
new_meta['crpix2'] = new_meta['crpix2'] - MDImap.data.shape[1] / 2 + new_fov.shape[1] / 2

# Identify the indices for appending the map original FoV
i1 = int(new_fov.shape[0] / 2 - MDImap.data.shape[0] / 2)
i2 = int(new_fov.shape[0] / 2 + MDImap.data.shape[0] / 2)

# Insert original image in new field of view
new_fov[i1:i2, i1:i2] = MDImap.data[:, :]

# Assemble Sunpy map
MDImap = Map(new_fov, new_meta)
MDImap = MDImap.rotate(scale=scale_factor, recenter=True)


# # Crop HMI image to desired shape
sz_x_diff = (MDImap.data.shape[0]-target_shape)//2
sz_y_diff = (MDImap.data.shape[0]-target_shape)//2

print(MDImap.meta['crpix1'])
print(MDImap.meta['crpix2'])

MDImap.meta['crpix1'] = MDImap.meta['crpix1']-sz_x_diff
MDImap.meta['crpix2'] = MDImap.meta['crpix2']-sz_y_diff

MDImap = Map(MDImap.data[sz_x_diff:sz_x_diff+target_shape, sz_y_diff:sz_y_diff+target_shape].copy(), MDImap.meta)
```

Resolving patch:
diff --git a/sunpy/map/mapbase.py b/sunpy/map/mapbase.py
--- a/sunpy/map/mapbase.py
+++ b/sunpy/map/mapbase.py
@@ -7,6 +7,7 @@
 import inspect
 import numbers
 import textwrap
+import itertools
 import webbrowser
 from io import BytesIO
 from base64 import b64encode
@@ -1594,6 +1595,7 @@ def rotate(self, angle: u.deg = None, rmatrix=None, order=4, scale=1.0,
         if angle is not None and rmatrix is not None:
             raise ValueError("You cannot specify both an angle and a rotation matrix.")
         elif angle is None and rmatrix is None:
+            # Be aware that self.rotation_matrix may not actually be a pure rotation matrix
             rmatrix = self.rotation_matrix
 
         if order not in range(6):
@@ -1615,15 +1617,24 @@ def rotate(self, angle: u.deg = None, rmatrix=None, order=4, scale=1.0,
             rmatrix = np.array([[c, -s],
                                 [s, c]])
 
+        # The data will be rotated by the inverse of the rotation matrix. Because rmatrix may not
+        # actually be a pure rotation matrix, we calculate the inverse in a general manner.
+        inv_rmatrix = np.linalg.inv(rmatrix)
+
         # Calculate the shape in pixels to contain all of the image data
-        extent = np.max(np.abs(np.vstack((self.data.shape @ rmatrix,
-                                          self.data.shape @ rmatrix.T))), axis=0)
+        corners = itertools.product([-0.5, self.data.shape[1]-0.5],
+                                    [-0.5, self.data.shape[0]-0.5])
+        rot_corners = np.vstack([rmatrix @ c for c in corners]) * scale
+        extent = np.max(rot_corners, axis=0) - np.min(rot_corners, axis=0)
 
         # Calculate the needed padding or unpadding
-        diff = np.asarray(np.ceil((extent - self.data.shape) / 2), dtype=int).ravel()
+        diff = np.asarray(np.ceil((extent - np.flipud(self.data.shape)) / 2), dtype=int)
+        pad_x = np.max((diff[0], 0))
+        pad_y = np.max((diff[1], 0))
+        unpad_x = -np.min((diff[0], 0))
+        unpad_y = -np.min((diff[1], 0))
+
         # Pad the image array
-        pad_x = int(np.max((diff[1], 0)))
-        pad_y = int(np.max((diff[0], 0)))
 
         if issubclass(self.data.dtype.type, numbers.Integral) and (missing % 1 != 0):
             warn_user("The specified `missing` value is not an integer, but the data "
@@ -1646,18 +1657,18 @@ def rotate(self, angle: u.deg = None, rmatrix=None, order=4, scale=1.0,
             pixel_center = pixel_array_center
 
         # Apply the rotation to the image data
-        new_data = affine_transform(new_data.T,
-                                    np.asarray(rmatrix),
+        new_data = affine_transform(new_data,
+                                    np.asarray(inv_rmatrix),
                                     order=order, scale=scale,
-                                    image_center=np.flipud(pixel_center),
+                                    image_center=pixel_center,
                                     recenter=recenter, missing=missing,
-                                    method=method).T
+                                    method=method)
 
         if recenter:
             new_reference_pixel = pixel_array_center
         else:
             # Calculate new pixel coordinates for the rotation center
-            new_reference_pixel = pixel_center + np.dot(rmatrix,
+            new_reference_pixel = pixel_center + np.dot(rmatrix * scale,
                                                         pixel_rotation_center - pixel_center)
             new_reference_pixel = np.array(new_reference_pixel).ravel()
 
@@ -1668,11 +1679,9 @@ def rotate(self, angle: u.deg = None, rmatrix=None, order=4, scale=1.0,
         new_meta['crpix2'] = new_reference_pixel[1] + 1  # FITS pixel origin is 1
 
         # Unpad the array if necessary
-        unpad_x = -np.min((diff[1], 0))
         if unpad_x > 0:
             new_data = new_data[:, unpad_x:-unpad_x]
             new_meta['crpix1'] -= unpad_x
-        unpad_y = -np.min((diff[0], 0))
         if unpad_y > 0:
             new_data = new_data[unpad_y:-unpad_y, :]
             new_meta['crpix2'] -= unpad_y
@@ -1681,7 +1690,7 @@ def rotate(self, angle: u.deg = None, rmatrix=None, order=4, scale=1.0,
         # "subtracting" the rotation matrix used in the rotate from the old one
         # That being calculate the dot product of the old header data with the
         # inverse of the rotation matrix.
-        pc_C = np.dot(self.rotation_matrix, np.linalg.inv(rmatrix))
+        pc_C = np.dot(self.rotation_matrix, inv_rmatrix)
         new_meta['PC1_1'] = pc_C[0, 0]
         new_meta['PC1_2'] = pc_C[0, 1]
         new_meta['PC2_1'] = pc_C[1, 0]


Test patch:
diff --git a/sunpy/map/tests/test_mapbase.py b/sunpy/map/tests/test_mapbase.py
--- a/sunpy/map/tests/test_mapbase.py
+++ b/sunpy/map/tests/test_mapbase.py
@@ -10,6 +10,7 @@
 import numpy as np
 import pytest
 from hypothesis import given, settings
+from matplotlib.figure import Figure
 from packaging import version
 
 import astropy.units as u
@@ -864,6 +865,10 @@ def calc_new_matrix(angle):
 
 
 def test_rotate(aia171_test_map):
+    # The test map has big-endian floats, so we switch it to floats with native byte ordering
+    # Otherwise, errors can be raised by code that has been compiled
+    aia171_test_map._data = aia171_test_map.data.astype('float')
+
     rotated_map_1 = aia171_test_map.rotate(20 * u.deg)
     rotated_map_2 = rotated_map_1.rotate(20 * u.deg)
     np.testing.assert_allclose(rotated_map_1.rotation_matrix,
@@ -879,17 +884,14 @@ def test_rotate(aia171_test_map):
     np.testing.assert_allclose(rotated_map_2.data[0, 0], 0., atol=1e-7)
     assert rotated_map_2.mean() < rotated_map_1.mean() < aia171_test_map.mean()
 
-    rotated_map_3 = aia171_test_map.rotate(0 * u.deg, scale=1.5)
-    assert rotated_map_3.mean() > aia171_test_map.mean()
+    # A scaled-up map should have the same mean because the output map should be expanded
+    rotated_map_3 = aia171_test_map.rotate(0 * u.deg, order=3, scale=2)
+    np.testing.assert_allclose(aia171_test_map.mean(), rotated_map_3.mean(), rtol=1e-4)
 
-    # Mean and std should be equal when angle of rotation is integral multiple
-    # of 90 degrees for a square map
-    rotated_map_4 = aia171_test_map.rotate(90 * u.deg, scale=1.5)
-    np.testing.assert_allclose(rotated_map_3.mean(), rotated_map_4.mean(), rtol=1e-3)
-    np.testing.assert_allclose(rotated_map_3.std(), rotated_map_4.std(), rtol=1e-3)
-    rotated_map_5 = aia171_test_map.rotate(180 * u.deg, scale=1.5)
-    np.testing.assert_allclose(rotated_map_3.mean(), rotated_map_5.mean(), rtol=1e-3)
-    np.testing.assert_allclose(rotated_map_3.std(), rotated_map_5.std(), rtol=2e-3)
+    # Mean and std should be equal for a 90 degree rotation
+    rotated_map_4 = aia171_test_map.rotate(90 * u.deg, order=3, scale=2)
+    np.testing.assert_allclose(rotated_map_3.mean(), rotated_map_4.mean(), rtol=1e-10)
+    np.testing.assert_allclose(rotated_map_3.std(), rotated_map_4.std(), rtol=1e-10)
 
     # Rotation of a rectangular map by a large enough angle will change which dimension is larger
     aia171_test_map_crop = aia171_test_map.submap(
@@ -1422,6 +1424,25 @@ def test_rotation_rect_pixelated_data(aia171_test_map):
     rect_rot_map.peek()
 
 
+@figure_test
+def test_derotating_nonpurerotation_pcij(aia171_test_map):
+    # The following map has a a PCij matrix that is not a pure rotation
+    weird_map = aia171_test_map.rotate(30*u.deg).superpixel([2, 1]*u.pix)
+
+    # De-rotating the map by its PCij matrix should result in a normal-looking map
+    derotated_map = weird_map.rotate()
+
+    fig = Figure(figsize=(8, 4))
+
+    ax1 = fig.add_subplot(121, projection=weird_map)
+    weird_map.plot(axes=ax1, title='Map with a non-pure-rotation PCij matrix')
+
+    ax2 = fig.add_subplot(122, projection=derotated_map)
+    derotated_map.plot(axes=ax2, title='De-rotated map')
+
+    return fig
+
+
 # This function is used in the arithmetic tests below
 def check_arithmetic_value_and_units(map_new, data_expected):
     assert u.allclose(map_new.quantity, data_expected)
diff --git a/sunpy/tests/figure_hashes_mpl_332_ft_261_astropy_421_animators_100.json b/sunpy/tests/figure_hashes_mpl_332_ft_261_astropy_421_animators_100.json
--- a/sunpy/tests/figure_hashes_mpl_332_ft_261_astropy_421_animators_100.json
+++ b/sunpy/tests/figure_hashes_mpl_332_ft_261_astropy_421_animators_100.json
@@ -7,6 +7,7 @@
   "sunpy.map.tests.test_compositemap.test_set_alpha_composite_map": "b9d729ff67b0307c037ac62f97c69bb80d22ad6679a0e56352075845d4ef732b",
   "sunpy.map.tests.test_compositemap.test_peek_composite_map": "bd1df27446d420f3e8cb6fa07daff88b77dd0f0441e4acf83ee48341a9e3218f",
   "sunpy.map.tests.test_mapbase.test_rotation_rect_pixelated_data": "3835ea8f163f8b843088524fb6f1ea7b1357ee00224bd9ea625c641d974f17b7",
+  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij": "ea58afbd8814f450c2f22ac942400b19e67256a6342082570147e5155530b74e",
   "sunpy.map.tests.test_mapsequence.test_norm_animator": "a400eef26fdd915ccf12769031bd8dadedbf8f6a93e31cd5982bffe2484702ee",
   "sunpy.map.tests.test_mapsequence.test_map_sequence_plot": "15c8aee7d3a6ff3d39856dc37e02afa983e1ebc1582f810699defd14b3f6cec7",
   "sunpy.map.tests.test_mapsequence.test_map_sequence_plot_custom_cmap_norm": "7f115dedaf88653dff71f784b0d8173d66638e429f92239a7a6f7d6f10fd05f5",
diff --git a/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json b/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json
--- a/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json
+++ b/sunpy/tests/figure_hashes_mpl_dev_ft_261_astropy_dev_animators_dev.json
@@ -7,6 +7,7 @@
   "sunpy.map.tests.test_compositemap.test_set_alpha_composite_map": "f811cf6be8abfcd1b3a7f4bae7781806b37b1b2b0cb77ac34c4ed4491a1d633a",
   "sunpy.map.tests.test_compositemap.test_peek_composite_map": "d459182443472330df9b0dde3bde647479c100f56f45b6321619fbf67810c174",
   "sunpy.map.tests.test_mapbase.test_rotation_rect_pixelated_data": "817ef0de8c17246fc4e728d55d119b08c803b6a6906d4b0923f3969eaab0177f",
+  "sunpy.map.tests.test_mapbase.test_derotating_nonpurerotation_pcij": "3405502781af50c4f4d9610d6c36f5aa8915159cd9c734505a34d4eeba7a6d2c",
   "sunpy.map.tests.test_mapsequence.test_norm_animator": "5a7217dd55ab3a593a27741fecfe0851208fd1d31baeca4cbd018d65aaf3f483",
   "sunpy.map.tests.test_mapsequence.test_map_sequence_plot": "3cdc53f41914028a4e9f23112d7e616ce8a423ef5670c8365fdadbc0e59780ca",
   "sunpy.map.tests.test_mapsequence.test_map_sequence_plot_custom_cmap_norm": "c1276702147f4debaa14c17f6b0b2ab7f9e0b1ace309c72e767b2af071c4405e",