# Copyright (c) 2012-2026 by the GalSim developers team on GitHub
# https://github.com/GalSim-developers
#
# This file is part of GalSim: The modular galaxy image simulation toolkit.
# https://github.com/GalSim-developers/GalSim
#
# GalSim is free software: redistribution and use in source and binary forms,
# with or without modification, are permitted provided that the following
# conditions are met:
#
# 1. Redistributions of source code must retain the above copyright notice, this
#    list of conditions, and the disclaimer given in the accompanying LICENSE
#    file.
# 2. Redistributions in binary form must reproduce the above copyright notice,
#    this list of conditions, and the disclaimer given in the documentation
#    and/or other materials provided with the distribution.
#

import logging
import os
import sys
import numpy as np
import datetime
from unittest import mock

import galsim
import galsim.roman
from galsim_test_helpers import *


@timer
def skip_roman_wcs():
    """Test the Roman WCS routines against ones provided by the Roman project office.
    """
    # This test is out of date and is not run, but since it was a useful test, the code is kept here
    # as a reminder to reinstate it if/when we get an updated version of the WCS software from the
    # Roman project office for cycle 7+.  Everything below this comment is the original code from
    # GalSim v1.4.
    ######################################################################################3
    # The standard against which we will compare is the output of some software provided by Jeff
    # Kruk.  The files used here were generated by Rachel on her Macbook using the script in
    # roman_files/make_standards.sh, and none of the parameters below can be changed without
    # modifying and rerunning that script.  We use 4 sky positions and rotation angles (2 defined
    # using the focal plane array, 2 using the observatory coordinates), and in each case, use a
    # different SCA for our tests.  We will simply read in the stored WCS and generate new ones, and
    # check that they have the right value of SCA center and pixel scale at the center, and that if
    # we offset by 500 pixels in some direction that gives the same sky position in each case.
    ra_test = [127., 307.4, -61.52, 0.0]
    dec_test = [-70., 50., 22.7, 0.0]
    pa_test = [160., 79., 23.4, -3.1]
    sca_test = [2, 13, 7, 18]
    import datetime
    ve = datetime.datetime(2025,3,20,9,2,0)
    date_test = [ve, ve, ve, datetime.date(2025,6,20)]
    pa_is_fpa_test = [True, False, True, False]

    dist_arcsec = []
    dist_2_arcsec = []
    pix_area_ratio = []
    for i_test in range(len(ra_test)):
         # Make the WCS for this test.
        world_pos = galsim.CelestialCoord(ra_test[i_test]*galsim.degrees,
                                          dec_test[i_test]*galsim.degrees)
        if i_test == 0:
            # Just for this case, we want to get the WCS for all SCAs.  This will enable some
            # additional tests that we don't do for the other test case.
            gs_wcs_dict = galsim.roman.getWCS(PA=pa_test[i_test]*galsim.degrees,
                                              world_pos=world_pos,
                                              PA_is_FPA=pa_is_fpa_test[i_test],
                                              date=date_test[i_test])
            np.testing.assert_equal(
                len(gs_wcs_dict), galsim.roman.n_sca,
                err_msg='WCS dict has wrong length: %d vs. %d'%(len(gs_wcs_dict),
                                                                galsim.roman.n_sca))
        else:
            # Use the SCAs keyword to just get the WCS for the SCA that we want.
            gs_wcs_dict = galsim.roman.getWCS(PA=pa_test[i_test]*galsim.degrees,
                                              world_pos=world_pos,
                                              PA_is_FPA=pa_is_fpa_test[i_test],
                                              SCAs=sca_test[i_test],
                                              date=date_test[i_test])
            np.testing.assert_equal(
                len(gs_wcs_dict), 1,
                err_msg='WCS dict has wrong length: %d vs. %d'%(len(gs_wcs_dict), 1))

        # Read in reference.
        test_file = 'test%d_sca_%02d.fits'%(i_test+1, sca_test[i_test])
        ref_wcs = galsim.FitsWCS(os.path.join('roman_files',test_file))

        gs_wcs = gs_wcs_dict[sca_test[i_test]]

        # Check center position:
        im_cent_pos = galsim.PositionD(galsim.roman.n_pix/2., galsim.roman.n_pix/2)
        ref_cent_pos = ref_wcs.toWorld(im_cent_pos)
        gs_cent_pos = gs_wcs.toWorld(im_cent_pos)
        dist_arcsec.append(ref_cent_pos.distanceTo(gs_cent_pos) / galsim.arcsec)

        # Check pixel area
        rat = ref_wcs.pixelArea(image_pos=im_cent_pos)/gs_wcs.pixelArea(image_pos=im_cent_pos)
        pix_area_ratio.append(rat-1.)

        # Check another position, just in case rotations are messed up.
        im_other_pos = galsim.PositionD(im_cent_pos.x+500., im_cent_pos.y-200.)
        ref_other_pos = ref_wcs.toWorld(im_other_pos)
        gs_other_pos = gs_wcs.toWorld(im_other_pos)
        dist_2_arcsec.append(ref_other_pos.distanceTo(gs_other_pos) / galsim.arcsec)

        if i_test == 0:
            # For just one of our tests cases, we'll do some additional tests.  These will target
            # the findSCA() functionality.  First, we'll choose an SCA and check that its center is
            # found to be in that SCA.
            found_sca = galsim.roman.findSCA(gs_wcs_dict, gs_cent_pos)
            np.testing.assert_equal(found_sca, sca_test[i_test],
                                    err_msg='Did not find SCA center position to be on that SCA!')

            # Then, we go to a place that should be off the side by a tiny bit, and check that it is
            # NOT on an SCA if we exclude borders, but IS on the SCA if we include borders.
            im_off_edge_pos = galsim.PositionD(-2., galsim.roman.n_pix/2.)
            world_off_edge_pos = gs_wcs.toWorld(im_off_edge_pos)
            found_sca = galsim.roman.findSCA(gs_wcs_dict, world_off_edge_pos)
            assert found_sca is None
            found_sca = galsim.roman.findSCA(gs_wcs_dict, world_off_edge_pos, include_border=True)
            np.testing.assert_equal(found_sca, sca_test[i_test],
                                    err_msg='Did not find slightly off-edge position on the SCA'
                                    ' when including borders!')

    np.testing.assert_array_less(
        np.array(dist_arcsec),
        np.ones(len(ra_test))*galsim.roman.pixel_scale/100,
        err_msg='For at least one WCS, center offset from reference was > 0.01(pixel scale).')
    np.testing.assert_array_less(
        np.array(dist_2_arcsec),
        np.ones(len(ra_test))*galsim.roman.pixel_scale/100,
        err_msg='For at least one WCS, other offset from reference was > 0.01(pixel scale).')
    np.testing.assert_array_less(
        np.array(pix_area_ratio),
        np.ones(len(ra_test))*0.0001,
        err_msg='For at least one WCS, pixel areas differ from reference by >0.01%.')


