diff --git a/.travis.yml b/.travis.yml index d2841278a..f71f26238 100644 --- a/.travis.yml +++ b/.travis.yml @@ -91,3 +91,5 @@ cache: - $PANDIR/astrometry/ after_success: - bash <(curl -s https://codecov.io/bash) +after_failure: + - bash scripts/testing/failed-images-upload.sh ${POCS}/test_images/ diff --git a/pocs/tests/utils/baseline_images/test_plot_dither.png b/pocs/tests/utils/baseline_images/test_plot_dither.png new file mode 100644 index 000000000..c9d71f89a Binary files /dev/null and b/pocs/tests/utils/baseline_images/test_plot_dither.png differ diff --git a/pocs/tests/utils/test_dither.py b/pocs/tests/utils/test_dither.py new file mode 100644 index 000000000..01df88461 --- /dev/null +++ b/pocs/tests/utils/test_dither.py @@ -0,0 +1,191 @@ +import pytest +import astropy.units as u +from astropy.coordinates import SkyCoord +from astropy.coordinates import Angle + +from pocs.utils import dither + + +def test_dice9_SkyCoord(): + base = SkyCoord("16h52m42.2s -38d37m12s") + + positions = dither.get_dither_positions(base_position=base, + num_positions=12, + pattern=dither.dice9, + pattern_offset=30 * u.arcminute) + + assert isinstance(positions, SkyCoord) + assert len(positions) == 12 + # postion 0 should be the base position + assert positions[0].separation(base) < Angle(1e12 * u.degree) + # With no random offset positions 9, 10, 11 should be the same as 0, 1, 2 + assert positions[0:3].to_string() == positions[9:12].to_string() + # Position 1 should be 30 arcminute offset from base, in declination direction only + assert base.spherical_offsets_to( + positions[1])[0].radian == pytest.approx(Angle(0 * u.degree).radian) + assert base.spherical_offsets_to( + positions[1])[1].radian == pytest.approx(Angle(0.5 * u.degree).radian) + # Position 3 should be 30 arcminute offset from base in RA only. + assert base.spherical_offsets_to( + positions[3])[0].radian == pytest.approx(Angle(0.5 * u.degree).radian) + assert base.spherical_offsets_to( + positions[3])[1].radian == pytest.approx(Angle(0 * u.degree).radian) + + +def test_dice9_string(): + base = "16h52m42.2s -38d37m12s" + + positions = dither.get_dither_positions(base_position=base, + num_positions=12, + pattern=dither.dice9, + pattern_offset=30 * u.arcminute) + + base = SkyCoord(base) + + assert isinstance(positions, SkyCoord) + assert len(positions) == 12 + # postion 0 should be the base position + assert positions[0].separation(base) < Angle(1e12 * u.degree) + # With no random offset positions 9, 10, 11 should be the same as 0, 1, 2 + assert positions[0:3].to_string() == positions[9:12].to_string() + # Position 1 should be 30 arcminute offset from base, in declination direction only + assert base.spherical_offsets_to( + positions[1])[0].radian == pytest.approx(Angle(0 * u.degree).radian) + assert base.spherical_offsets_to( + positions[1])[1].radian == pytest.approx(Angle(0.5 * u.degree).radian) + # Position 3 should be 30 arcminute offset from base in RA only. + assert base.spherical_offsets_to( + positions[3])[0].radian == pytest.approx(Angle(0.5 * u.degree).radian) + assert base.spherical_offsets_to( + positions[3])[1].radian == pytest.approx(Angle(0 * u.degree).radian) + + +def test_dice9_bad_base_position(): + with pytest.raises(ValueError): + dither.get_dither_positions(base_position=42, + num_positions=42, + pattern=dither.dice9, + pattern_offset=300 * u.arcsecond) + + +def test_dice9_random(): + base = SkyCoord("16h52m42.2s -38d37m12s") + + # Offsets don't have units so added as arcseconds + positions = dither.get_dither_positions(base_position=base, + num_positions=12, + pattern=dither.dice9, + pattern_offset=30 * 60, + random_offset=30) + + assert isinstance(positions, SkyCoord) + assert len(positions) == 12 + # postion 0 should be the base position + assert positions[0].separation(base) < Angle(30 * 2**0.5 * u.arcsecond) + + angle_0 = Angle(0 * u.degree).radian + angle_05 = Angle(0.5 * u.degree).radian + angle_30 = Angle(30 * u.arcsecond).radian + position_1_offset = base.spherical_offsets_to(positions[1]) + position_3_offset = base.spherical_offsets_to(positions[3]) + + # Position 1 should be 30 arcminute offset from base, in declination direction only + assert position_1_offset[0].radian == pytest.approx(angle_0, abs=angle_30) + assert position_1_offset[1].radian == pytest.approx(angle_05, abs=angle_30) + + # Position 3 should be 30 arcminute offset from base in RA only. + assert position_3_offset[0].radian == pytest.approx(angle_05, abs=angle_30) + assert position_3_offset[1].radian == pytest.approx(angle_0, abs=angle_30) + + +def test_random(): + base = SkyCoord("16h52m42.2s -38d37m12s") + + positions = dither.get_dither_positions(base_position=base, + num_positions=12, + random_offset=30 * u.arcsecond) + assert isinstance(positions, SkyCoord) + assert len(positions) == 12 + + angle_0 = Angle(0 * u.degree).radian + angle_30 = Angle(30 * u.arcsecond).radian + position_0_offset = base.spherical_offsets_to(positions[0]) + position_1_offset = base.spherical_offsets_to(positions[1]) + + assert position_0_offset[0].radian == pytest.approx(angle_0, abs=angle_30) + assert position_0_offset[1].radian == pytest.approx(angle_0, abs=angle_30) + + assert position_1_offset[0].radian == pytest.approx(angle_0, abs=angle_30) + assert position_1_offset[1].radian == pytest.approx(angle_0, abs=angle_30) + + +def test_dice5(): + base = SkyCoord("16h52m42.2s -38d37m12s") + + positions = dither.get_dither_positions(base_position=base, + num_positions=12, + pattern=dither.dice5, + pattern_offset=30 * u.arcminute) + + assert isinstance(positions, SkyCoord) + assert len(positions) == 12 + # postion 0 should be the base position + assert positions[0].separation(base) < Angle(1e12 * u.degree) + # With no random offset positions 5, 6, 7 should be the same as 0, 1, 2 + assert positions[0:3].to_string() == positions[5:8].to_string() + # Position 1 should be 30 arcminute offset from base, in RA and dec + assert base.spherical_offsets_to( + positions[1])[0].radian == pytest.approx(Angle(0.5 * u.degree).radian) + assert base.spherical_offsets_to( + positions[1])[1].radian == pytest.approx(Angle(0.5 * u.degree).radian) + # Position 3 should be 30 arcminute offset from base in RA and dec + assert base.spherical_offsets_to(positions[3])[0].radian == pytest.approx( + Angle(-0.5 * u.degree).radian) + assert base.spherical_offsets_to(positions[3])[1].radian == pytest.approx( + Angle(-0.5 * u.degree).radian) + + +def test_custom_pattern(): + base = SkyCoord("16h52m42.2s -38d37m12s") + cross = ((0, 0), + (0, 1), + (1, 0), + (0, -1), + (-1, 0)) + + positions = dither.get_dither_positions(base_position=base, + num_positions=12, + pattern=cross, + pattern_offset=1800 * u.arcsecond) + + assert isinstance(positions, SkyCoord) + assert len(positions) == 12 + # postion 0 should be the base position + assert positions[0].separation(base) < Angle(1e12 * u.degree) + # With no random offset positions 5, 6, 7 should be the same as 0, 1, 2 + assert positions[0:3].to_string() == positions[5:8].to_string() + # Position 3 should be 30 arcminute offset from base, in declination direction only + assert base.spherical_offsets_to( + positions[3])[0].radian == pytest.approx(Angle(0 * u.degree).radian) + assert base.spherical_offsets_to(positions[3])[1].radian == pytest.approx( + Angle(-0.5 * u.degree).radian) + # Position 4 should be 30 arcminute offset from base in RA only. + assert base.spherical_offsets_to(positions[4])[0].radian == pytest.approx( + Angle(-0.5 * u.degree).radian) + assert base.spherical_offsets_to( + positions[4])[1].radian == pytest.approx(Angle(0 * u.degree).radian) + + +# Note that the tolerance is way to high for this to be an effective test but +# we are waiting on some clarity from the module. +# https://github.com/matplotlib/pytest-mpl/issues/81 +@pytest.mark.mpl_image_compare(baseline_dir='baseline_images', tolerance=15) +def test_plot_dither(tmpdir): + base = SkyCoord("16h52m42.2s -38d37m12s") + positions = dither.get_dither_positions(base_position=base, + num_positions=12, + pattern=dither.dice9, + pattern_offset=30 * u.arcminute) + + dither_figure = dither.plot_dither_pattern(positions) + return dither_figure diff --git a/pocs/utils/dither.py b/pocs/utils/dither.py new file mode 100644 index 000000000..021a17fb4 --- /dev/null +++ b/pocs/utils/dither.py @@ -0,0 +1,220 @@ +import numpy as np +import astropy.units as u +from astropy.coordinates import SkyCoord +from astropy.coordinates import SkyOffsetFrame +from astropy.coordinates import ICRS +from astropy.wcs import WCS + +from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas +from matplotlib.figure import Figure + +# Pattern for dice 9 3x3 grid (sequence of (RA offset, dec offset) pairs) +dice9 = ((0, 0), + (0, 1), + (1, 1), + (1, 0), + (1, -1), + (0, -1), + (-1, -1), + (-1, 0), + (-1, 1)) + + +# Pattern for dice 5 grid (sequence of (RA offset, dec offset) pairs) +dice5 = ((0, 0), + (1, 1), + (1, -1), + (-1, -1), + (-1, 1)) + + +def get_dither_positions(base_position, + num_positions, + pattern=None, + pattern_offset=30 * u.arcminute, + random_offset=None): + """Create a a dithering pattern for a given position. + + Given a base position creates a SkyCoord list of dithered sky positions, + applying a dither pattern and/or random dither offsets. + + .. code-block:: python + + >>> # Get 10 positions follwing a dice9 pattern with no random offset. + >>> from astropy.coordinates import SkyCoord + >>> from pocs.utils import dither + >>> from pocs.utils import altaz_to_radec + >>> base_position = SkyCoord("16h52m42.2s -38d37m12s") + >>> dither.get_dither_positions(base_position=base_position, \ + num_positions=10, \ + pattern=dither.dice9) + + + Most likely you will want to apply a random offset to each position: + + .. code-block:: python + + >>> # Get 10 positions follwing a dice9 pattern with random offset. + >>> from astropy.coordinates import SkyCoord + >>> from pocs.utils import dither + >>> from pocs.utils import altaz_to_radec + >>> base_position = SkyCoord("16h52m42.2s -38d37m12s") + >>> dither.get_dither_positions(base_position=base_position, \ + num_positions=10, \ + pattern=dither.dice9, \ + random_offset=10 * u.arcmin) # doctest: +SKIP + + + Args: + base_position (SkyCoord or compatible): base position for the dither pattern, + either a SkyCoord or an object that can be converted to one by the SkyCoord + constructor (e.g. string). + num_positions (int): number of dithered sky positions to generate. + pattern (sequence of 2-tuples, optional): sequence of (RA offset, dec offset) + tuples, in units of the pattern_offset. If given pattern_offset must also + be specified. Two pre-defined patterns are included in this module, + `pocs.utils.dither.dice5` and `pocs.utils.dither.dice9`. + pattern_offset (Quantity, optional): scale for the dither pattern. Should + be a Quantity with angular units, if a numeric type is passed instead + it will be assumed to be in arceconds. If pattern offset is given pattern + must be given too. Default 30 arcminutes. + random_offset (Quantity, optional): scale of the random offset to apply + to both RA and dec. Should be a Quantity with angular units, if numeric + type passed instead it will be assumed to be in arcseconds. + + Returns: + SkyCoord: list of num_positions dithered sky positions. + + Raises: + ValueError: Raised if the `base_position` is not a valid `astropy.coordinates.SkyCoord`. + """ + if not isinstance(base_position, SkyCoord): + try: + base_position = SkyCoord(base_position) + except ValueError: + raise ValueError(f"Base position '{base_position}' cannot be converted to a SkyCoord") + + # Use provided pattern if given. + if pattern: + if not isinstance(pattern_offset, u.Quantity): + pattern_offset = pattern_offset * u.arcsec + + # Iterate over the pattern for num_positions (i.e. cycle through the pattern) + ra_offsets = [_get_pattern_position(i, pattern)[0] for i in range(num_positions)] + dec_offsets = [_get_pattern_position(i, pattern)[1] for i in range(num_positions)] + + # Apply offsets to positions + ra_offsets *= pattern_offset + dec_offsets *= pattern_offset + + else: + ra_offsets = np.zeros(num_positions) * u.arcsec + dec_offsets = np.zeros(num_positions) * u.arcsec + + if random_offset: + if not isinstance(random_offset, u.Quantity): + random_offset = random_offset * u.arcsec + + # Apply random offsets + ra_offsets += np.random.uniform(low=-1, high=+1, size=ra_offsets.shape) * random_offset + dec_offsets += np.random.uniform(low=-1, high=+1, size=dec_offsets.shape) * random_offset + + offsets = SkyOffsetFrame(lon=ra_offsets, lat=dec_offsets, origin=base_position) + positions = offsets.transform_to(ICRS) + + dither_coords = SkyCoord(positions) + + return dither_coords + + +def _get_pattern_position(index, pattern): + """Utility function to get a position index from the given pattern. + + Args: + index (int): The requested index position. + pattern (seqeunce of 2-tuples): The pattern to get the position from. + + Returns: + tuple: The pattern value at the corresponding position. + """ + return pattern[index % len(pattern)] + + +def plot_dither_pattern(dither_positions): + """Utility function to generate a plot of the dither pattern. + + Args: + dither_positions (SkyCoord): SkyCoord positions to be plotted as generated from + `get_dither_positions`. + + .. code-block:: python + + >>> # Get 10 positions follwing a dice9 pattern with random offset. + >>> from astropy.coordinates import SkyCoord + >>> from pocs.utils import dither + >>> from pocs.utils import altaz_to_radec + >>> base_position = SkyCoord("16h52m42.2s -38d37m12s") + >>> dither.get_dither_positions(base_position=base_position, \ + num_positions=10, \ + pattern=dither.dice9, \ + random_offset=10 * u.arcmin) # doctest: +SKIP + + + .. plot:: + + from matplotlib import pyplot as plt + # Get 10 positions follwing a dice9 pattern with random offset. + from astropy.coordinates import SkyCoord + from pocs.utils import dither + from pocs.utils import altaz_to_radec + base_position = SkyCoord("16h52m42.2s -38d37m12s") + positions = dither.get_dither_positions(base_position=base_position, \ + num_positions=10, \ + pattern=dither.dice9, \ + random_offset=10 * u.arcmin) + dither.plot_dither_pattern(positions) + + Returns: + `matplotlib.figure.Figure`: The matplotlib plot. + """ + fig = Figure() + FigureCanvas(fig) + + base_position = dither_positions[0] + + dummy_wcs = WCS(naxis=2) + dummy_wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] + dummy_wcs.wcs.crval = [base_position.ra.value, base_position.dec.value] + ax = fig.add_subplot(111, projection=dummy_wcs) + + ax.plot(dither_positions.ra, dither_positions.dec, 'b*-', transform=ax.get_transform('world')) + ax.plot(base_position.ra.value, base_position.dec.value, + 'rx', transform=ax.get_transform('world')) + + ax.set_aspect('equal', adjustable='datalim') + ax.coords[0].set_axislabel('Right Ascension') + ax.coords[0].set_major_formatter('hh:mm') + ax.coords[1].set_axislabel('Declination') + ax.coords[1].set_major_formatter('dd:mm') + ax.grid() + + ax.set_title(base_position.to_string('hmsdms')) + + fig.set_size_inches(8, 8.5) + + return fig diff --git a/requirements.txt b/requirements.txt index e3b5c90a0..ca4b728c7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ pycodestyle == 2.3.1 pymongo >= 3.2.2 pyserial >= 3.1.1 pytest >= 3.4.0 +pytest-mpl python_dateutil >= 2.5.3 PyYAML >= 3.11 pyzmq >= 15.3.0 diff --git a/scripts/testing/failed-images-upload.sh b/scripts/testing/failed-images-upload.sh new file mode 100755 index 000000000..2e6948f98 --- /dev/null +++ b/scripts/testing/failed-images-upload.sh @@ -0,0 +1,12 @@ +#!/bin/bash +e + +UPLOAD_DIR=$1 +echo "Zipping files found in ${UPLOAD_DIR}" + +tar zcf failed-images.tgz $UPLOAD_DIR + +echo "Uploading public temporary hosting site" +echo "Download failed images from:" > temp.txt +curl --upload-file failed-images.tgz https://transfer.sh/failed-images.tgz >> temp.txt +echo '\n There will be one subfolder per failed test.' >> temp.txt +cat temp.txt diff --git a/setup.cfg b/setup.cfg index 14e79ace1..2b27fbe83 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,7 +5,7 @@ test=pytest testpaths= pocs/tests/ peas/tests/ python_files= test_*.py norecursedirs= scripts -addopts= --doctest-modules +addopts= --doctest-modules --mpl --mpl-results-path=test_images doctest_optionflags= ELLIPSIS NORMALIZE_WHITESPACE ALLOW_UNICODE IGNORE_EXCEPTION_DETAIL filterwarnings = ignore:elementwise == comparison failed:DeprecationWarning