diff --git a/proseco/characteristics_guide.py b/proseco/characteristics_guide.py index 997320e2..458ee630 100644 --- a/proseco/characteristics_guide.py +++ b/proseco/characteristics_guide.py @@ -9,6 +9,9 @@ # Minimum scaled guide count for thumbs_up min_guide_count = 4.0 +# Add this padding to region checked for bad pixels (in addition to dither) +dither_pix_pad = 0.4 + CCD = {'row_min': -512.0, 'row_max': 512.0, 'col_min': -512.0, diff --git a/proseco/guide.py b/proseco/guide.py index 580af193..3716c3f0 100644 --- a/proseco/guide.py +++ b/proseco/guide.py @@ -606,6 +606,30 @@ def check_column_spoilers(cand_stars, ok, stars, n_sigma): return column_spoiled, rej +def get_ax_range(rc, extent): + """ + Given a float pixel row or col value and an "extent" in float pixels, + generally 4 + 1.6 for 8" dither and 4 + 5.0 for 20" dither, + return a range for the row or col that is divisible by 2 and contains + at least the requested extent. + + :param rc: row or col float value (edge pixel coords) + :param extent: half of desired range from n (should include pixel dither) + :returns: tuple of range as (minus, plus) + """ + minus = int(np.floor(rc - extent)) + plus = int(np.ceil(rc + extent)) + # If there isn't an even range of pixels, add or subtract one from the range + if (plus - minus) % 2 != 0: + # If the "rc" value in on the 'right' side of a pixel, add one to the plus + if rc - np.floor(rc) > 0.5: + plus += 1 + # Otherwise subtract one from the minus + else: + minus -= 1 + return minus, plus + + def get_imposter_mags(cand_stars, dark, dither): """ Get "pseudo-mag" of max pixel value in each candidate star region @@ -615,27 +639,15 @@ def get_imposter_mags(cand_stars, dark, dither): :param dither: observation dither to be used to determine pixels a star could use :returns: np.array pixmags, np.array pix_r, np.array pix_c all of length cand_stars """ - def get_ax_range(r, extent): - - # Should come back to this and do something smarter - # but right now I just want things that bin nicely 2x2 - rminus = int(np.floor(r - row_extent)) - rplus = int(np.ceil(r + row_extent)) - if (np.floor(r) != np.ceil(r)): - if r - np.floor(r) > .5: - rplus += 1 - else: - rminus -= 1 - return rminus, rplus pixmags = [] pix_r = [] pix_c = [] - # Define the 1/2 pixel region as half the 8x8 plus dither - row_extent = np.ceil(4 + dither.row) - col_extent = np.ceil(4 + dither.col) - for idx, cand in enumerate(cand_stars): + # Define the 1/2 pixel region as half the 8x8 plus a pad plus dither + row_extent = 4 + GUIDE_CHAR.dither_pix_pad + dither.row + col_extent = 4 + GUIDE_CHAR.dither_pix_pad + dither.col + for cand in cand_stars: rminus, rplus = get_ax_range(cand['row'], row_extent) cminus, cplus = get_ax_range(cand['col'], col_extent) pix = np.array(dark.aca[rminus:rplus, cminus:cplus]) @@ -653,7 +665,9 @@ def get_ax_range(r, extent): idx = np.unravel_index(np.argmax(bin_image), bin_image.shape) max_r = rminus + row_off + idx[0] * 2 max_c = cminus + col_off + idx[1] * 2 - pixmax_mag = count_rate_to_mag(pixmax) + # Get the mag equivalent to pixmax. If pixmax is zero (for a synthetic dark map) + # clip lower bound at 1.0 to avoid 'inf' mag and warnings from chandra_aca.transform + pixmax_mag = count_rate_to_mag(np.clip(pixmax, 1.0, None)) pixmags.append(pixmax_mag) pix_r.append(max_r) pix_c.append(max_c) diff --git a/proseco/tests/test_guide.py b/proseco/tests/test_guide.py index 144ab8fc..f64d1561 100644 --- a/proseco/tests/test_guide.py +++ b/proseco/tests/test_guide.py @@ -11,10 +11,10 @@ from chandra_aca.transform import mag_to_count_rate, count_rate_to_mag from ..guide import (get_guide_catalog, check_spoil_contrib, get_pixmag_for_offset, - check_mag_spoilers) -from ..characteristics_guide import mag_spoiler + check_mag_spoilers, get_ax_range) +from ..characteristics_guide import mag_spoiler, CCD from ..core import StarsTable -from .test_common import STD_INFO +from .test_common import STD_INFO, mod_std_info HAS_SC_ARCHIVE = Path(mica.starcheck.starcheck.FILES['data_root']).exists() @@ -203,6 +203,36 @@ def test_check_spoil_contrib(): assert bg_spoil[0] +pix_cases = [{'dither': (8, 8), 'offset_row': 4, 'offset_col': 4, 'spoils': True}, + {'dither': (64, 8), 'offset_row': 16, 'offset_col': 0, 'spoils': True}, + {'dither': (64, 8), 'offset_row': 20, 'offset_col': 0, 'spoils': False}, + {'dither': (64, 8), 'offset_row': 0, 'offset_col': 16, 'spoils': False}, + {'dither': (8, 64), 'offset_row': 0, 'offset_col': 16, 'spoils': True}, + {'dither': (8, 64), 'offset_row': 0, 'offset_col': 20, 'spoils': False}] + + +@pytest.mark.parametrize('case', pix_cases) +def test_pix_spoiler(case): + """ + Check that for various dither configurations, a hot pixel near a star will + result in that star not being selected. + """ + stars = StarsTable.empty() + stars.add_fake_star(row=0, col=0, mag=7.0, id=1, ASPQ1=0) + stars.add_fake_constellation(n_stars=4) + dark = ACAImage(np.zeros((1024, 1024)), row0=-512, col0=-512) + pix_config = {'att': (0, 0, 0), + 'date': '2018:001', + 't_ccd': -10, + 'n_guide': 5, + 'stars': stars} + # Use the "case" to try to spoil the first star with a bad pixel + dark.aca[case['offset_row'] + int(stars[0]['row']), + case['offset_col'] + int(stars[0]['col'])] = mag_to_count_rate(stars[0]['mag']) + selected = get_guide_catalog(**pix_config, dither=case['dither'], dark=dark) + assert (1 not in selected['id']) == case['spoils'] + + def test_check_mag_spoilers(): """ Check that stars that should fail the mag/line test actually fail @@ -286,3 +316,54 @@ def test_guides_include_exclude(): assert np.all(guides['id'] == [9, 11, 2, 3, 4, 5, 6, 7]) assert np.allclose(guides['mag'], [10.0, 12.0, 7.1, 7.2, 7.3, 7.4, 7.5, 7.6]) + + +dither_cases = [(8, 8), (64, 8), (8, 64), (20, 20), (30, 20)] + +@pytest.mark.parametrize('dither', dither_cases) +def test_edge_star(dither): + """ + Add stars right at row and col max for various dithers. + This test both confirms that the dark map extraction doesn't break and that + the stars can still be selected. + """ + stars = StarsTable.empty() + + stars.add_fake_constellation(mag=[7.0, 7.1, 7.2, 7.3], + id=[1, 2, 3, 4], + size=2000, n_stars=4) + + # Add stars exactly at 4 corners of allowed "in bounds" area for this dither + row_dither = dither[0] / 5. + col_dither = dither[1] / 5. + row_max = CCD['row_max'] - (CCD['row_pad'] + CCD['window_pad'] + row_dither) + col_max = CCD['col_max'] - (CCD['col_pad'] + CCD['window_pad'] + col_dither) + stars.add_fake_star(row=row_max, col=col_max, mag=6.0) + stars.add_fake_star(row=row_max * -1, col=col_max, mag=6.0) + stars.add_fake_star(row=row_max * -1, col=col_max * -1, mag=6.0) + stars.add_fake_star(row=row_max, col=col_max * -1, mag=6.0) + info = mod_std_info(n_guide=8, dither_guide=(row_dither * 5, col_dither * 5), stars=stars) + guides = get_guide_catalog(**info) + # Confirm 4 generic stars plus for corner stars are selected + assert len(guides) == 8 + + +def test_get_ax_range(): + """ + Confirm that the ranges from get_ax_range are reasonable for a variety of + center pixel locations and extents (extent = 4 + pix_dither) + """ + ns = [0, 0.71, 495.3, -200.2] + extents = [4.0, 5.6, 4.8, 9.0] + for (n, extent) in itertools.product(ns, extents): + minus, plus = get_ax_range(n, extent) + # Confirm range divisable by 2 + assert (plus - minus) % 2 == 0 + # Confirm return order + assert plus > minus + # Confirm the range contains the full extent + assert n + extent <= plus + assert n - extent >= minus + # Confirm the range does not contain more than 2 pix extra on either side + assert n + extent + 2 > plus + assert n - extent - 2 < minus