@timer
def test_roman_wcs(run_slow):
    """Test the Roman WCS routines against the one produced by code from Chris Hirata.
    """
    # The standard against which we will compare is the output of some software provided by Chris
    # Hirata.  The files used here were generated by Rachel on her Macbook using the script in
    # roman_files/, with sky positions randomly selected and then stored as part of the
    # comparison.  We read in a list of FPA center positions and other RA/dec positions, the
    # position angle for the observation, and the SCA those other positions land on (if any).  Then
    # we compare that with the GalSim routines for finding SCAs.
    import datetime
    date = datetime.datetime(2025, 1, 12)
    test_data_file = os.path.join('roman_files','chris_comparison.txt')
    test_data = np.loadtxt(test_data_file).transpose()

    ra_cen = test_data[0,:]
    dec_cen = test_data[1,:]
    ra = test_data[2,:]
    dec = test_data[3,:]
    pa = test_data[4,:]
    chris_sca = test_data[5,:]
    if not run_slow:
        i_start = 4
        n_test = 3  # None of these 3 fail, so the nfail test is ok.
    else:
        i_start = 0
        n_test = len(ra_cen)

    n_fail = 0
    for i_test in range(i_start, i_start+n_test):
        print('i_test = ',i_test)
        # Make the WCS for this test.
        world_pos = galsim.CelestialCoord(ra_cen[i_test]*galsim.degrees,
                                          dec_cen[i_test]*galsim.degrees)
        gs_wcs_dict = galsim.roman.getWCS(PA=pa[i_test]*galsim.radians,
                                          world_pos=world_pos,
                                          PA_is_FPA=True,
                                          date=date)
        np.testing.assert_equal(
            len(gs_wcs_dict), galsim.roman.n_sca,
            err_msg='WCS dict has wrong length: %d vs. %d'%(len(gs_wcs_dict),
                                                            galsim.roman.n_sca))

        found_sca = galsim.roman.findSCA(
            gs_wcs_dict,
            galsim.CelestialCoord(ra[i_test]*galsim.degrees,
                                  dec[i_test]*galsim.degrees))
        if found_sca is None: found_sca=0
        if found_sca != chris_sca[i_test]:
            n_fail += 1
            print('Failed to find SCA: ',found_sca, chris_sca[i_test])

        # Just cycle through the SCAs for the next bits.
        sca_test = i_test % 18 + 1
        gs_wcs = gs_wcs_dict[sca_test]

        # Check center position:
        im_cent_pos = galsim.PositionD(galsim.roman.n_pix/2., galsim.roman.n_pix/2)
        gs_cent_pos = gs_wcs.toWorld(im_cent_pos)

        # Check pixel area
        pix_area = gs_wcs.pixelArea(image_pos=im_cent_pos)
        print('pix_area = ',pix_area)
        np.testing.assert_allclose(pix_area, 0.012, atol=0.001)

        if i_test == 0:
            # For just one of our tests cases, we'll do some additional tests.  These will target
            # the findSCA() functionality.  First, check that the center is found in that SCA.
            found_sca = galsim.roman.findSCA(gs_wcs_dict, gs_cent_pos)
            np.testing.assert_equal(found_sca, sca_test,
                                    err_msg='Did not find SCA center position to be on that SCA!')

            # Then, we go to a place that should be off the side by a tiny bit, and check that it is
            # NOT on an SCA if we exclude borders, but IS on the SCA if we include borders.
            im_off_edge_pos = galsim.PositionD(-2., galsim.roman.n_pix/2.)
            world_off_edge_pos = gs_wcs.toWorld(im_off_edge_pos)
            found_sca = galsim.roman.findSCA(gs_wcs_dict, world_off_edge_pos)
            assert found_sca is None
            found_sca = galsim.roman.findSCA(gs_wcs_dict, world_off_edge_pos, include_border=True)
            np.testing.assert_equal(found_sca, sca_test,
                                    err_msg='Did not find slightly off-edge position on the SCA'
                                    ' when including borders!')

        if i_test < 5:
            # Also make sure that for a given SCA, we find positions on it that should be on it,
            # without/with inclusion of borders.  Just do this test a limited number of times.
            for sca_ind in range(1,19):
                sca_edge_test = sca_ind
                tmp_wcs = gs_wcs_dict[sca_edge_test]
                im_test_pos = galsim.PositionD(10.0, galsim.roman.n_pix/2)
                tmp_pos = tmp_wcs.toWorld(im_test_pos)
                found_sca = galsim.roman.findSCA(gs_wcs_dict, tmp_pos, include_border=False)
                assert found_sca==sca_edge_test
                found_sca = galsim.roman.findSCA(gs_wcs_dict, tmp_pos, include_border=True)
                assert found_sca==sca_edge_test
                im_test_pos = galsim.PositionD(galsim.roman.n_pix/2, galsim.roman.n_pix+3)
                tmp_pos = tmp_wcs.toWorld(im_test_pos)
                found_sca = galsim.roman.findSCA(gs_wcs_dict, tmp_pos, include_border=False)
                assert found_sca==None
                found_sca = galsim.roman.findSCA(gs_wcs_dict, tmp_pos, include_border=True)
                assert found_sca==sca_edge_test

                # And check that we can go from the center of that SCA and reverse-engineer the
                # position of the center of the FPA.
                im_test_pos = galsim.PositionD(galsim.roman.n_pix/2, galsim.roman.n_pix/2)
                test_sca_pos = tmp_wcs.toWorld(im_test_pos)
                test_fpa_pos = galsim.roman.convertCenter(test_sca_pos, int(sca_edge_test),
                                                          PA=pa[i_test]*galsim.radians,
                                                          date=date, PA_is_FPA=True)
                # Also test that convertCenter checks inputs appropriately.
                with assert_raises(TypeError):
                    galsim.roman.convertCenter(test_sca_pos, 3.5)
                with assert_raises(TypeError):
                    galsim.roman.convertCenter(
                        test_sca_pos, int(sca_edge_test), PA=pa[i_test]*galsim.radians,
                        date=date, PA_is_FPA=True, tol=1.0)

                delta_arcsec = test_fpa_pos.distanceTo(world_pos) / galsim.arcsec
                assert delta_arcsec<0.5, "could not round-trip from FPA to SCA to FPA center"

    # There were few-arcsec offsets in our WCS, so allow some fraction of failures.
    print('n_fail = ',n_fail)
    assert n_fail < 0.2*n_test, 'Failed in SCA-matching against reference: %d %d'%(n_fail,n_test)

    # Check whether we're allowed to look at certain positions on certain dates.
    # Let's choose RA=90 degrees, dec=10 degrees.
    # We know that it's best to look about 90 degrees from the Sun.  So on the vernal and autumnal
    # equinox, this should be a great place to look, but not midway in between.
    pos = galsim.CelestialCoord(90.*galsim.degrees, 10.*galsim.degrees)
    import datetime
    assert galsim.roman.allowedPos(pos, datetime.date(2025,3,20))
    assert galsim.roman.allowedPos(pos, datetime.date(2025,9,20))
    assert not galsim.roman.allowedPos(pos, datetime.date(2025,6,20))
    assert galsim.roman.bestPA(pos, datetime.date(2025,6,20)) is None

    # Finally make sure it does something reasonable for the observatory position angle.
    # When the sun is at (0,0), and we look at (90,0), then +Z points towards the Sun and +Y points
    # North, giving a PA of 0 degrees.
    pos = galsim.CelestialCoord(90.*galsim.degrees, 0.*galsim.degrees)
    test_date = datetime.datetime(2025,3,20,9,2)
    pa = galsim.roman.bestPA(pos, test_date)
    np.testing.assert_almost_equal(pa.rad, 0., decimal=3)
    # Now make it look at the same RA as the sun but quite different declination.  It wants +Z
    # pointing North toward Sun, so we'll get a -90 degree angle for the PA.
    pos = galsim.CelestialCoord(0.*galsim.degrees, -70.*galsim.degrees)
    pa = galsim.roman.bestPA(pos, test_date)
    np.testing.assert_almost_equal(pa.rad, -np.pi/2, decimal=3)

    sun_pos= galsim.CelestialCoord(0*galsim.degrees, 0*galsim.degrees)
    sun_pa = galsim.roman.bestPA(sun_pos, test_date)
    assert sun_pa is None

    with assert_raises(TypeError):
        galsim.roman.getWCS(world_pos=galsim.PositionD(300,400))
    with assert_raises(galsim.GalSimError):
        galsim.roman.getWCS(world_pos=sun_pos, date=test_date)
    with assert_raises(TypeError):
        galsim.roman.getWCS(world_pos=pos, PA=33.)
    with assert_raises(galsim.GalSimRangeError):
        galsim.roman.getWCS(world_pos=pos, SCAs=[-1,1])
    with assert_raises(galsim.GalSimRangeError):
        galsim.roman.getWCS(world_pos=pos, SCAs=[1,23])

    # Check the rather bizarre convention that LONPOLE is always 180 EXCEPT (!!) when
    # observing directly at the south pole.  Apparently, this convention comes from the Roman
    # project office's use of the LONPOLE keyword.  So we keep it, even though it's stupid.
    # cf. https://github.com/GalSim-developers/GalSim/pull/651#discussion-diff-26277673
    assert gs_wcs_dict[1].header['LONPOLE'] == 180.
    south_pole = galsim.CelestialCoord(0*galsim.degrees, -90*galsim.degrees)
    wcs = galsim.roman.getWCS(world_pos=south_pole, SCAs=1)
    assert wcs[1].header['LONPOLE'] == 0

    with assert_raises(TypeError):
        galsim.roman.findSCA(wcs_dict=None, world_pos=pos)
    with assert_raises(TypeError):
        galsim.roman.findSCA(wcs_dict=wcs, world_pos=galsim.PositionD(300,400))
    with mock.patch('galsim.roman.roman_wcs.sip_filename', 'sip_7_6_8.txt'):
        with assert_raises(OSError):
            galsim.roman.getWCS(world_pos=world_pos, date=date)


@timer
def test_roman_backgrounds():
    """Test the Roman background estimation routines for basic sanity.
    """
    import datetime

    # The routine should not allow us to look directly at the sun since the background there is high
    # (to understate the problem).  If no date is supplied, then the routine assumes RA=dec=0 means
    # we are looking at the sun.
    bp_dict = galsim.roman.getBandpasses()
    bp = bp_dict['J129'] # one of the standard filters, doesn't really matter which
    with assert_raises(ValueError):
        galsim.roman.getSkyLevel(
                bp, world_pos=galsim.CelestialCoord(0.*galsim.degrees, 0.*galsim.degrees))
        # near autumn equinox
    with assert_raises(ValueError):
        galsim.roman.getSkyLevel(
                bp, world_pos=galsim.CelestialCoord(180.*galsim.degrees, 5.*galsim.degrees),
                date=datetime.date(2025,9,15))

    # world_pos must be a CelestialCoord.
    with assert_raises(TypeError):
        galsim.roman.getSkyLevel(bp, world_pos=galsim.PositionD(300,400))

    # No world_pos works.  Produces sky level for some plausible generic location.
    sky_level = galsim.roman.getSkyLevel(bp)
    print('sky_level = ',sky_level)
    # regression test relative to v2.5
    np.testing.assert_allclose(sky_level, 6928.267815, rtol=0.01)

    # But not with a non-Roman bandpass
    with assert_raises(galsim.GalSimError):
        galsim.roman.getSkyLevel(galsim.Bandpass('wave', 'nm', 400, 550))

    # The routine should have some obvious symmetry, for example, ecliptic latitude above vs. below
    # plane and ecliptic longitude positive vs. negative (or vs. 360 degrees - original value).
    # Because of how equatorial and ecliptic coordinates are related on the adopted date, we can do
    # this test as follows:
    test_ra = 50.*galsim.degrees
    test_dec = 10.*galsim.degrees
    test_pos_p = galsim.CelestialCoord(test_ra, test_dec)
    test_pos_m = galsim.CelestialCoord(-1.*(test_ra/galsim.degrees)*galsim.degrees,
                                       -1.*(test_dec/galsim.degrees)*galsim.degrees)
    level_p = galsim.roman.getSkyLevel(bp, world_pos=test_pos_p)
    level_m = galsim.roman.getSkyLevel(bp, world_pos=test_pos_m)
    np.testing.assert_almost_equal(level_m, level_p, decimal=8)

    # The routine should handle an input exposure time sensibly.  Our original level_p was in
    # e-/arcsec^2 using the Roman exposure time.  We will define another exposure time, pass it in,
    # and confirm that the output is consistent with this.
    level_p_2 = galsim.roman.getSkyLevel(bp, world_pos=test_pos_p,
                                         exptime=1.7*galsim.roman.exptime)
    np.testing.assert_almost_equal(1.7*level_p, level_p_2, decimal=8)


@timer
def test_roman_bandpass():
    """Test the Roman bandpasses for basic sanity.
    """
    from galsim._pyfits import pyfits

    # Obtain the bandpasses with AB_zeropoint set
    bp = galsim.roman.getBandpasses(AB_zeropoint=True)

    # Check if the zeropoints have been set correctly
    AB_spec = lambda x: (3631e-23)
    AB_sed = galsim.SED(spec=AB_spec, wave_type='nm', flux_type='fnu')
    for filter_name, filter_ in bp.items():
        mag = AB_sed.calculateMagnitude(bandpass=filter_)
        np.testing.assert_almost_equal(mag,0.0,decimal=6,
            err_msg="Zeropoint not set accurately enough for bandpass filter "+filter_name)

    # Do a slightly less trivial check of bandpass-related calculations:
    # Jeff Kruk (at Goddard) took an SED template from the Castelli-Kurucz library, normalized it to
    # a particular magnitude in SDSS g band, and checked the count rates he expects for the Roman
    # bands.  I (RM) independently did the same calculation (downloading the templates and bandpass
    # myself and using GalSim for all the important bits of the calculation) and my results agree a
    # the 5% level.  Given that I didn't quite have the same SED, we were very happy with this level
    # of agreement.  The unit test below reproduces this test, and requires agreement at the 10%
    # level.
    # Jeff used the C-K template with solar metallicity, T=9550K, surface gravity logg=3.95.  I
    # downloaded a grid of templates and just used the nearest one, which has solar metallicity,
    # T=9500K, surface gravity logg=4.0.
    with pyfits.open(os.path.join('roman_files','ckp00_9500.fits')) as fits:
        sed_data = fits[1].data
    lam = sed_data.WAVELENGTH.astype(np.float64)
    t = sed_data.g40.astype(np.float64)
    sed_tab = galsim.LookupTable(x=lam, f=t, interpolant='linear')
    sed = galsim.SED(sed_tab, wave_type='A', flux_type='flambda')

    # Now take the SDSS g bandpass:
    # gfile =  '/Users/rmandelb/Downloads/g.dat'
    bp_dat = np.loadtxt(os.path.join('roman_files','g.dat')).transpose()
    bp_tab = galsim.LookupTable(x=bp_dat[0,:], f=bp_dat[1,:], interpolant='linear')
    bp_ref = galsim.Bandpass(bp_tab, wave_type='A').withZeropoint('AB')
    # Now get a new SED that has magnitude -0.093 in this filter, since that's the normalization
    # that Jeff imposed for his tests.
    sed = sed.withMagnitude(-0.093, bp_ref)

    # Reference count rates, from Jeff:
    reference = {}
    reference['Z087'] = 1.98e10
    reference['Y106'] = 1.97e10
    reference['J129'] = 1.52e10
    reference['H158'] = 1.11e10
    reference['F184'] = 0.58e10
    reference['W146'] = 4.34e10

    # Only 15% accuracy required because did not use quite the same stellar template.  Fortunately,
    # bugs can easily lead to orders of magnitude errors, so this unit test is still pretty
    # non-trivial.
    for filter_name, filter_ in bp.items():
        if filter_name not in reference:
            continue
        flux = sed.calculateFlux(filter_)  # photons / cm^2 / s
        count_rate = flux * galsim.roman.collecting_area  # photons / s
        print(count_rate, reference[filter_name])
        np.testing.assert_allclose(
            count_rate, reference[filter_name], rtol=0.15,
            err_msg="Count rate for stellar model not as expected for bandpass "
            "{0}".format(filter_name))

    # Finally, compare against some external zeropoint calculations from the Roman microlensing
    # group: https://roman.ipac.caltech.edu/sims/MABuLS_sim.html
    # They calculated instrumental zero points, defined such that the flux is 1 photon/sec (taking
    # into account the Roman collecting area).  We convert ours to their definition by adding
    # `delta_zp` calculated below:
    area_eff = galsim.roman.collecting_area
    delta_zp = 2.5 * np.log10(area_eff)

    # Define the zeropoints that they calculated:
    # Note: with the new bandpass changes since their calculation, the agreement is only about
    # 0.3 magnitudes.
    ref_zp = {
        'W146': 27.554,
        'Z087': 26.163
        }
    for key in ref_zp.keys():
        galsim_zp = bp[key].zeropoint + delta_zp
        # They use slightly different versions of the bandpasses, so we only require agreement to
        # 0.1 mag.
        print('zp for %s: '%key, galsim_zp, ref_zp[key])
        np.testing.assert_allclose(galsim_zp, ref_zp[key], atol=0.3,
                                   err_msg="Wrong zeropoint for bandpass "+key)

    # Note: the difference is not due to our default thinning.  This isn't any better.
    nothin_bp = galsim.roman.getBandpasses(AB_zeropoint=True, default_thin_trunc=False)
    for key in ref_zp.keys():
        galsim_zp = nothin_bp[key].zeropoint + delta_zp
        print('nothin zp for %s: '%key, galsim_zp, ref_zp[key])
        np.testing.assert_allclose(galsim_zp, ref_zp[key], atol=0.3,
                                   err_msg="Wrong zeropoint for bandpass "+key)

    # Even with fairly extreme thinning, the error is still 0.3 mag.
    verythin_bp = galsim.roman.getBandpasses(AB_zeropoint=True, default_thin_trunc=False,
                                             relative_throughput=0.05, rel_err=0.1)
    for key in ref_zp.keys():
        galsim_zp = verythin_bp[key].zeropoint + delta_zp
        print('verythin zp for %s: '%key, galsim_zp, ref_zp[key])
        np.testing.assert_allclose(galsim_zp, ref_zp[key], atol=0.3,
                                   err_msg="Wrong zeropoint for bandpass "+key)

    with assert_raises(TypeError):
        galsim.roman.getBandpasses(default_thin_trunc=False, rel_tp=0.05)
    with assert_warns(galsim.GalSimWarning):
        galsim.roman.getBandpasses(relative_throughput=0.05, rel_err=0.1)

    # Can also not bother to set the zeropoint.
    nozp_bp = galsim.roman.getBandpasses(AB_zeropoint=False)
    for key in nozp_bp:
        assert nozp_bp[key].zeropoint is None


@timer
def test_roman_nonimaging_bandpass():
    """Test the Roman non-imaging bandpasses for basic sanity.
    """
    bp_imaging = galsim.roman.getBandpasses(AB_zeropoint=True)
    bp_all = galsim.roman.getBandpasses(AB_zeropoint=True, include_all_bands=True)

    # Check that the imaging bandpasses are in the all bandpasses
    for key in bp_imaging:
        assert key in bp_all

    # Check that the non-imaging bandpasses are in the all bandpasses
    assert 'Grism_0thOrder' in bp_all
    assert 'Grism_1stOrder' in bp_all
    assert 'SNPrism' in bp_all

    # Check that the non-imaging bandpasses are not in the imaging bandpasses
    assert 'Grism_0thOrder' not in bp_imaging
    assert 'Grism_1stOrder' not in bp_imaging
    assert 'SNPrism' not in bp_imaging


@timer
def test_roman_detectors():
    """Test the Roman detector routines for consistency with standard detector routines.
    """
    # This seems almost silly, but for now the Roman detector routines are defined in terms of the
    # standard GalSim detector routines, and we should check that even if the routines are modified,
    # they still can agree given the same inputs.
    # So start by making a fairly simple image.
    obj = galsim.Gaussian(sigma=3.*galsim.roman.pixel_scale, flux=1.e5)
    im = obj.drawImage(scale=galsim.roman.pixel_scale)
    im.replaceNegative(0.)

    # Make copies that we transform using both sets of routines, and check for consistency.
    # First we do nonlinearity:
    im_1 = im.copy()
    im_2 = im.copy()
    im_1.applyNonlinearity(NLfunc=galsim.roman.NLfunc)
    galsim.roman.applyNonlinearity(im_2)
    assert im_2.scale == im_1.scale
    assert im_2.wcs == im_1.wcs
    assert im_2.dtype == im_1.dtype
    assert im_2.bounds == im_1.bounds
    np.testing.assert_array_equal(
        im_2.array, im_1.array,
        err_msg='Nonlinearity results depend on function used.')

    # Then we do reciprocity failure:
    im_1 = im.copy()
    im_2 = im.copy()
    im_1.addReciprocityFailure(exp_time=galsim.roman.exptime,
                               alpha=galsim.roman.reciprocity_alpha,
                               base_flux=1.0)
    galsim.roman.addReciprocityFailure(im_2)
    assert im_2.scale == im_1.scale
    assert im_2.wcs == im_1.wcs
    assert im_2.dtype == im_1.dtype
    assert im_2.bounds == im_1.bounds
    np.testing.assert_array_equal(
        im_2.array, im_1.array,
        err_msg='Reciprocity failure results depend on function used.')

    # Then we do Persistence
    im_1 = im.copy()
    im_2 = im.copy()

    rng = galsim.BaseDeviate(1234567)
    im0 = galsim.Image(im.bounds)       # create a new image for two noise images as 'ghost images'
    im0_1 = im0.copy()
    im0_1.addNoise(galsim.GaussianNoise(rng,sigma=10.))

    im0_2 = im0_1.copy()
    im0_2.addNoise(galsim.PoissonNoise(rng))

    im_list = [im0_1,im0_2]*4

    im_1.applyPersistence(im_list,galsim.roman.persistence_coefficients)
    galsim.roman.applyPersistence(im_2, im_list, method='linear') #check the linear method
    assert im_2.scale == im_1.scale
    assert im_2.wcs == im_1.wcs
    assert im_2.dtype == im_1.dtype
    assert im_2.bounds == im_1.bounds
    np.testing.assert_array_equal(
        im_2.array, im_1.array,
        err_msg='Persistence results depend on function used.')

    im_unit = galsim.Image(np.ones((2,2)), copy=True)
    im_f = im_unit*0.0
    im_f1 = im_unit*0.0
    illuminatin_list = [1.E3, 1.E4, 4.E4, 4.99E4, 5.01E4, 1.E5, 1.0E6]
    im_f_list = [x*im_unit for x in illuminatin_list]

    galsim.roman.applyPersistence(im_f, im_f_list, method='fermi') #check fermi method

    #Check the functionality of the fermi method.
    A, x0, dx, a, r, half_well = galsim.roman.persistence_fermi_parameters
    #formula of the fermi model. See the documentation of
    #galsim.roman.roman_detectors.applyPersistence for more info.
    f_fermi = lambda x, t: A* (x/x0)**a * (t/1000.)**(-r)/(np.exp( -(x-x0)/dx)+1.)
    ps = 0.0
    for i,x in enumerate(illuminatin_list):
        t = (0.5+i)*galsim.roman.exptime #mid-time of each exposure
        if x>=0.0 and x<half_well: #linear tail below half well of saturation
            ps += f_fermi(half_well, t)*x/half_well
        elif x>= half_well:
            ps += f_fermi(x,t)
    ps *= galsim.roman.exptime
    assert np.allclose( im_f.array, np.ones((2,2))*ps, rtol=1.E-06 ), 'Error in Fermi persistence model'

    galsim.roman.applyPersistence(im_f1, im_f_list)
    np.testing.assert_array_equal(im_f, im_f1,
        err_msg='The default method of roman.applyPersistence is not fermi.')

    assert_raises(TypeError, galsim.roman.applyPersistence, im_2, im0)
    assert_raises(galsim.GalSimValueError, galsim.roman.applyPersistence, im_2, im_list, method='wrong method')

    # Then we do IPC:
    im_1 = im.copy()
    im_2 = im.copy()
    im_1.applyIPC(IPC_kernel=galsim.roman.ipc_kernel, kernel_normalization=True)
    galsim.roman.applyIPC(im_2)
    assert im_2.scale == im_1.scale
    assert im_2.wcs == im_1.wcs
    assert im_2.dtype == im_1.dtype
    assert im_2.bounds == im_1.bounds
    np.testing.assert_array_equal(
        im_2.array, im_1.array,
        err_msg='IPC results depend on function used.')

    # Finally, just check that this runs.
    # (Accuracy of component functionality is all tested elsewhere.)
    ntest = 10  # number of exposures for this test
    past_images = []
    for i in range(ntest):
        im = obj.drawImage(scale=galsim.roman.pixel_scale)
        past_images = galsim.roman.allDetectorEffects(im, past_images, rng=rng)
    assert len(past_images) == ntest


@timer
def test_roman_psfs(run_slow):
    """Test the Roman PSF routines for reasonable behavior.
    """
    # The Roman PSF routines can take a long time under some circumstances.  For example, storing
    # images for interpolation can be expensive, particularly when using the full pupil plane
    # functionality.  To speed up our calculations, we will limit the unit tests to certain
    # situations:
    # - fully chromatic PSFs without interpolation.  Then we just want to play with the objects in
    #   a fast way (e.g., evaluating at one  wavelength, not integrating over a bandpass).
    # - fully chromatic PSFs with interpolation, but only interpolating between two wavelengths.
    # - achromatic PSFs.
    #
    # We also only test pupil_bin=4,8 in pytest runs.  Tests of pupil_bin=1,2 are done in
    # __main__ runs.

    # Providing a wavelength returns achromatic PSFs
    psf_5 = galsim.roman.getPSF(SCA=5, bandpass='F184', wavelength=1950., pupil_bin=8)
    assert isinstance(psf_5, galsim.GSObject)
    # Make sure we do the case where we add aberrations
    psf_5_ab = galsim.roman.getPSF(SCA=5, bandpass='F184', wavelength=1950., pupil_bin=8,
                                   extra_aberrations=np.zeros(23)+0.001)
    # Check that we get the same answer if we specify the center of the focal plane.
    psf_5_tmp = galsim.roman.getPSF(SCA=5, bandpass='F184', wavelength=1950., pupil_bin=8,
                                    SCA_pos=galsim.PositionD(galsim.roman.n_pix/2,
                                                             galsim.roman.n_pix/2))
    assert psf_5==psf_5_tmp

    # Check that if we specify a particular wavelength, the PSF that is drawn is the same as if we
    # had gotten chromatic PSFs and then used evaluateAtWavelength.  Note that this nominally seems
    # like a test of the chromatic functionality, but there are ways that getPSF() could mess up
    # inputs such that there is a disagreement.  That's why this unit test belongs here.
    use_sca = 5
    all_bp = galsim.roman.getBandpasses()
    zbp = all_bp['Z087']
    use_lam = zbp.effective_wavelength
    psf_chrom = galsim.roman.getPSF(use_sca, None, pupil_bin=8)
    psf_achrom = galsim.roman.getPSF(use_sca, None, wavelength=use_lam, pupil_bin=8)
    psf_achrom2 = galsim.roman.getPSF(use_sca, 'Z087', wavelength=use_lam, pupil_bin=8)
    # First, we can draw the achromatic PSF.
    im_achrom = psf_achrom.drawImage(scale=galsim.roman.pixel_scale)
    im_achrom2 = im_achrom.copy()
    im_achrom2 = psf_achrom2.drawImage(image=im_achrom2, scale=galsim.roman.pixel_scale)
    im_chrom = im_achrom.copy()
    obj_chrom = psf_chrom.evaluateAtWavelength(use_lam)
    im_chrom = obj_chrom.drawImage(image=im_chrom, scale=galsim.roman.pixel_scale)
    # Normalization should probably not be right.
    im_chrom *= im_achrom.array.sum()/im_chrom.array.sum()
    # But otherwise these images should agree *extremely* well.
    np.testing.assert_array_almost_equal(
        im_chrom.array, im_achrom.array, decimal=8,
        err_msg='PSF at a given wavelength and chromatic one evaluated at that wavelength disagree.')
    np.testing.assert_array_almost_equal(
        im_achrom.array, im_achrom2.array, decimal=8,
        err_msg='Two PSFs at a given wavelength specified in different ways disagree.')

    # Make a very limited check that interpolation works: just 2 wavelengths, 1 SCA.
    # use the blue and red limits for Z087:
    blue_limit = all_bp['Z087'].blue_limit
    red_limit = all_bp['Z087'].red_limit
    n_waves = 3
    psf_int = galsim.roman.getPSF(SCA=use_sca, bandpass='Z087', pupil_bin=8, n_waves=n_waves)
    # Check that evaluation at a single wavelength is consistent with previous results.
    im_int = im_achrom.copy()
    obj_int = psf_int.evaluateAtWavelength(use_lam)
    im_int = obj_int.drawImage(image=im_int, scale=galsim.roman.pixel_scale)
    # These images should agree well, but not perfectly.  One of them comes from drawing an image
    # from an object directly, whereas the other comes from drawing an image of that object, making
    # it into an InterpolatedImage, then re-drawing it.  Different accuracies are used for those
    # intermediate steps than would be used when drawing directly, so that can give rise to some
    # disagreement.  Check for agreement at the level of 2e-3 (requiring 1e-3 gives rise to failure
    # in 2 pixels!).
    diff_im = 0.5*(im_int.array-im_achrom.array)
    np.testing.assert_array_almost_equal(
        diff_im, np.zeros_like(diff_im), decimal=3,
        err_msg='PSF at a given wavelength and interpolated chromatic one evaluated at that '
        'wavelength disagree.')

    # Make sure the interpolated version isn't gratuitously copying the aperture.  It should be
    # able to use the same aperture object for each wavelength.
    for obj in psf_int.objs[1:]:
        assert obj._aper is psf_int.objs[0]._aper

    # Check some invalid inputs.
    with assert_raises(TypeError):
        galsim.roman.getPSF(SCA=use_sca, bandpass='Z087', n_waves=2, wavelength='Z087')
    with assert_raises(TypeError):
        galsim.roman.getPSF(SCA=use_sca, bandpass=None, n_waves=2, wavelength_limits=red_limit)
    with assert_raises(TypeError):
        galsim.roman.getPSF(SCA=use_sca, bandpass='Z087', wavelength='Z087')
    with assert_raises(ValueError):
        galsim.roman.getPSF(SCA=use_sca, bandpass='Z099', n_waves=2, wavelength='Z099')
    with assert_raises(ValueError):
        galsim.roman.getPSF(SCA=use_sca, bandpass='Z099', n_waves=2, wavelength='Z099')
    with assert_raises(TypeError):
        galsim.roman.getPSF(SCA=use_sca, bandpass='Z087', n_waves=2, wavelength='Z087')
    with assert_raises(TypeError):
        galsim.roman.getPSF(SCA=use_sca, bandpass='F184', n_waves=2, wavelength='F184')
    with assert_raises(galsim.GalSimValueError):
        galsim.roman.getPSF(SCA=use_sca, bandpass=3)

    # Make sure we can instantiate a PSF with bandpass='short'/'long' and get an equivalent object
    # when we're not using interpolation.
    use_sca = 3
    bp_type = 'long'
    bp = galsim.roman.longwave_bands[0]
    psf1 = galsim.roman.getPSF(use_sca, bp, pupil_bin=8)
    psf2 = galsim.roman.getPSF(use_sca, 'long', pupil_bin=8)
    assert psf1==psf2

    # Test some variation in the accuracy settings.
    kwargs_list = [
        { 'pupil_bin':4 },
        { 'pupil_bin':8 },
    ]
    if run_slow:
        # A few more that are too slow to run in regular pytest
        kwargs_list.extend([
            { 'pupil_bin':1 },
            { 'pupil_bin':2 },
            { 'pupil_bin':1, 'gsparams':galsim.GSParams(folding_threshold=2.e-3) },
            { 'pupil_bin':2, 'gsparams':galsim.GSParams(folding_threshold=2.e-3) },
            { 'pupil_bin':4, 'gsparams':galsim.GSParams(folding_threshold=2.e-3) },
        ])
    for kwargs in kwargs_list:
        psf = galsim.roman.getPSF(use_sca, 'Z087', **kwargs)
        psf_achrom = galsim.roman.getPSF(use_sca, 'Z087', wavelength=zbp, **kwargs)
        psf_chrom = psf.evaluateAtWavelength(use_lam)
        im_achrom = psf_achrom.drawImage(scale=galsim.roman.pixel_scale)
        im_chrom = psf_chrom.drawImage(image=im_achrom.copy())
        #im_achrom.write('im_achrom.fits')
        #im_chrom.write('im_chrom.fits')
        print("chrom, achrom fluxes = ", im_chrom.array.sum(), im_achrom.array.sum())
        im_chrom *= im_achrom.array.sum()/im_chrom.array.sum()
        print("max diff = ",np.max(np.abs(im_chrom.array - im_achrom.array)))
        np.testing.assert_array_almost_equal(
            im_chrom.array, im_achrom.array, decimal=8,
            err_msg='getPSF with %s has discrepency for chrom/achrom'%kwargs)

    # Check the stated method for recovering memory used in aperture caches.
    # Despite using a leading-underscore, sub-module-level function, the fact that we
    # document it makes it officially part of the API.  It requires a proper deprecation
    # if we change the syntax of this.
    galsim.roman.roman_psfs._make_aperture.clear()

    # Check for exceptions if we:
    # (1) Include optional aberrations in an unacceptable form.
    # (2) Invalid SCA numbers.
    # (3) Invalid kwarg combination.
    assert_raises(ValueError, galsim.roman.getPSF, 3, None, extra_aberrations=[0.03, -0.06]*20)
    assert_raises(ValueError, galsim.roman.getPSF, 30, None)
    assert_raises(ValueError, galsim.roman.getPSF, 0, None)
    assert_raises(ValueError, galsim.roman.getPSF, 3, 'short', n_waves=10)


@timer
def test_roman_basic_numbers():
    """Trivial test of basic numbers stored in Roman module.
    """
    # Would be better to have a non-trivial test, but this will do for now.
    ref_gain = 1.0
    ref_pixel_scale = 0.11  # arcsec / pixel
    ref_diameter = 2.36  # meters
    ref_obscuration = 0.32
    ref_exptime = 139.8  # s
    ref_dark_current = 0.015 # e-/pix/s
    ref_nonlinearity_beta = -6.e-7
    ref_reciprocity_alpha = 0.0065
    ref_read_noise = 8.5 # e-
    ref_n_dithers = 6
    ref_thermal_backgrounds = {'R062': 0.00, # e-/pix/s
                               'Z087': 0.00,
                               'Y106': 0.00,
                               'J129': 0.00,
                               'H158': 0.04,
                               'F184': 0.17,
                               'K213': 4.52,
                               'W146': 0.98,
                               'SNPrism': 0.00,
                               'Grism_0thOrder': 0.00,
                               'Grism_1stOrder': 0.00,
                              }
    ref_pupil_plane_file = os.path.join(
            galsim.meta_data.share_dir, 'roman', 'SCA2_rim_mask.fits.gz')
    ref_stray_light_fraction = 0.1
    ref_ipc_kernel = np.array([ [0.001269938, 0.015399776, 0.001199862],
                                [0.013800177, 1.0,         0.015600367],
                                [0.001270391, 0.016129619, 0.001200137] ])
    ref_ipc_kernel /= np.sum(ref_ipc_kernel)
    ref_ipc_kernel = galsim.Image(ref_ipc_kernel)
    ref_persistence_coefficients = np.array(
        [0.045707683,0.014959818,0.009115737,0.00656769,0.005135571,
         0.004217028,0.003577534,0.003106601])/100.
    ref_persistence_fermi_parameters = np.array(
        [0.017, 60000., 50000., 0.045, 1., 50000.])
    ref_n_sca = 18
    ref_n_pix_tot = 4096
    ref_n_pix = 4088
    ref_jitter_rms = 0.014
    ref_charge_diffusion = 0.1

    assert galsim.roman.gain==ref_gain
    assert galsim.roman.pixel_scale==ref_pixel_scale
    assert galsim.roman.diameter==ref_diameter
    assert galsim.roman.obscuration==ref_obscuration
    assert galsim.roman.exptime==ref_exptime
    assert galsim.roman.dark_current==ref_dark_current
    assert galsim.roman.nonlinearity_beta==ref_nonlinearity_beta
    assert galsim.roman.reciprocity_alpha==ref_reciprocity_alpha
    assert galsim.roman.read_noise==ref_read_noise
    assert galsim.roman.n_dithers==ref_n_dithers
    assert galsim.roman.thermal_backgrounds.keys()==ref_thermal_backgrounds.keys()
    for key in ref_thermal_backgrounds.keys():
        assert galsim.roman.thermal_backgrounds[key]==ref_thermal_backgrounds[key]
    assert galsim.roman.pupil_plane_file==ref_pupil_plane_file
    assert galsim.roman.stray_light_fraction==ref_stray_light_fraction
    np.testing.assert_array_equal(ref_ipc_kernel, galsim.roman.ipc_kernel)
    np.testing.assert_array_equal(ref_persistence_coefficients,
                                  galsim.roman.persistence_coefficients)
    np.testing.assert_array_equal(ref_persistence_fermi_parameters,
                                  galsim.roman.persistence_fermi_parameters)
    assert galsim.roman.n_sca==ref_n_sca
    assert galsim.roman.n_pix_tot==ref_n_pix_tot
    assert galsim.roman.n_pix==ref_n_pix
    assert galsim.roman.jitter_rms==ref_jitter_rms
    assert galsim.roman.charge_diffusion==ref_charge_diffusion


@timer
def test_roman_psf_wcs():
    """Test drawing the PSF with a provided WCS."""

    # Make a PSF without giving a wcs
    image_pos = galsim.PositionD(153, 921)
    psf = galsim.roman.getPSF(SCA=5, bandpass='F184', wavelength=1950., pupil_bin=8,
                              SCA_pos=image_pos)

    # Draw it on an image with pixel_scale wcs
    im_scale = psf.drawImage(scale=galsim.roman.pixel_scale, center=image_pos)

    # Get a plausible commemorative observation for Roman's 100th birthday.
    world_pos = galsim.CelestialCoord(
        ra = galsim.Angle.from_hms('16:01:41.01257'),  # AG Draconis
        dec = galsim.Angle.from_dms('66:48:10.1312')
    )
    PA = 112*galsim.degrees  # Random.
    date = datetime.datetime(2025, 5, 16)  # NGR's 100th birthday.
    wcs_dict = galsim.roman.getWCS(PA=PA, world_pos=world_pos, date=date)
    wcs = wcs_dict[5]

    # Get the PSF in real world coordinates with this wcs
    psf = galsim.roman.getPSF(SCA=5, bandpass='F184', wavelength=1950., pupil_bin=8,
                              SCA_pos=image_pos, wcs=wcs)
    # Draw on an image with this wcs.
    im_wcs = psf.drawImage(bounds=im_scale.bounds, wcs=wcs, center=image_pos)

    np.testing.assert_allclose(im_wcs.array, im_scale.array)


@timer
def test_config_psf():
    """Test RomanPSF config type"""

    # Start with default everything
    config = {
        'modules' : ['galsim.roman'],
        'psf' : { 'type' : 'RomanPSF', 'SCA': 4, 'bandpass': 'H158' }
    }

    galsim.config.ImportModules(config)
    psf1 = galsim.config.BuildGSObject(config, 'psf')[0]
    psf2 = galsim.roman.getPSF(SCA=4, bandpass='H158')
    print('psf1 = ',str(psf1))
    print('psf2 = ',str(psf2))
    assert psf1 == psf2

    # Now check some non-default options
    config = galsim.config.CleanConfig(config)
    config['psf']['pupil_bin'] = 8
    config['psf']['n_waves'] = 4
    config['psf']['extra_aberrations'] = [0.01, 0, 0, 0.03, -0.05]
    config['psf']['gsparams'] = {'folding_threshold' : 1.e-2}
    psf1 = galsim.config.BuildGSObject(config, 'psf')[0]
    psf2 = galsim.roman.getPSF(SCA=4, bandpass='H158', pupil_bin=8, n_waves=4,
                               extra_aberrations=[0,0,0,0, 0.01, 0, 0, 0.03, -0.05],
                               gsparams=galsim.GSParams(folding_threshold=1.e-2))
    print('psf1 = ',str(psf1))
    print('psf2 = ',str(psf2))
    assert psf1 == psf2

    # Check using some values that are may already loaded into base config dict.
    config = galsim.config.CleanConfig(config)
    del config['psf']['SCA']
    del config['psf']['bandpass']
    del config['psf']['n_waves']
    config['bandpass'] = galsim.roman.getBandpasses(AB_zeropoint=True)['Z087']
    config['SCA'] = 9
    config['image_pos'] = galsim.PositionD(123,456)
    config['psf']['use_SCA_pos'] = True
    config['psf']['wavelength'] = 985.
    psf1 = galsim.config.BuildGSObject(config, 'psf')[0]
    psf2 = galsim.roman.getPSF(SCA=9, bandpass='Z087', pupil_bin=8, wavelength=985.,
                               SCA_pos=galsim.PositionD(123,456),
                               extra_aberrations=[0,0,0,0, 0.01, 0, 0, 0.03, -0.05],
                               gsparams=galsim.GSParams(folding_threshold=1.e-2))
    print('psf1 = ',str(psf1))
    print('psf2 = ',str(psf2))
    assert psf1 == psf2

    # Let bandpass be built by RomanBandpass type
    config = galsim.config.CleanConfig(config)
    config['image'] = {
        'bandpass' : { 'type' : 'RomanBandpass', 'name' : 'J129' }
    }
    config['bandpass'] = galsim.config.BuildBandpass(config['image'], 'bandpass', config)[0]
    psf1 = galsim.config.BuildGSObject(config, 'psf')[0]
    psf2 = galsim.roman.getPSF(SCA=9, bandpass='J129', pupil_bin=8, wavelength=985.,
                               SCA_pos=galsim.PositionD(123,456),
                               extra_aberrations=[0,0,0,0, 0.01, 0, 0, 0.03, -0.05],
                               gsparams=galsim.GSParams(folding_threshold=1.e-2))
    print('psf1 = ',str(psf1))
    print('psf2 = ',str(psf2))
    assert psf1 == psf2


@timer
def test_config_sca():
    """Test RomanSCA config type"""

    # The standard size of a Roman SCA is a bit large for an efficient unit test,
    # so we use mock to reduce the size of the image being constructed here.

    logger = logging.getLogger('test_config_sca')
    logger.addHandler(logging.StreamHandler(sys.stdout))
    logger.setLevel(logging.DEBUG)

    with mock.patch('galsim.roman.roman_config.n_pix', 64):

        config = {
            'modules': ['galsim.roman'],
            'image': {
                'type': 'RomanSCA',

                # These are required:
                'nobjects' : 1,
                'SCA': 5,
                'ra': '16 hours',
                'dec': '66 degrees',
                'filter': 'H158',
                'date': datetime.datetime(2025, 5, 16),

                # Set the rng seed:
                'random_seed': 1234,

                # Start with all the extra effects turned off.
                'stray_light': False,
                'thermal_background': False,
                'reciprocity_failure': False,
                'dark_current': False,
                'nonlinearity': False,
                'ipc': False,
                'read_noise': False,
                'sky_subtract': False,

                # image_pos can be either here or in stamp.
                'image_pos': (23,17),
            },

            # Nothing complicated for the object to draw.
            'gal': {
                'type': 'Exponential',
                'half_light_radius': 1.2,
                'flux': 177,
            },
        }

        galsim.config.ImportModules(config)
        im1 = galsim.config.BuildImage(config, obj_num=0)

        # Compare to manually constructed image
        pointing = galsim.CelestialCoord(ra=16*galsim.hours, dec=66*galsim.degrees)
        date = datetime.datetime(2025, 5, 16)
        wcs = galsim.roman.getWCS(world_pos=pointing, SCAs=[5], date=date)[5]
        im2 = galsim.Image(64,64, wcs=wcs)
        bp = galsim.roman.getBandpasses()['H158']
        sky_level = galsim.roman.getSkyLevel(bp, world_pos=wcs.toWorld(im2.true_center))
        wcs.makeSkyImage(im2, sky_level)
        gal = galsim.Exponential(half_light_radius=1.2, flux=177)
        gal.drawImage(im2, center=(23,17), add_to_image=True)
        first_seed = galsim.BaseDeviate(1234).raw()
        poisson_noise = galsim.PoissonNoise(galsim.BaseDeviate(first_seed))
        im2.addNoise(poisson_noise)
        im2 /= galsim.roman.gain
        im2.quantize()
        assert im1 == im2

        # Repeat with all of the detector effects
        config = galsim.config.CleanConfig(config)
        config['image']['stray_light'] = True
        config['image']['thermal_background'] = True
        config['image']['reciprocity_failure'] = True
        config['image']['dark_current'] = True
        config['image']['nonlinearity'] = True
        config['image']['ipc'] = True
        config['image']['read_noise'] = True
        config['image']['sky_subtract'] = True
        im1 = galsim.config.BuildImage(config, obj_num=0, logger=logger)
        sky_level *= (1.0 + galsim.roman.stray_light_fraction)
        wcs.makeSkyImage(im2, sky_level)
        im2 += galsim.roman.thermal_backgrounds['H158'] * galsim.roman.exptime
        sky_image = im2.copy()
        gal.drawImage(im2, center=(23,17), add_to_image=True)
        rng = galsim.BaseDeviate(first_seed)
        poisson_noise = galsim.PoissonNoise(rng)
        im2.addNoise(poisson_noise)
        galsim.roman.addReciprocityFailure(im2)
        dc = galsim.roman.dark_current * galsim.roman.exptime
        sky_image += dc
        im2.addNoise(galsim.DeviateNoise(galsim.PoissonDeviate(rng, dc)))
        galsim.roman.applyNonlinearity(im2)
        galsim.roman.applyIPC(im2)
        im2.addNoise(galsim.GaussianNoise(rng, sigma=galsim.roman.read_noise))
        im2 /= galsim.roman.gain
        im2.quantize()
        sky_image /= galsim.roman.gain
        sky_image.quantize()
        im2 -= sky_image
        assert im1 == im2

        # If photon shooting, objects already have Poisson noise.
        # Also, all detector effects on is the default, so can remove these items from config.
        # And use explicit Bandpass, which is allowed (possibly to override the default bp's),
        # but in this case is equivalent to the default behavior.
        config = galsim.config.CleanConfig(config)
        del config['image']['stray_light']
        del config['image']['thermal_background']
        del config['image']['reciprocity_failure']
        del config['image']['dark_current']
        del config['image']['nonlinearity']
        del config['image']['ipc']
        del config['image']['read_noise']
        del config['image']['sky_subtract']
        config['stamp'] = { 'draw_method' : 'phot' }
        config['image']['bandpass'] = { 'type' : 'RomanBandpass', 'name' : 'H158' }
        im1 = galsim.config.BuildImage(config, obj_num=0, logger=logger)
        wcs.makeSkyImage(im2, sky_level)
        im2 += galsim.roman.thermal_backgrounds['H158'] * galsim.roman.exptime
        sky_image = im2.copy()
        rng = galsim.BaseDeviate(first_seed)
        poisson_noise = galsim.PoissonNoise(rng)
        im2.addNoise(poisson_noise)
        gal_rng = galsim.BaseDeviate(first_seed+1)
        gal.drawImage(im2, center=(23,17), add_to_image=True, method='phot', rng=gal_rng)
        galsim.roman.addReciprocityFailure(im2)
        dc = galsim.roman.dark_current * galsim.roman.exptime
        sky_image += dc
        im2.addNoise(galsim.DeviateNoise(galsim.PoissonDeviate(rng, dc)))
        galsim.roman.applyNonlinearity(im2)
        galsim.roman.applyIPC(im2)
        im2.addNoise(galsim.GaussianNoise(rng, sigma=galsim.roman.read_noise))
        im2 /= galsim.roman.gain
        im2.quantize()
        sky_image /= galsim.roman.gain
        sky_image.quantize()
        im2 -= sky_image
        assert im1 == im2


@timer
def test_aberration_interpolation(run_slow):
    """Test the Roman aberration interpolation method inside roman.roman_psfs
    """
    # We read in pairs of conjunction points, they are on different SCAs but are physically
    # adjacent on the FPA. The optical aberration between the two points in a pair should
    # be much less than the aberration range in the FPA. The maximum and minimum of aberration
    # in the FPA is pre-calculated using an 20x20 grid of positions on each SCA by Tianqing
    # Zhang. The conjunction pairs between the first row and the second row are considered
    # 'far', because are further separated. For 'far' pairs, z_diff<= 0.1*(z_max - z_min),
    # elsewhere, z_diff<= 0.05*(z_max - z_min).

    print("Start continuity test for aberration interpolation")

    # Make an arbitrary WCS.  We don't care about the sky positions, but we'll check
    # which SCAs have close neighbors on each side by round tripping through the sky.
    world_pos = galsim.CelestialCoord(127.*galsim.degrees, -70*galsim.degrees)
    date = datetime.datetime(2025,3,20,9,2,0)
    wcs = galsim.roman.getWCS(PA=0*galsim.degrees, world_pos=world_pos, date=date)

    # World position of the SCA centers.
    centers = { isca:  wcs[isca].toWorld(galsim.PositionD(2048,2048)) for isca in range(1,19) }

    # List of (x, y, dx, dy) for possible pairings.
    trial_positions = [(0,4046,-500,0), (2048,4096,0,500), (4096,4046,500,0),
                       (0,2048,-500,0),                    (4096,2048,500,0),
                       (0,50,-500,0),    (2048,0,0,-500),   (4096,50,500,0)    ]

    # Pairs will have tuples that look like:
    #   (sca1, x1, y1, sca2, x2, y2, far)
    # corresponding to points on two different SCAs that are physically close to each other.
    # far means we found a pair at 1000 pixels separation, but not 500.
    pairs = []
    for sca1 in range(1,19):
        for sca2 in range(sca1+1,19):
            # Nominal size of one SCA is 4096 * 0.11 arcsec/pix ~= 440 arcsec
            # If two SCAs are more than sqrt(2) times this apart, they don't share any edges.
            if centers[sca1].distanceTo(centers[sca2]) > 650 * galsim.arcsec: continue
            #print('Consider pair ', sca1, sca2)
            for x, y, dx, dy in trial_positions:
                pos2 = wcs[sca2].toImage(wcs[sca1].toWorld(galsim.PositionD(x+dx, y+dy)))
                #print('Position on sc2 = ',pos2)
                if 0 < pos2.x < 4096 and 0 < pos2.y < 4096:
                    #print('Valid')
                    pairs.append((sca1, x, y, sca2, pos2.x, pos2.y, False))
                elif x == 2048:
                    # For vertical offsets, also try doubling dy.
                    pos2 = wcs[sca2].toImage(wcs[sca1].toWorld(galsim.PositionD(x+dx, y+2*dy)))
                    #print('Far position on sc2 = ',pos2)
                    if 0 < pos2.x < 4096 and 0 < pos2.y < 4096:
                        #print('Valid for far')
                        pairs.append((sca1, x, y, sca2, pos2.x, pos2.y, True))

    for pair in pairs:
        print(pair)

    #Read the aberration and sca_pos for the interpolation *reference* points from the roman files.
    abers = { isca: galsim.roman.roman_psfs._read_aberrations(isca) for isca in range(1,19) }

    # Calculate the min/max zernike values across the FPA.
    aberration_array = np.concatenate([a[0] for a in abers.values()])
    print('all aberrations = ',aberration_array)
    print('max = ',np.max(aberration_array, axis=0))
    print('min = ',np.min(aberration_array, axis=0))
    Z_min = np.min(aberration_array, axis=0)
    Z_max = np.max(aberration_array, axis=0)
    Z_range = Z_max - Z_min

    if run_slow:
        from matplotlib import pyplot as plt

        world_pos = galsim.CelestialCoord(0.*galsim.degrees, 0*galsim.degrees)
        date = datetime.datetime(2025,5,20)
        wcs = galsim.roman.getWCS(PA=0*galsim.degrees, world_pos=world_pos, date=date)

        # Plot the value of each zernike coefficient across the fov.
        for i in range(1,23):
            fig = plt.figure(figsize=(6,6))
            ax = fig.subplots()
            ax.set_xlim(0.6, -0.6)  # +RA is left
            ax.set_ylim(-0.6, 0.6)
            ax.set_xlabel('RA')
            ax.set_ylabel('Dec')
            ax.set_title('Zernike i={}'.format(i))

            ra = []
            dec = []
            aber = []
            for sca in range(1,19):
                x,y = np.meshgrid(np.arange(0,4096,500), np.arange(0,4096,500))
                for xx,yy in zip(x.ravel(),y.ravel()):
                    ab = galsim.roman.roman_psfs._interp_aberrations_bilinear(
                        abers[sca][0], abers[sca][1], abers[sca][2],
                        SCA_pos=galsim.PositionD(xx,yy))
                    coord = wcs[sca].toWorld(galsim.PositionD(xx,yy))
                    print(i,sca,xx,yy,coord,ab[i])
                    ra.append(coord.ra.deg)
                    dec.append(coord.dec.deg)
                    aber.append(ab[i])
            ax.scatter(ra, dec, c=aber)
            plt.savefig('output/z{}.png'.format(i))
            #plt.show()
            plt.close()

    for sca1, x1, y1, sca2, x2, y2, far in pairs:
        print(sca1, x1, y1, sca2, x2, y2, far)

        #For each pair of conjunction points, calculate their aberration by calling
        # _interp_aberrations_bilinear
        point_1_abe = galsim.roman.roman_psfs._interp_aberrations_bilinear(
            abers[sca1][0], abers[sca1][1], abers[sca1][2],
            SCA_pos=galsim.PositionD(x1,y1))
        point_2_abe = galsim.roman.roman_psfs._interp_aberrations_bilinear(
            abers[sca2][0], abers[sca2][1], abers[sca2][2],
            SCA_pos=galsim.PositionD(x2,y2))

        for i in range(1,23):
            z_diff = np.abs(point_2_abe[i] - point_1_abe[i])
            print('  ',i,z_diff,point_1_abe[i],point_2_abe[i],Z_range[i],far,z_diff/Z_range[i])

            # We used to have different tolerances for far vs near separations, but now
            # the main thing is that different zernike coefficients are differently smooth.
            # 2-10 are all very smooth and most pass with frac=0.10.
            # 1 is worse, but 1 is weird that it is even in the data.
            # 11-14 are also a little choppy.
            # 15+ all look really bad in the plots (made above when run from main).
            # I think our code is ok here, but the data seem to be very noisy for the
            # higher order Zernikes.
            if i < 2:
                frac = 0.25
            elif i < 11:
                frac = 0.15
            elif i < 15:
                frac = 0.25
            else:
                frac = 0.5
            assert z_diff <= frac*(Z_range[i]),\
                'z_diff > {}(z_max - z_min), failed aberration continuity test.\
                \n Fail for Z{}, z_diff = {}, sca1 = {}, sca2 = {}'.format(frac,i,z_diff,sca1,sca2)

    print("Continuity test passes.")


@timer
def test_roman_focal_plane(run_slow):
    """Test that a full focal plane has everything oriented as shown in mapping_v210503.pdf
    """
    # For this test, we mostly try to plot points on each SCA that visually reproduce
    # the appearance of the red and blue arrows on Chris's plot of the focal plane.
    # We'll put in some asserts, but the real test is to open the produced figure and
    # compare the two diagrams visually.

    # Boresight at 0,0, so RA, Dec are essentially observatory coordinates.
    world_pos = galsim.CelestialCoord(0.*galsim.degrees, 0*galsim.degrees)
    date = datetime.datetime(2025,5,20)
    # Zero PA, so +Dec is up.  (Remember that +RA is left.)
    wcs = galsim.roman.getWCS(PA=0*galsim.degrees, world_pos=world_pos, date=date)

    red_arrows = {}
    blue_arrows = {}
    border = {}
    numbers = {}
    for sca in range(1,19):
        # Make the red arrow for this SCA.
        # x = 200, y = 200..3900, dotted.
        red_arrows[sca] = []
        x = 200
        for y in range(200,3901,10):
            if (y // 500) % 2 == 1: continue
            coord = wcs[sca].toWorld(galsim.PositionD(x,y))
            red_arrows[sca].append( (coord.ra.deg, coord.dec.deg) )

        # Make the blue arrow
        # y = 200, x = 200..3900, solid
        blue_arrows[sca] = []
        y = 200
        for x in range(200,3901,10):
            coord = wcs[sca].toWorld(galsim.PositionD(x,y))
            blue_arrows[sca].append( (coord.ra.deg, coord.dec.deg) )

        # Make a grey border around the edge
        border[sca] = []
        for xy in range(0,4096,10):
            coord = wcs[sca].toWorld(galsim.PositionD(xy,0))
            border[sca].append( (coord.ra.deg, coord.dec.deg) )
            coord = wcs[sca].toWorld(galsim.PositionD(0,xy))
            border[sca].append( (coord.ra.deg, coord.dec.deg) )
            coord = wcs[sca].toWorld(galsim.PositionD(xy,4096))
            border[sca].append( (coord.ra.deg, coord.dec.deg) )
            coord = wcs[sca].toWorld(galsim.PositionD(4096,xy))
            border[sca].append( (coord.ra.deg, coord.dec.deg) )

    # Make crude numbers for each SCA
    # Fit each number into rectangle from 1200<x<2800, 1000<y<3400
    numbers[1] = []
    x = 2000
    for y in range(1000,3401,10):
        numbers[1].append( (x,y) )
    for d in range(10,401,10):
        numbers[1].append( (2000-d, 3400-d) )

    numbers[2] = []
    for t in range(180,-91,-1):
        theta = t * galsim.degrees
        numbers[2].append( (2000+800*theta.cos(), 2600+800*theta.sin()) )
    for d in range(10,801,10):
        numbers[2].append( (2000-d, 1800-d) )
    y = 1000
    for x in range(1200,2801,10):
        numbers[2].append( (x, y) )

    numbers[3] = []
    for t in range(180,-91,-1):
        theta = t * galsim.degrees
        numbers[3].append( (2000+600*theta.cos(), 2800+600*theta.sin()) )
    for t in range(90,-181,-1):
        theta = t * galsim.degrees
        numbers[3].append( (2000+600*theta.cos(), 1600+600*theta.sin()) )

    numbers[4] = []
    x = 2400
    for y in range(1000,3401,10):
        numbers[4].append( (x,y) )
    for d in range(10,1201,10):
        numbers[4].append( (2400-d,3400-d) )
    y = 2200
    for x in range(1200,2801,10):
        numbers[4].append( (x,y) )

    numbers[5] = []
    for t in range(-160,161):
        theta = t * galsim.degrees
        numbers[5].append( (2000+800*theta.cos(), 1800+800*theta.sin()) )
    x = 1250
    for y in range(2080,3401,10):
        numbers[5].append( (x,y) )
    y = 3400
    for x in range(1250,2751,10):
        numbers[5].append( (x,y) )

    numbers[6] = []
    for t in range(30,180):
        theta = t * galsim.degrees
        numbers[6].append( (2000+800*theta.cos(), 2600+800*theta.sin()) )
    x = 1200
    for y in range(1800,2600,10):
        numbers[6].append( (x,y) )
    for t in range(-180,180):
        theta = t * galsim.degrees
        numbers[6].append( (2000+800*theta.cos(), 1800+800*theta.sin()) )

    numbers[7] = []
    for x in range(1200,2801,10):
        y = 1000 + 3*(x-1200)//2
        numbers[7].append( (x,y) )
    for x in range(1200,2801,10):
        numbers[7].append( (x,3400) )

    numbers[8] = []
    for t in range(0,360):
        theta = t * galsim.degrees
        numbers[8].append( (2000+600*theta.cos(), 2800+600*theta.sin()) )
    for t in range(0,360):
        theta = t * galsim.degrees
        numbers[8].append( (2000+600*theta.cos(), 1600+600*theta.sin()) )

    numbers[9] = []
    for t in range(-150,0):
        theta = t * galsim.degrees
        numbers[9].append( (2000+800*theta.cos(), 1800+800*theta.sin()) )
    x = 2800
    for y in range(1800,2600,10):
        numbers[9].append( (x,y) )
    for t in range(0,360):
        theta = t * galsim.degrees
        numbers[9].append( (2000+800*theta.cos(), 2600+800*theta.sin()) )

    numbers[10] = []
    for t in range(0,360):
        theta = t * galsim.degrees
        numbers[10].append( (2400+800*theta.cos(), 2200+1200*theta.sin()) )
    x = 1200
    for y in range(1000,3401,10):
        numbers[10].append( (x,y) )
    for d in range(10,401,10):
        numbers[10].append( (1200-d, 3400-d) )

    for sca in range(11,19):
        numbers[sca] = [(x+400,y) for x,y in numbers[sca-10]]
        x = 1200
        for y in range(1000,3401,10):
            numbers[sca].append( (x,y) )
        for d in range(10,401,10):
            numbers[sca].append( (1200-d, 3400-d) )

    # OK, now convert all the x,y in numbers to ra, dec
    for sca in range(1,19):
        coords = [wcs[sca].toWorld(galsim.PositionD(x,y)) for x,y in numbers[sca]]
        numbers[sca] = [(c.ra.deg, c.dec.deg) for c in coords]

    if run_slow:
        from matplotlib.figure import Figure
        from matplotlib.backends.backend_agg import FigureCanvasAgg

        # Make a plot of all these in the observatory coordinate system.
        fig = Figure(figsize=(6,6))
        ax = fig.subplots()
        ax.set_xlim(0.6, -0.6)  # +RA is left
        ax.set_ylim(-0.6, 0.6)
        ax.set_xlabel('RA')
        ax.set_ylabel('Dec')

        for sca in range(1,19):
            r,d = zip(*red_arrows[sca])
            ax.scatter(r, d, color='red', marker='.', s=2)
            r,d = zip(*blue_arrows[sca])
            ax.scatter(r, d, color='blue', marker='.', s=2)
            r,d = zip(*numbers[sca])
            ax.scatter(r, d, color='black', marker='.', s=2)
            r,d = zip(*border[sca])
            ax.scatter(r, d, color='grey', marker='.', s=1)

        canvas = FigureCanvasAgg(fig)
        fig.set_layout_engine('tight')
        file_name = os.path.join('output', 'test_roman_focal_plane.pdf')
        canvas.print_figure(file_name, dpi=300)
        print('Made ',file_name)
        print('Compare this to ../devel/roman/mapping_v210503.pdf.')

    # The real test is that the images look similar.  But turn this into some
    # assert statements.
    for sca in range(1,19):
        if sca%3 == 0:
            # 3,6,9,... have blue moving NE and red moving NW
            assert blue_arrows[sca][-1][0] > blue_arrows[sca][0][0]  # RA increases
            assert blue_arrows[sca][-1][1] > blue_arrows[sca][0][1]  # Dec increases
            assert red_arrows[sca][-1][0] < red_arrows[sca][0][0]    # RA decreases
            assert red_arrows[sca][-1][1] > red_arrows[sca][0][1]    # Dec increases
        else:
            # others have blue moving SW and red moving SE
            assert blue_arrows[sca][-1][0] < blue_arrows[sca][0][0]  # RA decreases
            assert blue_arrows[sca][-1][1] < blue_arrows[sca][0][1]  # Dec decreases
            assert red_arrows[sca][-1][0] > red_arrows[sca][0][0]    # RA increases
            assert red_arrows[sca][-1][1] < red_arrows[sca][0][1]    # Dec decreases


if __name__ == "__main__":
    runtests(__file__)
