RMS/Formats/FFfile.py
def read(directory, filename, fmt=None, array=False, full_filename=False, verbose=True):
def write(ff, directory, filename, fmt=None):
def reconstructFrame(ff, frame_no, avepixel=False):
def reconstruct(ff):
def selectFFFrames(img_input, ff, frame_min, frame_max):
def filenameToDatetime(file_name):
def getMiddleTimeFF(ff_name, fps, ret_milliseconds=True, ff_frames=256):
def validFFName(ff_file, fmt=None):
Utils/ShowerAssociation.py
def showerAssociation(config, ftpdetectinfo_list, shower_code=None, show_plot=False, save_plot=False, \
    plot_activity=False):
    """ Do single station shower association based on radiant direction and height. 
    
    Arguments:
        config: [Config instance]
        ftpdetectinfo_list: [list] A list of paths to FTPdetectinfo files.

    Keyword arguments:
        shower_code: [str] Only use this one shower for association (e.g. ETA, PER, SDA). None by default,
            in which case all active showers will be associated.
        show_plot: [bool] Show the plot on the screen. False by default.
        save_plot: [bool] Save the plot in the folder with FTPdetectinfos. False by default.
        plot_activity: [bool] Whether to plot the shower activity plot of not. False by default.

    Return:
        associations, shower_counts: [tuple]
            - associations: [dict] A dictionary where the FF name and the meteor ordinal number on the FF
                file are keys, and the associated Shower object are values.
            - shower_counts: [list] A list of shower code and shower count pairs.
    """

    # Load the list of meteor showers
    shower_list = loadShowers(config.shower_path, config.shower_file_name)


    # Load FTPdetectinfos
    meteor_data = []
    for ftpdetectinfo_path in ftpdetectinfo_list:

        if not os.path.isfile(ftpdetectinfo_path):
            print('No such file:', ftpdetectinfo_path)
            continue

        meteor_data += readFTPdetectinfo(*os.path.split(ftpdetectinfo_path))

    if not len(meteor_data):
        return {}, []


    # Dictionary which holds FF names as keys and meteor measurements + associated showers as values
    associations = {}

    for meteor in meteor_data:

        ff_name, cam_code, meteor_No, n_segments, fps, hnr, mle, binn, px_fm, rho, phi, meteor_meas = meteor

        # Skip very short meteors
        if len(meteor_meas) < 4:
            continue

        # Check if the data is calibrated
        if not meteor_meas[0][0]:
            print('Data is not calibrated! Meteors cannot be associated to showers!')
            break


        # Init container for meteor observation
        meteor_obj = MeteorSingleStation(cam_code, config.latitude, config.longitude, ff_name)

        # Infill the meteor structure
        for entry in meteor_meas:
            
            calib_status, frame_n, x, y, ra, dec, azim, elev, inten, mag = entry

            # Compute the Julian data of every point
            jd = datetime2JD(filenameToDatetime(ff_name) + datetime.timedelta(seconds=float(frame_n)/fps))

            meteor_obj.addPoint(jd, ra, dec, mag)

            
        # Fit the great circle and compute the geometrical parameters
        meteor_obj.fitGC()


        # Skip all meteors with beginning heights below 15 deg
        if meteor_obj.beg_alt < 15:
            continue

        
        # Go through all showers in the list and find the best match
        best_match_shower = None
        best_match_dist = np.inf
        for shower_entry in shower_list:

            # Extract shower parameters
            shower = Shower(shower_entry)


            # If the shower code was given, only check this one shower
            if shower_code is not None:
                if shower.name.lower() != shower_code.lower():
                    continue



            ### Solar longitude filter

            # If the shower doesn't have a stated beginning or end, check if the meteor is within a preset
            # threshold solar longitude difference
            if np.any(np.isnan([shower.lasun_beg, shower.lasun_end])):

                shower.lasun_beg = (shower.lasun_max - config.shower_lasun_threshold)%360
                shower.lasun_end = (shower.lasun_max + config.shower_lasun_threshold)%360


            # Filter out all showers which are not active    
            if not isAngleBetween(np.radians(shower.lasun_beg), np.radians(meteor_obj.lasun), 
                np.radians(shower.lasun_end)):

                continue

            ### ###


            ### Radiant filter ###

            # Assume a fixed meteor height for an approximate apparent radiant
            meteor_fixed_ht = 100000 # 100 km
            shower.computeApparentRadiant(config.latitude, config.longitude, meteor_obj.jdt_ref, \
                meteor_fixed_ht=meteor_fixed_ht)

            # Compute the angle between the meteor radiant and the great circle normal
            radiant_separation = meteor_obj.angularSeparationFromGC(shower.ra, shower.dec)


            # Make sure the meteor is within the radiant distance threshold
            if radiant_separation > config.shower_max_radiant_separation:
                continue


            # Compute angle between the meteor's beginning and end, and the shower radiant
            shower.radiant_vector = vectNorm(raDec2Vector(shower.ra, shower.dec))
            begin_separation = np.degrees(angularSeparationVect(shower.radiant_vector, \
                meteor_obj.meteor_begin_cartesian))
            end_separation = np.degrees(angularSeparationVect(shower.radiant_vector, \
                meteor_obj.meteor_end_cartesian))


            # Make sure the beginning of the meteor is closer to the radiant than it's end
            if begin_separation > end_separation:
                continue

            ### ###


            ### Height filter ###

            # Estimate the limiting meteor height from the velocity (meters)
            filter_beg_ht = heightModel(shower.v_init, ht_type='beg')
            filter_end_ht = heightModel(shower.v_init, ht_type='end')


            ### Estimate the meteor beginning height with +/- 1 frame, otherwise some short meteor may get
            ###   rejected

            meteor_obj_orig = copy.deepcopy(meteor_obj)

            # Shorter
            meteor_obj_m1 = copy.deepcopy(meteor_obj_orig)
            meteor_obj_m1.duration -= 1.0/config.fps
            meteor_beg_ht_m1 = estimateMeteorHeight(config, meteor_obj_m1, shower)

            # Nominal
            meteor_beg_ht = estimateMeteorHeight(config, meteor_obj_orig, shower)

            # Longer
            meteor_obj_p1 = copy.deepcopy(meteor_obj_orig)
            meteor_obj_p1.duration += 1.0/config.fps
            meteor_beg_ht_p1 = estimateMeteorHeight(config, meteor_obj_p1, shower)


            meteor_obj = meteor_obj_orig


            ### ###

            
            # If all heights (even those with +/- 1 frame) are outside the height range, reject the meteor
            if ((meteor_beg_ht_p1 < filter_end_ht) or (meteor_beg_ht_p1 > filter_beg_ht)) and \
                ((meteor_beg_ht    < filter_end_ht) or (meteor_beg_ht    > filter_beg_ht)) and \
                ((meteor_beg_ht_m1 < filter_end_ht) or (meteor_beg_ht_m1 > filter_beg_ht)):

                continue

            ### ###


            # Compute the radiant elevation above the horizon
            shower.azim, shower.elev = raDec2AltAz(shower.ra, shower.dec, meteor_obj.jdt_ref, \
                config.latitude, config.longitude)


            # Take the shower that's closest to the great circle if there are multiple candidates
            if radiant_separation < best_match_dist:
                best_match_dist = radiant_separation
                best_match_shower = copy.deepcopy(shower)


        # If a shower is given and the match is not this shower, skip adding the meteor to the list
        # If no specific shower is give for association, add all meteors
        if ((shower_code is not None) and (best_match_shower is not None)) or (shower_code is None):

            # Store the associated shower
            associations[(ff_name, meteor_No)] = [meteor_obj, best_match_shower]


    # Find shower frequency and sort by count
    shower_name_list_temp = []
    shower_list_temp = []
    for key in associations:
        _, shower = associations[key]

        if shower is None:
            shower_name = '...'
        else:
            shower_name = shower.name

        shower_name_list_temp.append(shower_name)
        shower_list_temp.append(shower)

    _, unique_showers_indices = np.unique(shower_name_list_temp, return_index=True)
    unique_shower_names = np.array(shower_name_list_temp)[unique_showers_indices]
    unique_showers = np.array(shower_list_temp)[unique_showers_indices]
    shower_counts = [[shower_obj, shower_name_list_temp.count(shower_name)] for shower_obj, \
        shower_name in zip(unique_showers, unique_shower_names)]
    shower_counts = sorted(shower_counts, key=lambda x: x[1], reverse=True)


    # Create a plot of showers
    if show_plot or save_plot:
        # Generate consistent colours
        colors_by_name = makeShowerColors(shower_list)
        def get_shower_color(shower):
            try:
                return colors_by_name[shower.name] if shower else "0.4"
            except KeyError:
                return 'gray'

        # Init the figure
        plt.figure()

        # Init subplots depending on if the activity plot is done as well
        if plot_activity:
            gs = gridspec.GridSpec(2, 1, height_ratios=[3, 1])
            ax_allsky = plt.subplot(gs[0], facecolor='black')
            ax_activity = plt.subplot(gs[1], facecolor='black')
        else:
            ax_allsky = plt.subplot(111, facecolor='black')


        # Init the all-sky plot
        allsky_plot = AllSkyPlot(ax_handle=ax_allsky)

        # Plot all meteors
        for key in associations:

            meteor_obj, shower = associations[key]


            ### Plot the observed meteor points ###
            color = get_shower_color(shower)
            allsky_plot.plot(meteor_obj.ra_array, meteor_obj.dec_array, color=color, linewidth=1, zorder=4)

            # Plot the peak of shower meteors a different color
            peak_color = 'blue'
            if shower is not None:
                peak_color = 'tomato'

            allsky_plot.scatter(meteor_obj.ra_array[-1], meteor_obj.dec_array[-1], c=peak_color, marker='+', \
                s=5, zorder=5)

            ### ###


            ### Plot fitted great circle points ###

            # Find the GC phase angle of the beginning of the meteor
            gc_beg_phase = meteor_obj.findGCPhase(meteor_obj.ra_array[0], meteor_obj.dec_array[0])[0]%360

            # If the meteor belongs to a shower, find the GC phase which ends at the shower
            if shower is not None:
                gc_end_phase = meteor_obj.findGCPhase(shower.ra, shower.dec)[0]%360

                # Fix 0/360 wrap
                if abs(gc_end_phase - gc_beg_phase) > 180:
                    if gc_end_phase > gc_beg_phase:
                        gc_end_phase -= 360
                    else:
                        gc_beg_phase -= 360

                gc_alpha = 1.0


            else:

                # If it's a sporadic, find the direction to which the meteor should extend
                gc_end_phase = meteor_obj.findGCPhase(meteor_obj.ra_array[-1], \
                    meteor_obj.dec_array[-1])[0]%360

                # Find the correct direction
                if (gc_beg_phase - gc_end_phase)%360 > (gc_end_phase - gc_beg_phase)%360:
                    gc_end_phase = gc_beg_phase - 170

                else:
                    gc_end_phase = gc_beg_phase + 170

                gc_alpha = 0.7


            # Store great circle beginning and end phase
            meteor_obj.gc_beg_phase = gc_beg_phase
            meteor_obj.gc_end_phase = gc_end_phase

            # Get phases 180 deg before the meteor
            phase_angles = np.linspace(gc_end_phase, gc_beg_phase, 100)%360

            # Compute RA/Dec of points on the great circle
            ra_gc, dec_gc = meteor_obj.sampleGC(phase_angles)

            # Cull all points below the horizon
            azim_gc, elev_gc = raDec2AltAz(ra_gc, dec_gc, meteor_obj.jdt_ref, config.latitude, \
                config.longitude)
            temp_arr = np.c_[ra_gc, dec_gc]
            temp_arr = temp_arr[elev_gc > 0]
            ra_gc, dec_gc = temp_arr.T

            # Plot the great circle fitted on the radiant
            gc_color = get_shower_color(shower)
            allsky_plot.plot(ra_gc, dec_gc, linestyle='dotted', color=gc_color, alpha=gc_alpha, linewidth=1)

            # Plot the point closest to the shower radiant
            if shower is not None:
                allsky_plot.plot(ra_gc[0], dec_gc[0], color='r', marker='+', ms=5, mew=1)

                # Store shower radiant point
                meteor_obj.radiant_ra = ra_gc[0]
                meteor_obj.radiant_dec = dec_gc[0]


            ### ###


        ### Plot all showers ###

        # Find unique showers and their apparent radiants computed at highest radiant elevation
        # (otherwise the apparent radiants can be quite off)
        shower_dict = {}
        for key in associations:
            meteor_obj, shower = associations[key]

            if shower is None:
                continue

            # If the shower name is in dict, find the shower with the highest radiant elevation
            if shower.name in shower_dict:
                if shower.elev > shower_dict[shower.name].elev:
                    shower_dict[shower.name] = shower
                
            else:
                shower_dict[shower.name] = shower


        # Plot the location of shower radiants
        for shower_name in shower_dict:
            
            shower = shower_dict[shower_name]

            heading_arr = np.linspace(0, 360, 50)

            # Compute coordinates on a circle around the given RA, Dec
            ra_circle, dec_circle = sphericalPointFromHeadingAndDistance(shower.ra, shower.dec, \
                heading_arr, config.shower_max_radiant_separation)


            # Plot the shower circle
            allsky_plot.plot(ra_circle, dec_circle, color=colors_by_name[shower_name])


            # Plot the shower name
            x_text, y_text = allsky_plot.raDec2XY(shower.ra, shower.dec)
            allsky_plot.ax.text(x_text, y_text, shower.name, color='w', size=8, va='center', \
                ha='center', zorder=6)



        # Plot station name and solar longiutde range
        allsky_plot.ax.text(-180, 89, "{:s}".format(cam_code), color='w', family='monospace')

        # Get a list of JDs of meteors
        jd_list = [associations[key][0].jdt_ref for key in associations]

        if len(jd_list):

            # Get the range of solar longitudes
            jd_min = min(jd_list)
            sol_min = np.degrees(jd2SolLonSteyaert(jd_min))
            jd_max = max(jd_list)
            sol_max = np.degrees(jd2SolLonSteyaert(jd_max))

            # Plot the date and solar longitude range
            date_sol_beg = u"Beg: {:s} (sol = {:.2f}\u00b0)".format(jd2Date(jd_min, dt_obj=True).strftime("%Y%m%d %H:%M:%S"), sol_min)
            date_sol_end = u"End: {:s} (sol = {:.2f}\u00b0)".format(jd2Date(jd_max, dt_obj=True).strftime("%Y%m%d %H:%M:%S"), sol_max)
            
            allsky_plot.ax.text(-180, 85, date_sol_beg, color='w', family='monospace')
            allsky_plot.ax.text(-180, 81, date_sol_end, color='w', family='monospace')
            allsky_plot.ax.text(-180, 77, "-"*len(date_sol_end), color='w', family='monospace')

            # Plot shower counts
            for i, (shower, count) in enumerate(shower_counts):

                if shower is not None:
                    shower_name = shower.name
                else:
                    shower_name = "..."

                allsky_plot.ax.text(-180, 73 - i*4, "{:s}: {:d}".format(shower_name, count), color='w', \
                    family='monospace')


            ### ###

            # Plot yearly meteor shower activity
            if plot_activity:

                # Plot the activity diagram
                generateActivityDiagram(config, shower_list, ax_handle=ax_activity, \
                    sol_marker=[sol_min, sol_max], colors=colors_by_name)


        

        # Save plot and text file
        if save_plot:

            dir_path, ftpdetectinfo_name = os.path.split(ftpdetectinfo_path)
            ftpdetectinfo_base_name = ftpdetectinfo_name.replace('FTPdetectinfo_', '').replace('.txt', '')
            plot_name = ftpdetectinfo_base_name + '_radiants.png'

            # Increase figure size
            allsky_plot.fig.set_size_inches(18, 9, forward=True)

            allsky_plot.beautify()

            plt.savefig(os.path.join(dir_path, plot_name), dpi=100, facecolor='k')



            # Save the text file with shower info
            if len(jd_list):
                with open(os.path.join(dir_path, ftpdetectinfo_base_name + "_radiants.txt"), 'w') as f:

                    # Print station code
                    f.write("# RMS single station association\n")
                    f.write("# \n")
                    f.write("# Station: {:s}\n".format(cam_code))

                    # Print date range
                    f.write("#                    Beg          |            End            \n")
                    f.write("#      -----------------------------------------------------\n")
                    f.write("# Date | {:24s} | {:24s} \n".format(jd2Date(jd_min, \
                        dt_obj=True).strftime("%Y%m%d %H:%M:%S.%f"), jd2Date(jd_max, \
                        dt_obj=True).strftime("%Y%m%d %H:%M:%S.%f")))
                    f.write("# Sol  | {:>24.2f} | {:>24.2f} \n".format(sol_min, sol_max))

                    # Write shower counts
                    f.write("# \n")
                    f.write("# Shower counts:\n")
                    f.write("# --------------\n")
                    f.write("# Code, Count, IAU link\n")

                    for i, (shower, count) in enumerate(shower_counts):

                        if shower is not None:
                            shower_name = shower.name

                            # Create link to the IAU database of showers
                            iau_link = "https://www.ta3.sk/IAUC22DB/MDC2007/Roje/pojedynczy_obiekt.php?kodstrumienia={:05d}".format(shower.iau_code)

                        else:
                            shower_name = "..."
                            iau_link = "None"

                        f.write("# {:>4s}, {:>5d}, {:s}\n".format(shower_name, count, iau_link))



                    f.write("# \n")
                    f.write("# Meteor parameters:\n")
                    f.write("# ------------------\n")
                    f.write("#          Date And Time,      Beg Julian date,     La Sun, Shower, RA beg, Dec beg, RA end, Dec end, RA rad, Dec rad, GC theta0,  GC phi0, GC beg phase, GC end phase,  Mag\n")


                    # Create a sorted list of meteor associations by time
                    associations_list = [associations[key] for key in associations]
                    associations_list = sorted(associations_list, key=lambda x: x[0].jdt_ref)

                    # Write out meteor parameters
                    for meteor_obj, shower in associations_list:

                        # Find peak magnitude
                        if np.any(meteor_obj.mag_array):
                            peak_mag = "{:+.1f}".format(np.min(meteor_obj.mag_array))

                        else:
                            peak_mag = "None"


                        if shower is not None:

                            f.write("{:24s}, {:20.12f}, {:>10.6f}, {:>6s}, {:6.2f}, {:+7.2f}, {:6.2f}, {:+7.2f}, {:6.2f}, {:+7.2f}, {:9.3f}, {:8.3f}, {:12.3f}, {:12.3f}, {:4s}\n".format(jd2Date(meteor_obj.jdt_ref, dt_obj=True).strftime("%Y%m%d %H:%M:%S.%f"), \
                                meteor_obj.jdt_ref, meteor_obj.lasun, shower.name, \
                                meteor_obj.ra_array[0]%360, meteor_obj.dec_array[0], \
                                meteor_obj.ra_array[-1]%360, meteor_obj.dec_array[-1], \
                                meteor_obj.radiant_ra%360, meteor_obj.radiant_dec, \
                                np.degrees(meteor_obj.theta0), np.degrees(meteor_obj.phi0), \
                                meteor_obj.gc_beg_phase, meteor_obj.gc_end_phase, peak_mag))

                        else:
                            f.write("{:24s}, {:20.12f}, {:>10.6f}, {:>6s}, {:6.2f}, {:+7.2f}, {:6.2f}, {:+7.2f}, {:>6s}, {:>7s}, {:9.3f}, {:8.3f}, {:12.3f}, {:12.3f}, {:4s}\n".format(jd2Date(meteor_obj.jdt_ref, dt_obj=True).strftime("%Y%m%d %H:%M:%S.%f"), \
                                meteor_obj.jdt_ref, meteor_obj.lasun, '...', meteor_obj.ra_array[0]%360, \
                                meteor_obj.dec_array[0], meteor_obj.ra_array[-1]%360, \
                                meteor_obj.dec_array[-1], "None", "None", np.degrees(meteor_obj.theta0), \
                                np.degrees(meteor_obj.phi0), meteor_obj.gc_beg_phase, \
                                meteor_obj.gc_end_phase, peak_mag))





        if show_plot:
            allsky_plot.show()

        else:
            plt.clf()
            plt.close()



    return associations, shower_counts
RMS/Routines/FOVArea.py
def xyHt2Geo(platepar, x, y, area_ht, indicate_limit=False, elev_limit=5):
    """ Given pixel coordiantes on the image and a height above sea level, compute geo coordiantes of the
        point. The elevation is limited to 5 deg above horizon.

    Arguments:
        platepar: [Platepar object]
        x: [float] Image X coordinate.
        y: [float] Image Y coordiante.
        area_ht: [float] Height above sea level (meters).

    Keyword arguments:
        indicate_limit: [bool] Indicate that the elevation was below the limit of 5 deg by setting the
            height to -1. False by default.
        elev_limit: [float] Limit of elevation above horizon (deg). 5 degrees by default.

    
    Return:
        (r, lat, lon, ht): [tuple of floats] range in meters, latitude and longitude in degrees, \
            WGS84 height in meters

    """

    
    # Compute RA/Dec in J2000 of the image point, at J2000 epoch time so we don't have to precess
    _, ra, dec, _ = xyToRaDecPP([jd2Date(J2000_JD.days)], [x], [y], [1], platepar, \
        extinction_correction=False)

    # Compute alt/az of the point
    azim, elev = raDec2AltAz(ra[0], dec[0], J2000_JD.days, platepar.lat, platepar.lon)

    # Limit the elevation to elev_limit degrees above the horizon
    limit_hit = False
    if elev < elev_limit:
        elev = elev_limit
        limit_hit = True

    # Compute the geo location of the point along the line of sight
    p_r, p_lat, p_lon, p_ht = AEH2LatLonAlt(azim, elev, area_ht, platepar.lat, platepar.lon, \
        platepar.elev)


    # If the elevation limit was hit, and the indicate flag is True, set the elevation to -1
    if indicate_limit and limit_hit:
        p_ht = -1

    return p_r, p_lat, p_lon, p_ht
RMS/Astrometry/Conversions.py
def datetime2JD(dt, UT_corr=0.0):
    """ Converts a datetime object to Julian date.
    Arguments:
        dt: [datetime object]
    Keyword arguments:
        UT_corr: [float] UT correction in hours (difference from local time to UT)
    Return:
        jd: [float] Julian date
    """

    return date2JD(dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond/1000.0,
                   UT_corr=UT_corr)
RMS/Astrometry/Conversions.py
J2000_JD = timedelta(2451545)  # julian epoch in julian dates
RMS/Routines/SolarLongitude.py
def jd2SolLonSteyaert(jd):
    """ Convert the given Julian date to solar longitude, J2000.0 epoch. Chris Steyaert method.

    Reference: Steyaert, C. (1991). Calculating the solar longitude 2000.0. WGN, Journal of the International 
        Meteor Organization, 19, 31-34.

    Arguments:
        jd: [float] julian date

    Return:
        [float] solar longitude in radians, J2000.0 epoch

    """

    # Define time constants
    A0 = [334166, 3489, 350, 342, 314, 268, 234, 132, 127, 120, 99, 90, 86, 78, 75, 51, 49, 36, 32, 28, 27, 
        24, 21, 21, 20, 16, 13, 13]

    B0 = [4.669257, 4.6261, 2.744, 2.829, 3.628, 4.418, 6.135, 0.742, 2.037, 1.110, 5.233, 2.045, 3.508, 
        1.179, 2.533, 4.58, 4.21, 2.92, 5.85, 1.90, 0.31, 0.34, 4.81, 1.87, 2.46, 0.83, 3.41, 1.08]

    C0 = [6283.07585, 12566.1517, 5753.385, 3.523, 77713.771, 7860.419, 3930.210, 11506.77, 529.691, 1577.344, 
        5884.927, 26.298, 398.149, 5223.694, 5507.553, 18849.23, 775.52, 0.07, 11790.63, 796.3, 10977.08, 
        5486.78, 2544.31, 5573.14, 6069.78, 213.3, 2942.46, 20.78]

    A1 = [20606, 430, 43]
    B1 = [2.67823, 2.635, 1.59]
    C1 = [6283.07585, 12566.152, 3.52]

    A2 = [872, 29]
    B2 = [1.073, 0.44]
    C2 = [6283.07585, 12566.15]

    A3 = 29
    B3 = 5.84
    C3 = 6283.07585

    # Number of millennia since 2000
    T = (jd - 2451545.0)/365250.0

    # Mean solar longitude
    L0 = 4.8950627 + 6283.07585*T - 0.0000099*T**2

    # Wrap L0 to [0, 2pi] range
    L0 = L0%(2*np.pi)

    # Periodical terms
    S0 = np.sum([A0[i]*np.cos((B0[i] + C0[i]*T)%(2*np.pi)) for i in range(28)])
    S1 = np.sum([A1[i]*np.cos((B1[i] + C1[i]*T)%(2*np.pi)) for i in range(3)])
    S2 = np.sum([A2[i]*np.cos((B2[i] + C2[i]*T)%(2*np.pi)) for i in range(2)])
    S3 = A3*np.cos((B3 + C3*T)%(2*np.pi))

    # Solar longitude of J2000.0
    L = L0 + (S0 + S1*T + S2*T**2 + S3*T**3)*1e-7

    # Bound to solar longitude to the [0, 2pi] range
    L = L%(2*np.pi)

    return L
RMS/Astrometry/Conversions.py
def jd2Date(jd, UT_corr=0, dt_obj=False):
    """ Converts the given Julian date to (year, month, day, hour, minute, second, millisecond) tuple.
    Arguments:
        jd: [float] Julian date
    Keyword arguments:
        UT_corr: [float] UT correction in hours (difference from local time to UT)
        dt_obj: [bool] returns a datetime object if True. False by default.
    Return:
        (year, month, day, hour, minute, second, millisecond)
    """

    dt = timedelta(days=jd)

    try:
        date = dt + JULIAN_EPOCH - J2000_JD + timedelta(hours=UT_corr)

    # If the date is out of range (i.e. before year 1) use year 1. This is the limitation in the datetime
    # library. Time handling should be switched to astropy.time
    except OverflowError:
        date = datetime(MINYEAR, 1, 1, 0, 0, 0)

    # Return a datetime object if dt_obj == True
    if dt_obj:
        return date

    return date.year, date.month, date.day, date.hour, date.minute, date.second, date.microsecond/1000.0
RMS/Astrometry/Conversions.py
def raDec2AltAz(ra, dec, jd, lat, lon):
    """ Calculate the reference azimuth and altitude of the centre of the FOV from the given RA/Dec.
    Arguments:
        ra:  [float] Right ascension in degrees.
        dec: [float] Declination in degrees.
        jd: [float] Reference Julian date.
        lat: [float] Latitude +N in degrees.
        lon: [float] Longitude +E in degrees.
    Return:
        (azim, elev): [tuple of float]: Azimuth and elevation (degrees).
    """
    ra = np.radians(ra)
    dec = np.radians(dec)
    lat = np.radians(lat)
    lon = np.radians(lon)

    # Compute azim and elev using a fast cython function
    if isinstance(ra, float) or isinstance(ra, int) or isinstance(ra, np.float64):
        azim, elev = cyraDec2AltAz(ra, dec, jd, lat, lon)

    elif isinstance(ra, np.ndarray):
        # Compute it for numpy arrays
        azim, elev = cyraDec2AltAz_vect(ra, dec, jd, lat, lon)

    else:
        raise TypeError("ra must be a number or np.ndarray, given: {}".format(type(ra)))

    return np.degrees(azim), np.degrees(elev)
RMS/ExtractStars.py
def extractStarsAndSave(config, ff_dir):
    """ Extract stars in the given folder and save the CALSTARS file. 
    
    Arguments:
        config: [config object] configuration object (loaded from the .config file)
        ff_dir: [str] Path to directory where FF files are.

    Return:
        star_list: [list] A list of [ff_name, star_data] entries, where star_data contains a list of 
            (column, row, amplitude, intensity, fwhm) values for every star.

    """




    time_start = time.time()

    # Load mask, dark, flat
    mask, dark, flat_struct = loadImageCalibration(ff_dir, config)
    

    extraction_list = []

    # Go through all files in the directory and add them to the detection list
    for ff_name in sorted(os.listdir(ff_dir)):

        # Check if the given file is a valid FF file
        if not FFfile.validFFName(ff_name):
            continue

        extraction_list.append(ff_name)



    # Run the QueuedPool for detection
    workpool = QueuedPool(extractStars, cores=-1, backup_dir=ff_dir)


    # Add jobs for the pool
    for ff_name in extraction_list:
        print('Adding for extraction:', ff_name)
        workpool.addJob([ff_dir, ff_name, config, None, None, None, None, flat_struct, dark, mask])


    print('Starting pool...')

    # Start the detection
    workpool.startPool()


    print('Waiting for the detection to finish...')

    # Wait for the detector to finish and close it
    workpool.closePool()


    # Get extraction results
    star_list = []
    for result in workpool.getResults():

        ff_name, x2, y2, amplitude, intensity, fwhm_data = result

        # Skip if no stars were found
        if not x2:
            continue


        # Construct the table of the star parameters
        star_data = list(zip(x2, y2, amplitude, intensity, fwhm_data))

        # Add star info to the star list
        star_list.append([ff_name, star_data])



    dir_name = os.path.basename(os.path.abspath(ff_dir))
    if dir_name.startswith(config.stationID):
        prefix = dir_name
    else:
        prefix = "{:s}_{:s}".format(config.stationID, dir_name)

    # Generate the name for the CALSTARS file
    calstars_name = 'CALSTARS_' + prefix + '.txt'


    # Write detected stars to the CALSTARS file
    CALSTARS.writeCALSTARS(star_list, ff_dir, calstars_name, config.stationID, config.height, config.width)

    # Delete QueudPool backed up files
    workpool.deleteBackupFiles()

    print('Total time taken: {:.2f} s'.format(time.time() - time_start))


    return star_list
RMS/Astrometry/ApplyAstrometry.py
def xyToRaDecPP(time_data, X_data, Y_data, level_data, platepar, extinction_correction=True, \
    measurement=False):
    """ Converts image XY to RA,Dec, but it takes a platepar instead of individual parameters. 

    Arguments:
        time_data: [2D ndarray] Numpy array containing time tuples of each data point (year, month, day,
            hour, minute, second, millisecond).
        X_data: [ndarray] 1D numpy array containing the image X component.
        Y_data: [ndarray] 1D numpy array containing the image Y component.
        level_data: [ndarray] Levels of the meteor centroid.
        platepar: [Platepar structure] Astrometry parameters.

    Keyword arguments:
        extinction_correction: [bool] Apply extinction correction. True by default. False is set to prevent 
            infinite recursion in extinctionCorrectionApparentToTrue when set to True.
        measurement: [bool] Indicates if the given images values are image measurements. Used for correcting
            celestial coordinates for refraction if the refraction was not taken into account during
            plate fitting.

    Return:
        (JD_data, RA_data, dec_data, magnitude_data): [tuple of ndarrays]
            JD_data: [ndarray] Julian date of each data point.
            RA_data: [ndarray] Right ascension of each point (deg).
            dec_data: [ndarray] Declination of each point (deg).
            magnitude_data: [ndarray] Array of meteor's lightcurve apparent magnitudes.
    """


    # Convert time to Julian date
    JD_data = np.array([date2JD(*time_data_entry) for time_data_entry in time_data], dtype=np.float64)

    # Convert x,y to RA/Dec using a fast cython function
    RA_data, dec_data = cyXYToRADec(JD_data, np.array(X_data, dtype=np.float64), \
        np.array(Y_data, dtype=np.float64), float(platepar.lat), float(platepar.lon), float(platepar.X_res), \
        float(platepar.Y_res), float(platepar.Ho), float(platepar.RA_d), float(platepar.dec_d), \
        float(platepar.pos_angle_ref), float(platepar.F_scale), platepar.x_poly_fwd, platepar.y_poly_fwd, \
        unicode(platepar.distortion_type), refraction=platepar.refraction, \
        equal_aspect=platepar.equal_aspect, force_distortion_centre=platepar.force_distortion_centre, \
        asymmetry_corr=platepar.asymmetry_corr)

    # Correct the coordinates for refraction if it wasn't taken into account during the astrometry calibration
    #   procedure
    if (not platepar.refraction) and measurement and platepar.measurement_apparent_to_true_refraction:
        for i, entry in enumerate(zip(JD_data, RA_data, dec_data)):
            jd, ra, dec = entry
            ra, dec = eqRefractionApparentToTrue(np.radians(ra), np.radians(dec), jd, \
                np.radians(platepar.lat), np.radians(platepar.lon))

            RA_data[i] = np.degrees(ra)
            dec_data[i] = np.degrees(dec)
            

    # Compute radiia from image centre
    radius_arr = np.hypot(np.array(X_data) - platepar.X_res/2, np.array(Y_data) - platepar.Y_res/2)

    # Calculate magnitudes
    magnitude_data = calculateMagnitudes(level_data, radius_arr, platepar.mag_lev, platepar.vignetting_coeff)


    # Extinction correction
    if extinction_correction:
        magnitude_data = extinctionCorrectionApparentToTrue(magnitude_data, X_data, Y_data, JD_data[0], \
            platepar)


    return JD_data, RA_data, dec_data, magnitude_data
RMS/Astrometry/Conversions.py
def areaGeoPolygon(lats, lons, ht):
    """ Computes area of spherical polygon given by geo coordinates, assuming spherical Earth. 
        Line integral based on Green's Theorem.

        Source: https://stackoverflow.com/a/61184491

    Arguments:
        lats: [list/ndarray] A list of latitudes (degrees).
        lons: [list/ndarray] A list of longitudes (degrees).
        ht: [float] Height above sea level (meters).

    Return:
        area: [float] Area enclosed by the polygon in m^2.
    
    """

    lats = np.radians(np.array(lats))
    lons = np.radians(np.array(lons))

    # Compute the mean latitude
    lat_mean = np.mean(lats)

    # Get distance from Earth centre to the position given by mean geographical coordinates, in WGS84 (m)
    N = EARTH.EQUATORIAL_RADIUS/np.sqrt(1.0 - (EARTH.E**2)*np.sin(lat_mean)**2)

    # Compute the total radius including the height
    radius = N + ht


    # Check if a closed polygon is given, and if not, close it
    if (lats[0] != lats[-1]) or (lons[0] != lons[-1]):
        lats = np.append(lats, lats[0])
        lons = np.append(lons, lons[0])

    # Get colatitude (a measure of surface distance as an angle)
    a = np.sin(lats/2)**2 + np.cos(lats)*np.sin(lons/2)**2
    colat = 2*np.arctan2(np.sqrt(a), np.sqrt(1 - a))

    # Azimuth of each point in segment from the arbitrary origin
    az = np.arctan2(np.cos(lats)*np.sin(lons), np.sin(lats))%(2*np.pi)

    # Calculate step sizes
    daz = np.diff(az)
    daz = (daz + np.pi)%(2*np.pi) - np.pi

    # Determine average surface distance for each step
    deltas = np.diff(colat)/2
    colat = colat[0:-1] + deltas

    # Integral over azimuth is 1-cos(colatitudes)
    integrands = (1 - np.cos(colat))*daz

    # Integrate and save the answer as a fraction of the unit sphere.
    # Note that the sum of the integrands will include a factor of 4pi.
    area = abs(sum(integrands))/(4*np.pi)

    # Could be area of inside or outside the polygon, choose the smaller value aka. the inner area
    area = min(area, 1 - area)

    # Compute the area in square meters
    return area*4*np.pi*radius**2
RMS/Routines/FOVArea.py
def fovArea(platepar, mask=None, area_ht=100000, side_points=10, elev_limit=5):
    """ Given a platepar file and a mask file, compute geo points of the FOV edges at the given height.

    Arguments:
        platepar: [Platepar object]

    Keyword arguments:
        mask: [Mask object] Mask object, None by default.
        area_ht: [float] Height in meters of the computed area.
        side_points: [int] How many points to use to evaluate the FOV on seach side of the image. Normalized
            to the longest side.
        elev_limit: [float] Limit of elevation above horizon (deg). 5 degrees by default.

    Return:
        [list] A list points for every side of the image, and every side is a list of (lat, lon, elev) 
            describing the sides of the FOV area. Values are in degrees and meters.

    """

    # If the mask has wrong dimensions, disregard it
    if mask is not None:
        if (mask.img.shape[0] != platepar.Y_res) or (mask.img.shape[1] != platepar.X_res):
            print("The mask has the wrong shape: {:s}, so it will be ignored!".format(str(mask.img.shape[::-1])))
            mask = None

    # If the mask is not given, make a dummy mask with all white pixels
    if mask is None:
        mask = MaskStructure(255 + np.zeros((platepar.Y_res, platepar.X_res), dtype=np.uint8))


    # Compute the number of points for the sizes
    longer_side_points = side_points
    shorter_side_points = int(np.ceil(side_points*platepar.Y_res/platepar.X_res))

    # Define operations for each side (number of points, axis of sampling, sampling start, direction of sampling, reverse sampling direction)
    side_operations = [
        [shorter_side_points, 'y', 0                 ,  1, False], # left
        [longer_side_points,  'x', platepar.Y_res - 1, -1, False], # bottom
        [shorter_side_points, 'y', platepar.X_res - 1, -1, True],  # right
        [longer_side_points,  'x', 0                 ,  1, True]]  # up
    


    # Sample the points on image borders
    side_points_list = []
    for n_sample, axis, c0, sampling_direction, reverse_sampling in side_operations:

        # Reverse some ordering to make the sampling counter-clockwise, starting in the top-left corner
        sampling_offsets = range(n_sample + 1)
        if reverse_sampling:
            sampling_offsets = reversed(sampling_offsets)

        # Sample points on every side
        side_points = []
        for i_sample in sampling_offsets:

            # Compute x, y coordinate of the sampled pixel
            if axis == 'x':
                axis_side = platepar.X_res
                other_axis_side = platepar.Y_res
                x0 = int((i_sample/n_sample)*(axis_side - 1))
                y0 = c0
            else:
                axis_side = platepar.Y_res
                other_axis_side = platepar.X_res
                x0 = c0
                y0 = int((i_sample/n_sample)*(axis_side - 1))


            # Find a pixel position along the axis that is not masked using increments of 10 pixels
            unmasked_point_found = False

            # Make a list of points to sample
            for mask_offset in np.arange(0, other_axis_side, 10):

                # Compute the current pixel position
                if axis == 'x':
                    x = x0
                    y = y0 + sampling_direction*mask_offset
                else:
                    x = x0 + sampling_direction*mask_offset
                    y = y0


                # If the position is not masked, stop searching for unmasked point
                if mask.img[y, x] > 0:
                    unmasked_point_found = True
                    break


            # Find azimuth and altitude at the given pixel, if a found unmask pixel was found along this
            #   line
            if unmasked_point_found:

                # Compute the geo location of the point along the line of sight
                _, p_lat, p_lon, p_elev = xyHt2Geo(platepar, x, y, area_ht, elev_limit=elev_limit)


                side_points.append([x, y, p_lat, p_lon, p_elev])
                

        # Add points from every side to the list (store a copy)
        side_points_list.append(list(side_points))


    # Postprocess the point list by removing points which intersect points on the previous side
    side_points_list_filtered = []
    for i, (n_sample, axis, c0, sampling_direction, reverse_sampling) in enumerate(side_operations):

        # Get the current and previous points list
        side_points = side_points_list[i]
        side_points_prev = side_points_list[i - 1]

        # Remove all points from the list that intersect points on the previous side
        side_points_filtered = []
        for x, y, p_lat, p_lon, p_elev in side_points:

            # Check all points from the previous side
            skip_point = False
            for entry_prev in side_points_prev:
                
                x_prev, y_prev = entry_prev[:2]

                # # Skip duplicates
                # if (x == x_prev) and (y == y_prev):
                #     skip_point = True
                #     break

                if axis == 'x':

                    if reverse_sampling:
                        if (y_prev < y) and (x_prev < x):
                            skip_point = True
                            break
                    else:
                        if (y_prev > y) and (x_prev > x):
                            skip_point = True
                            break

                else:
                    if reverse_sampling:
                        if (y_prev < y) and (x_prev > x):
                            skip_point = True
                            break
                    else:
                        if (y_prev > y) and (x_prev < x):
                            skip_point = True
                            break


            # If the point should not be skipped, add it to the final list
            if not skip_point:
                side_points_filtered.append([p_lat, p_lon, p_elev])

            #     print("ADDING   = {:4d}, {:4d}, {:10.6f}, {:11.6f}, {:.2f}".format(int(x), int(y), p_lat, p_lon, p_elev))

            # else:
            #     print("SKIPPING = {:4d}, {:4d}, {:10.6f}, {:11.6f}, {:.2f}".format(int(x), int(y), p_lat, p_lon, p_elev))


        side_points_list_filtered.append(side_points_filtered)


    return side_points_list_filtered
Utils/ShowerAssociation.py
def heightModel(v_init, ht_type='beg'):
    """ Function that takes a velocity and returns an extreme begin/end meteor height that was fit on CAMS
        data.

    Arguments:
        v_init: [float] Meteor initial velocity (m/s).

    Keyword arguments:
        ht_type: [str] 'beg' or 'end'

    Return:
        ht: [float] Height (m).

    """

    def _htVsVelModel(v_init, c, a, b):
        return c + a*v_init + b/(v_init**3)


    # Convert velocity to km/s
    v_init /= 1000

    if ht_type.lower() == 'beg':

        # Begin height fit
        fit_params = [97.8411, 0.4081, -20919.3867]

    else:
        # End height fit
        fit_params = [59.4751, 0.3743, -11193.7365]


    # Compute the height in meters
    ht = 1000*_htVsVelModel(v_init, *fit_params)

    return ht
RMS/Math.py
def angularSeparation(ra1, dec1, ra2, dec2):
    """ Calculates the angle between two points on a sphere. 
    
    Arguments:
        ra1: [float] Right ascension 1 (radians).
        dec1: [float] Declination 1 (radians).
        ra2: [float] Right ascension 2 (radians).
        dec2: [float] Declination 2 (radians).

    Return:
        [float] Angle between two coordinates (radians).
    """

    # Classical method
    return np.arccos(np.sin(dec1)*np.sin(dec2) + np.cos(dec1)*np.cos(dec2)*np.cos(ra2 - ra1))

    # # Compute the angular separation using the haversine formula
    # #   Source: https://idlastro.gsfc.nasa.gov/ftp/pro/astro/gcirc.pro
    # deldec2 = (dec2 - dec1)/2.0
    # delra2 =  (ra2 - ra1)/2.0
    # sindis = np.sqrt(np.sin(deldec2)*np.sin(deldec2) \
    #     + np.cos(dec1)*np.cos(dec2)*np.sin(delra2)*np.sin(delra2))
    # dis = 2.0*np.arcsin(sindis) 

    # return dis
RMS/Formats/Platepar.py
class Platepar(object):
    def __init__(self, distortion_type="poly3+radial"):
        """ Astrometric and photometric calibration plate parameters. Several distortion types are supported.

        Arguments:
            file_name: [string] Path to the platepar file.
        Keyword arguments:
            distortion_type: [str] Distortion type. It can be one of the following:
                - "poly3+radial" - 3rd order polynomial fit including a single radial term
                - "poly3+radial3" - 3rd order polynomial fit including two radial terms (r + r^3)
                - "radial3-all" - 3rd order radial distortion, all powers
                - "radial4-all" - 4rd order radial distortion, all powers
                - "radial5-all" - 5rd order radial distortion, all powers
                - "radial3-odd" - 3rd order radial distortion, only odd powers
                - "radial5-odd" - 5th order radial distortion, only odd powers
                - "radial7-odd" - 7th order radial distortion, only odd powers
                - "radial9-odd" - 7th order radial distortion, only odd powers

        Return:
            self: [object] Instance of this class with loaded platepar parameters.
        """

        self.version = 2

        # Set the distortion type
        self.distortion_type = distortion_type
        self.setDistortionType(self.distortion_type)



        # Station coordinates
        self.lat = self.lon = self.elev = 0

        # Reference time and date
        self.time = 0
        self.JD = 2451545.0

        # UT correction
        self.UT_corr = 0

        self.Ho = 0
        self.X_res = 1280
        self.Y_res = 720

        self.fov_h = 88
        self.fov_v = 45

        # FOV centre
        self.RA_d = 0
        self.dec_d = 0
        self.pos_angle_ref = 0
        self.rotation_from_horiz = 0

        self.az_centre = 0
        self.alt_centre = 0

        # FOV scale (px/deg)
        self.F_scale = 1.0

        # Refraction on/off
        self.refraction = True

        # If the calibration was done without then refraction and points on the sky are measured, then they 
        #   need to be corrected for refraction. WARNING: This should not be used if the distortion model 
        #   itself compensates for the refraction (e.g. the polynomial model)
        self.measurement_apparent_to_true_refraction = False

        # Equal aspect (X and Y scales are equal) - used ONLY for radial distortion
        self.equal_aspect = True

        # Force distortion centre to image centre
        self.force_distortion_centre = False

        # Asymmetry correction - used ONLY for radial distortion
        self.asymmetry_corr = False

        # Photometry calibration
        self.mag_0 = -2.5
        self.mag_lev = 1.0
        self.mag_lev_stddev = 0.0
        self.gamma = 1.0
        self.vignetting_coeff = 0.001
        self.vignetting_fixed = True

        # Extinction correction scaling
        self.extinction_scale = 0.6

        self.station_code = "None"

        self.star_list = None

        # Flag to indicate that the platepar was refined with CheckFit
        self.auto_check_fit_refined = False

        # Flag to indicate that the platepar was successfuly auto recalibrated on an individual FF files
        self.auto_recalibrated = False

        # Init the distortion parameters
        self.resetDistortionParameters()


    def resetDistortionParameters(self, preserve_centre=False):
        """ Set the distortion parameters to zero.

        Keyword arguments:
            preserve_centre: [bool] Don't reset the distortion centre. False by default, in which case it will
                be reset.
        """

        # Store the distortion centre if it needs to be preserved
        if preserve_centre:

            # Preserve centre for the radial distortion
            if self.distortion_type.startswith("radial"):

                # Note that the radial distortion parameters are kept in the X poly array
                x_centre_fwd, y_centre_fwd = self.x_poly_fwd[0], self.x_poly_fwd[1]
                x_centre_rev, y_centre_rev = self.x_poly_rev[0], self.x_poly_rev[1]

            else:

                # Preserve centre for the polynomial distortion
                x_centre_fwd, x_centre_rev = self.x_poly_fwd[0], self.x_poly_rev[0]
                y_centre_fwd, y_centre_rev = self.y_poly_fwd[0], self.y_poly_rev[0]


        # Reset distortion fit (forward and reverse)
        self.x_poly_fwd = np.zeros(shape=(self.poly_length,), dtype=np.float64)
        self.y_poly_fwd = np.zeros(shape=(self.poly_length,), dtype=np.float64)
        self.x_poly_rev = np.zeros(shape=(self.poly_length,), dtype=np.float64)
        self.y_poly_rev = np.zeros(shape=(self.poly_length,), dtype=np.float64)



        # Preserve the image centre
        if preserve_centre:

            # Preserve centre for the radial distortion
            if self.distortion_type.startswith("radial") and (not self.force_distortion_centre):

                # Note that the radial distortion parameters are kept in the X poly array
                self.x_poly_fwd[0], self.x_poly_fwd[1] = x_centre_fwd, y_centre_fwd
                self.x_poly_rev[0], self.x_poly_rev[1] = x_centre_rev, y_centre_rev

            else:

                # Preserve centre for the polynomial distortion
                self.x_poly_fwd[0], self.x_poly_rev[0] = x_centre_fwd, x_centre_rev
                self.y_poly_fwd[0], self.y_poly_rev[0] = y_centre_fwd, y_centre_rev


        # Reset the image centre
        else:
            # Set the first coeffs to 0.5, as that is the real centre of the FOV
            self.x_poly_fwd[0] = 0.5
            self.y_poly_fwd[0] = 0.5
            self.x_poly_rev[0] = 0.5
            self.y_poly_rev[0] = 0.5

            # If the distortion is radial, set the second X parameter to 0.5, as x_poly[1] is used for the Y
            #   offset in the radial models
            if self.distortion_type.startswith("radial"):
                self.x_poly_fwd[0] /= self.X_res/2
                self.x_poly_rev[0] /= self.X_res/2
                self.x_poly_fwd[1] = 0.5/(self.Y_res/2)
                self.x_poly_rev[1] = 0.5/(self.Y_res/2)

                # If the distortion center is forced to the center of the image, reset all parameters to zero
                if self.force_distortion_centre:
                    self.x_poly_fwd *= 0
                    self.x_poly_rev *= 0



        self.x_poly = self.x_poly_fwd
        self.y_poly = self.y_poly_fwd



    def setDistortionType(self, distortion_type, reset_params=True):
        """ Sets the distortion type. """

        # List of distortion types
        self.distortion_type_list = [
            "poly3+radial", \
            "poly3+radial3", \
            "poly3+radial5", \
            "radial3-all", \
            "radial4-all", \
            "radial5-all", \
            "radial3-odd", \
            "radial5-odd", \
            "radial7-odd", \
            "radial9-odd", \
            ]

        # Lenghts of full polynomials, (including distortion center, aspect, and asymmetry correction for 
        #   radial distortions)
        self.distortion_type_poly_length = [
            12, 13, 14, 7, 8, 9, 6, 7, 8, 9
        ]

        # Set the length of the distortion polynomial depending on the distortion type
        if distortion_type in self.distortion_type_list:

            # If the new distortion type (poly vs radial) is different from the old, reset the parameters
            if (distortion_type[:4] != self.distortion_type[:4]):
                reset_params = True

            # If the all vs odd only radial powers type is changed, reset the distortion
            if distortion_type.startswith("radial"):
                if (distortion_type[-3:] != self.distortion_type[-3:]):
                    reset_params = True

            
            self.distortion_type = distortion_type

            # Get the polynomial length
            self.poly_length = self.distortion_type_poly_length[self.distortion_type_list.index(distortion_type)]


            # Remove distortion center for radial distortions if it's not used
            if distortion_type.startswith("radial"):
                if self.force_distortion_centre:
                    self.poly_length -= 2

            # Remove aspect parameter for radial distortions if it's not used
            if distortion_type.startswith("radial"):
                if self.equal_aspect:
                    self.poly_length -= 1

            # Remove asymmetry correction parameters for radial distortions if they are not used
            if distortion_type.startswith("radial"):
                if not self.asymmetry_corr:
                    self.poly_length -= 2



        else:
            raise ValueError("The distortion type is not recognized: {:s}".format(self.distortion_type))


        # Reset distortion parameters
        if reset_params:
            self.resetDistortionParameters()


        # Set the correct polynomial size
        self.padDictParams()


    def addVignettingCoeff(self, use_flat):
        """ Add a vignetting coeff to the platepar if it doesn't have one.

        Arguments:
            use_flat: [bool] Is the flat used or not.
        """

        # Add a vignetting coefficient if it's not set
        if self.vignetting_coeff is None:

            # Only add it if a flat is not used
            if use_flat:
                self.vignetting_coeff = 0.0

            else:

                # Use 0.001 rad/px as the default coefficeint, as that's the one for 3.6 mm f/0.95 and 16 mm
                #   f/1.0 lenses. The vignetting coeff is dependent on the resolution, the default value of
                #   0.001 rad/px is for 720p.
                self.vignetting_coeff = 0.001*np.hypot(1280, 720)/np.hypot(self.X_res, self.Y_res)


    def fitPointing(self, jd, img_stars, catalog_stars, fixed_scale=False):
        """ Fit pointing parameters to the list of star image and celectial catalog coordinates.
        At least 4 stars are needed to fit the rigid body parameters.
        
        New parameters are saved to the given object (self).

        Arguments:
            jd: [float] Julian date of the image.
            img_stars: [list] A list of (x, y, intensity_sum) entires for every star.
            catalog_stars: [list] A list of (ra, dec, mag) entries for every star (degrees).

        Keyword arguments:
            fixed_scale: [bool] Keep the scale fixed. False by default.
            
        """


        def _calcImageResidualsAstro(params, platepar, jd, catalog_stars, img_stars):
            """ Calculates the differences between the stars on the image and catalog stars in image
                coordinates with the given astrometrical solution.
            """

            # Extract fitting parameters
            ra_ref, dec_ref, pos_angle_ref = params[:3]
            if not fixed_scale:
                F_scale = params[3]

            img_x, img_y, _ = img_stars.T

            pp_copy = copy.deepcopy(platepar)

            # Assign guessed parameters
            pp_copy.RA_d = ra_ref
            pp_copy.dec_d = dec_ref
            pp_copy.pos_angle_ref = pos_angle_ref

            if not fixed_scale:
                pp_copy.F_scale = abs(F_scale)


            # Get image coordinates of catalog stars
            catalog_x, catalog_y, catalog_mag = getCatalogStarsImagePositions(catalog_stars, jd, pp_copy)

            # Calculate the sum of squared distances between image stars and catalog stars
            dist_sum = np.sum((catalog_x - img_x)**2 + (catalog_y - img_y)**2)

            return dist_sum


        def _calcSkyResidualsAstro(params, platepar, jd, catalog_stars, img_stars):
            """ Calculates the differences between the stars on the image and catalog stars in sky
                coordinates with the given astrometrical solution.
            """

            # Extract fitting parameters
            ra_ref, dec_ref, pos_angle_ref = params[:3]
            if not fixed_scale:
                F_scale = params[3]

            img_x, img_y, _ = img_stars.T

            pp_copy = copy.deepcopy(platepar)

            # Assign guessed parameters
            pp_copy.RA_d = ra_ref
            pp_copy.dec_d = dec_ref
            pp_copy.pos_angle_ref = pos_angle_ref

            if not fixed_scale:
                pp_copy.F_scale = abs(F_scale)

                

            img_x, img_y, _ = img_stars.T

            # Get image coordinates of catalog stars
            ra_array, dec_array = getPairedStarsSkyPositions(img_x, img_y, jd, pp_copy)

            ra_catalog, dec_catalog, _ = catalog_stars.T

            # Compute the sum of the angular separation
            separation_sum = np.sum(angularSeparation(np.radians(ra_array), np.radians(dec_array), \
                np.radians(ra_catalog), np.radians(dec_catalog))**2)


            return separation_sum


        # Initial parameters for the astrometric fit
        p0 = [self.RA_d, self.dec_d, self.pos_angle_ref]

        # Add fitting scale if not fixed
        if not fixed_scale:
            p0 += [abs(self.F_scale)]


        # Fit the astrometric parameters using the reverse transform for reference        
        res = scipy.optimize.minimize(_calcImageResidualsAstro, p0, \
            args=(self, jd, catalog_stars, img_stars), method='SLSQP')

        # # Fit the astrometric parameters using the forward transform for reference
        #   WARNING: USING THIS MAKES THE FIT UNSTABLE
        # res = scipy.optimize.minimize(_calcSkyResidualsAstro, p0, args=(self, jd, \
        #     catalog_stars, img_stars), method='Nelder-Mead')

        # Update fitted astrometric parameters
        self.RA_d, self.dec_d, self.pos_angle_ref = res.x[:3]
        if not fixed_scale:
            self.F_scale = res.x[3]


        # Force scale to be positive
        self.F_scale = abs(self.F_scale)
        
        
        # Update alt/az of pointing
        self.updateRefAltAz()




    def fitAstrometry(self, jd, img_stars, catalog_stars, first_platepar_fit=False, fit_only_pointing=False,
        fixed_scale=False):
        """ Fit astrometric parameters to the list of star image and celectial catalog coordinates.
        At least 4 stars are needed to fit the rigid body parameters.
        
        New parameters are saved to the given object (self).

        Arguments:
            jd: [float] Julian date of the image.
            img_stars: [list] A list of (x, y, intensity_sum) entires for every star.
            catalog_stars: [list] A list of (ra, dec, mag) entries for every star (degrees).

        Keyword arguments:
            first_platepar_fit: [bool] Fit a platepar from scratch. False by default.
            fit_only_pointing: [bool] Only fit the pointing parameters, and not distortion.
            fixed_scale: [bool] Keep the scale fixed. False by default.

        """



        def _calcImageResidualsDistortion(params, platepar, jd, catalog_stars, img_stars, dimension):
            """ Calculates the differences between the stars on the image and catalog stars in image
                coordinates with the given astrometrical solution.
            Arguments:
                ...
                dimension: [str] 'x' for X polynomial fit, 'y' for Y polynomial fit
            """

            # Set distortion parameters
            pp_copy = copy.deepcopy(platepar)

            if (dimension == 'x') or (dimension == 'radial'):
                pp_copy.x_poly_rev = params
                pp_copy.y_poly_rev = np.zeros(platepar.poly_length)

            else:
                pp_copy.x_poly_rev = np.zeros(platepar.poly_length)
                pp_copy.y_poly_rev = params


            img_x, img_y, _ = img_stars.T


            # Get image coordinates of catalog stars
            catalog_x, catalog_y, catalog_mag = getCatalogStarsImagePositions(catalog_stars, jd, pp_copy)


            # Calculate the sum of squared distances between image stars and catalog stars, per every
            #   dimension
            if dimension == 'x':
                dist_sum = np.sum((catalog_x - img_x)**2)

            elif dimension == 'y':
                dist_sum = np.sum((catalog_y - img_y)**2)

            # Minimization for the radial distortion
            else:

                # Compute the image fit error
                dist_sum = np.sum((catalog_x - img_x)**2 + (catalog_y - img_y)**2)


            return dist_sum


        # Modify the residuals function so that it takes a list of arguments
        def _calcImageResidualsDistortionListArguments(params, *args, **kwargs):
            return [_calcImageResidualsDistortion(param_line, *args, **kwargs) for param_line in params]


        def _calcSkyResidualsDistortion(params, platepar, jd, catalog_stars, img_stars, dimension):
            """ Calculates the differences between the stars on the image and catalog stars in sky
                coordinates with the given astrometrical solution.
            Arguments:
                ...
                dimension: [str] 'x' for X polynomial fit, 'y' for Y polynomial fit
            """

            pp_copy = copy.deepcopy(platepar)

            if (dimension == 'x') or (dimension == 'radial'):
                pp_copy.x_poly_fwd = params

            else:
                pp_copy.y_poly_fwd = params


            img_x, img_y, _ = img_stars.T

            # Get image coordinates of catalog stars
            ra_array, dec_array = getPairedStarsSkyPositions(img_x, img_y, jd, pp_copy)

            ra_catalog, dec_catalog, _ = catalog_stars.T

            # Compute the sum of the angular separation
            separation_sum = np.sum(angularSeparation(np.radians(ra_array), np.radians(dec_array), \
                np.radians(ra_catalog), np.radians(dec_catalog))**2)

            return separation_sum


        # Modify the residuals function so that it takes a list of arguments
        def _calcSkyResidualsDistortionListArguments(params, *args, **kwargs):
            return [_calcSkyResidualsDistortion(param_line, *args, **kwargs) for param_line in params]



        def _calcImageResidualsAstroAndDistortionRadial(params, platepar, jd, catalog_stars, img_stars):
            """ Calculates the differences between the stars on the image and catalog stars in image
                coordinates with the given astrometrical solution. Pointing and distortion paramters are used
                in the fit.

            Arguments:
                ...
                dimension: [str] 'x' for X polynomial fit, 'y' for Y polynomial fit
            """

            # Set distortion parameters
            pp_copy = copy.deepcopy(platepar)

            
            # Unpack pointing parameters and assign to the copy of platepar used for the fit
            ra_ref, dec_ref, pos_angle_ref, F_scale = params[:4]

            pp_copy = copy.deepcopy(platepar)

            # Unnormalize the pointing parameters
            pp_copy.RA_d = (360*ra_ref)%(360)
            pp_copy.dec_d = -90 + (90*dec_ref + 90)%(180.000001)
            pp_copy.pos_angle_ref = (360*pos_angle_ref)%(360)
            pp_copy.F_scale = abs(F_scale)


            # Assign distortion parameters
            pp_copy.x_poly_rev = params[4:]


            img_x, img_y, _ = img_stars.T

            # Get image coordinates of catalog stars
            catalog_x, catalog_y, catalog_mag = getCatalogStarsImagePositions(catalog_stars, jd, pp_copy)


            # Calculate the sum of squared distances between image stars and catalog stars
            dist_sum = np.sum((catalog_x - img_x)**2 + (catalog_y - img_y)**2)


            return dist_sum


        def _calcSkyResidualsAstroAndDistortionRadial(params, platepar, jd, catalog_stars, img_stars):
            """ Calculates the differences between the stars on the image and catalog stars in celestial
                coordinates with the given astrometrical solution. Pointing and distortion paramters are used
                in the fit.
    
            """

            # Set distortion parameters
            pp_copy = copy.deepcopy(platepar)

            
            # Unpack pointing parameters and assign to the copy of platepar used for the fit
            ra_ref, dec_ref, pos_angle_ref, F_scale = params[:4]

            pp_copy = copy.deepcopy(platepar)

            # Unnormalize the pointing parameters
            pp_copy.RA_d = (360*ra_ref)%(360)
            pp_copy.dec_d = -90 + (90*dec_ref + 90)%(180.000001)
            pp_copy.pos_angle_ref = (360*pos_angle_ref)%(360)
            pp_copy.F_scale = abs(F_scale)


            # Assign distortion parameters
            pp_copy.x_poly_fwd = params[4:]


            img_x, img_y, _ = img_stars.T

            # Get image coordinates of catalog stars
            ra_array, dec_array = getPairedStarsSkyPositions(img_x, img_y, jd, pp_copy)

            ra_catalog, dec_catalog, _ = catalog_stars.T

            # Compute the sum of the angular separation
            separation_sum = np.sum(angularSeparation(np.radians(ra_array), np.radians(dec_array), \
                np.radians(ra_catalog), np.radians(dec_catalog))**2)


            return separation_sum


        # print('ASTRO', _calcImageResidualsAstro([self.RA_d, self.dec_d,
        #     self.pos_angle_ref, self.F_scale],  catalog_stars, img_stars))

        # print('DIS_X', _calcImageResidualsDistortion(self.x_poly_rev,  catalog_stars, \
        #     img_stars, 'x'))

        # print('DIS_Y', _calcImageResidualsDistortion(self.y_poly_rev,  catalog_stars, \
        #     img_stars, 'y'))



        ### ASTROMETRIC PARAMETERS FIT ###

        # Fit the pointing parameters (RA, Dec, rotation, scale)
        #   Only do the fit for the polynomial distortion model, or the first time if the radial distortion
        #   is used
        if self.distortion_type.startswith("poly") \
            or (not self.distortion_type.startswith("poly") and first_platepar_fit) or fit_only_pointing:

            self.fitPointing(jd, img_stars, catalog_stars, fixed_scale=fixed_scale)

        ### ###



        ### DISTORTION FIT ###

        # Fit the polynomial distortion parameters if there are enough picked stars
        min_fit_stars = self.poly_length + 1

        if (len(img_stars) >= min_fit_stars) and (not fit_only_pointing):

            # Fit the polynomial distortion
            if self.distortion_type.startswith("poly"):


                ### REVERSE MAPPING FIT ###

                # Fit distortion parameters in X direction, reverse mapping
                res = scipy.optimize.minimize(_calcImageResidualsDistortion, self.x_poly_rev, \
                    args=(self, jd, catalog_stars, img_stars, 'x'), method='Nelder-Mead', \
                    options={'maxiter': 10000, 'adaptive': True})

                # Exctact fitted X polynomial
                self.x_poly_rev = res.x

                # Fit distortion parameters in Y direction, reverse mapping
                res = scipy.optimize.minimize(_calcImageResidualsDistortion, self.y_poly_rev, \
                    args=(self, jd, catalog_stars, img_stars, 'y'), method='Nelder-Mead', \
                    options={'maxiter': 10000, 'adaptive': True})

                # Extract fitted Y polynomial
                self.y_poly_rev = res.x


                ### ###


                # If this is the first fit of the distortion, set the forward parametrs to be equal to the reverse
                if first_platepar_fit:

                    self.x_poly_fwd = np.array(self.x_poly_rev)
                    self.y_poly_fwd = np.array(self.y_poly_rev)



                ### FORWARD MAPPING FIT ###

                # Fit distortion parameters in X direction, forward mapping
                res = scipy.optimize.minimize(_calcSkyResidualsDistortion, self.x_poly_fwd, \
                    args=(self, jd, catalog_stars, img_stars, 'x'), method='Nelder-Mead', \
                    options={'maxiter': 10000, 'adaptive': True})

                # Extract fitted X polynomial
                self.x_poly_fwd = res.x


                # Fit distortion parameters in Y direction, forward mapping
                res = scipy.optimize.minimize(_calcSkyResidualsDistortion, self.y_poly_fwd, \
                    args=(self, jd, catalog_stars, img_stars, 'y'), method='Nelder-Mead', \
                    options={'maxiter': 10000, 'adaptive': True})

                # IMPORTANT NOTE - the X polynomial is used to store the fit paramters
                self.y_poly_fwd = res.x


                ### ###


            # Fit radial distortion (+ pointing)
            else:


                ### FORWARD MAPPING FIT ###


                # # Fit the radial distortion - the X polynomial is used to store the fit paramters
                # res = scipy.optimize.minimize(_calcSkyResidualsDistortion, self.x_poly_fwd, \
                #     args=(self, jd, catalog_stars, img_stars, 'radial'), method='Nelder-Mead', \
                #     options={'maxiter': 10000, 'adaptive': True})


                # # Extract distortion parameters, IMPORTANT NOTE - the X polynomial is used to store the 
                # #   fit paramters
                # self.x_poly_fwd = res.x



                # Fitting the pointing direction below! - if used, it should be put BEFORE the reverse fit!
                # Initial parameters for the pointing and distortion fit (normalize to the 0-1 range)
                p0  = [self.RA_d/360, self.dec_d/90, self.pos_angle_ref/360, abs(self.F_scale)]
                p0 += self.x_poly_fwd.tolist()

                # Fit the radial distortion - the X polynomial is used to store the fit paramters
                res = scipy.optimize.minimize(_calcSkyResidualsAstroAndDistortionRadial, p0, \
                    args=(self, jd, catalog_stars, img_stars), method='Nelder-Mead', \
                    options={'maxiter': 10000, 'adaptive': True})

                # Update fitted astrometric parameters (Unnormalize the pointing parameters)
                ra_ref, dec_ref, pos_angle_ref, F_scale = res.x[:4]
                self.RA_d = (360*ra_ref)%(360)
                self.dec_d = -90 + (90*dec_ref + 90)%(180.000001)
                self.pos_angle_ref = (360*pos_angle_ref)%(360)
                self.F_scale = abs(F_scale)

                self.updateRefAltAz()


                # Extract distortion parameters, IMPORTANT NOTE - the X polynomial is used to store the 
                #   fit paramters
                self.x_poly_fwd = res.x[4:]

                ### ###


                # If this is the first fit of the distortion, set the forward parametrs to be equal to the reverse
                if first_platepar_fit:
                    self.x_poly_rev = np.array(self.x_poly_fwd)


                ### REVERSE MAPPING FIT ###


                # # Initial parameters for the pointing and distortion fit (normalize to the 0-1 range)
                # p0  = [self.RA_d/360.0, self.dec_d/90.0, self.pos_angle_ref/360.0, abs(self.F_scale)]
                # p0 += self.x_poly_rev.tolist()


                # # Fit the radial distortion - the X polynomial is used to store the fit paramters
                # res = scipy.optimize.minimize(_calcImageResidualsAstroAndDistortionRadial, p0, \
                #     args=(self, jd, catalog_stars, img_stars), method='Nelder-Mead', \
                #     options={'maxiter': 10000, 'adaptive': True})


                # # Update fitted astrometric parameters (Unnormalize the pointing parameters)
                # ra_ref, dec_ref, pos_angle_ref, F_scale = res.x[:4]
                # self.RA_d = (360*ra_ref)%(360)
                # self.dec_d = -90 + (90*dec_ref + 90)%(180.000001)
                # self.pos_angle_ref = (360*pos_angle_ref)%(360)
                # self.F_scale = abs(F_scale)

                # # Compute reference Alt/Az to apparent coordinates, epoch of date
                # self.updateRefAltAz()


                # # Extract distortion parameters, IMPORTANT NOTE - the X polynomial is used to store the 
                # #   fit paramters
                # self.x_poly_rev = res.x[4:]



                ## Distortion-only fit below!

                # Fit the radial distortion - the X polynomial is used to store the fit paramters
                res = scipy.optimize.minimize(_calcImageResidualsDistortion, self.x_poly_rev, \
                    args=(self, jd, catalog_stars, img_stars, 'radial'), method='Nelder-Mead', \
                    options={'maxiter': 10000, 'adaptive': True})


                # Extract distortion parameters, IMPORTANT NOTE - the X polynomial is used to store the 
                #   fit paramters
                self.x_poly_rev = res.x


                ### ###




        else:
            if (len(img_stars) < min_fit_stars):
                print('Too few stars to fit the distortion, only the astrometric parameters where fitted!')


        # Set the list of stars used for the fit to the platepar
        fit_star_list = []
        for img_coords, cat_coords in zip(img_stars, catalog_stars):

            # Store time, image coordinate x, y, intensity, catalog ra, dec, mag
            fit_star_list.append([jd] + img_coords.tolist() + cat_coords.tolist())


        self.star_list = fit_star_list


        # Set the flag to indicate that the platepar was manually fitted
        self.auto_check_fit_refined = False
        self.auto_recalibrated = False

        ### ###


    def parseLine(self, f):
        """ Read next line, split the line and convert parameters to float.
        @param f: [file handle] file we want to read
        @return (a1, a2, ...): [tuple of floats] parsed data from the line
        """

        return map(float, f.readline().split())


    def padDictParams(self):
        """ Update the array length if an old platepar version was loaded which was shorter/longer. """

        # Extend the array if it's too short
        if self.x_poly_fwd.shape[0] < self.poly_length:
            self.x_poly_fwd = np.pad(self.x_poly_fwd, (0, self.poly_length - self.x_poly_fwd.shape[0]), \
                'constant', constant_values=0)
            self.x_poly_rev = np.pad(self.x_poly_rev, (0, self.poly_length - self.x_poly_rev.shape[0]), \
                'constant', constant_values=0)
            self.y_poly_fwd = np.pad(self.y_poly_fwd, (0, self.poly_length - self.y_poly_fwd.shape[0]), \
                'constant', constant_values=0)
            self.y_poly_rev = np.pad(self.y_poly_rev, (0, self.poly_length - self.y_poly_rev.shape[0]), \
                'constant', constant_values=0)


        # Cut the array if it's too long
        if self.x_poly_fwd.shape[0] > self.poly_length:
            self.x_poly_fwd = self.x_poly_fwd[:self.poly_length]
            self.x_poly_rev = self.x_poly_rev[:self.poly_length]
            self.y_poly_fwd = self.y_poly_fwd[:self.poly_length]
            self.y_poly_rev = self.y_poly_rev[:self.poly_length]


    def loadFromDict(self, platepar_dict, use_flat=None):
        """ Load the platepar from a dictionary. """

        # Parse JSON into an object with attributes corresponding to dict keys
        self.__dict__ = platepar_dict

        # Add the version if it was not in the platepar (v1 platepars didn't have a version)
        if not 'version' in self.__dict__:
            self.version = 1


        # If the refraction was not used for the fit, assume it is disabled
        if not 'refraction' in self.__dict__:
            self.refraction = False


        # If the measurement correction for refraction (if it was not taken into account during calibration)
        #   is not present, assume it's false
        if not 'measurement_apparent_to_true_refraction' in self.__dict__:
            self.measurement_apparent_to_true_refraction = False


        # Add equal aspect
        if not 'equal_aspect' in self.__dict__:
            self.equal_aspect = False


        # Add asymmetry correction
        if not 'asymmetry_corr' in self.__dict__:
            self.asymmetry_corr = False


        # Add forcing distortion centre to image center
        if not 'force_distortion_centre' in self.__dict__:
            self.force_distortion_centre = False


        # Add the distortion type if not present (assume it's the polynomal type with the radial term)
        if not 'distortion_type' in self.__dict__:

            # Check if the variable with the typo was used and correct it
            if 'distortion_type' in self.__dict__:
                self.distortion_type = self.distortion_type
                del self.distortion_type

            # Otherwise, assume the polynomial type
            else:
                self.distortion_type = "poly3+radial"

        # Add UT correction if it was not in the platepar
        if not 'UT_corr' in self.__dict__:
            self.UT_corr = 0

        # Add the gamma if it was not in the platepar
        if not 'gamma' in self.__dict__:
            self.gamma = 1.0

        # Add the vignetting coefficient if it was not in the platepar
        if not 'vignetting_coeff' in self.__dict__:
            self.vignetting_coeff = None

            # Add the default vignetting coeff
            self.addVignettingCoeff(use_flat=use_flat)

        # Add keeping the vignetting coefficient fixed
        if not 'vignetting_fixed' in self.__dict__:
            self.vignetting_fixed = False

        # Add extinction scale
        if not 'extinction_scale' in self.__dict__:
            self.extinction_scale = 1.0

        # Add the list of calibration stars if it was not in the platepar
        if not 'star_list' in self.__dict__:
            self.star_list = []

        # If v1 only the backward distortion coeffs were fitted, so use load them for both forward and
        #   reverse if nothing else is available
        if not 'x_poly_fwd' in self.__dict__:

            self.x_poly_fwd = np.array(self.x_poly)
            self.x_poly_rev = np.array(self.x_poly)
            self.y_poly_fwd = np.array(self.y_poly)
            self.y_poly_rev = np.array(self.y_poly)


        # Convert lists to numpy arrays
        self.x_poly_fwd = np.array(self.x_poly_fwd)
        self.x_poly_rev = np.array(self.x_poly_rev)
        self.y_poly_fwd = np.array(self.y_poly_fwd)
        self.y_poly_rev = np.array(self.y_poly_rev)


        # Set the distortion type
        self.setDistortionType(self.distortion_type, reset_params=False)


        # Set polynomial parameters used by the old code
        self.x_poly = self.x_poly_fwd
        self.y_poly = self.y_poly_fwd


        # Add rotation from horizontal
        if not 'rotation_from_horiz' in self.__dict__:
            self.rotation_from_horiz = RMS.Astrometry.ApplyAstrometry.rotationWrtHorizon(self)

        # Calculate the datetime
        self.time = jd2Date(self.JD, dt_obj=True)




    def read(self, file_name, fmt=None, use_flat=None):
        """ Read the platepar.

        Arguments:
            file_name: [str] Path and the name of the platepar to read.
        Keyword arguments:
            fmt: [str] Format of the platepar file. 'json' for JSON format and 'txt' for the usual CMN textual
                format.
            use_flat: [bool] Indicates wheter a flat is used or not. None by default.
        Return:
            fmt: [str]
        """


        # Check if platepar exists
        if not os.path.isfile(file_name):
            return False


        # Determine the type of the platepar if it is not given
        if fmt is None:

            with open(file_name) as f:
                data = " ".join(f.readlines())

                # Try parsing the file as JSON
                try:
                    json.loads(data)
                    fmt = 'json'

                except:
                    fmt = 'txt'


        # Load the file as JSON
        if fmt == 'json':

            # Load the JSON file
            with open(file_name) as f:
                data = " ".join(f.readlines())

            # Load the platepar from the JSON dictionary
            self.loadFromDict(json.loads(data), use_flat=use_flat)




        # Load the file as TXT (old CMN format)
        else:

            with open(file_name) as f:

                self.UT_corr = 0
                self.gamma = 1.0
                self.star_list = []

                # Parse latitude, longitude, elevation
                self.lon, self.lat, self.elev = self.parseLine(f)

                # Parse date and time as int
                D, M, Y, h, m, s = map(int, f.readline().split())

                # Calculate the datetime of the platepar time
                self.time = datetime.datetime(Y, M, D, h, m, s)

                # Convert time to JD
                self.JD = date2JD(Y, M, D, h, m, s)

                # Calculate the reference hour angle
                T = (self.JD - 2451545.0)/36525.0
                self.Ho = (280.46061837 + 360.98564736629*(self.JD - 2451545.0) + 0.000387933*T**2 - \
                    T**3/38710000.0)%360

                # Parse camera parameters
                self.X_res, self.Y_res, self.focal_length = self.parseLine(f)

                # Parse the right ascension of the image centre
                self.RA_d, self.RA_H, self.RA_M, self.RA_S = self.parseLine(f)

                # Parse the declination of the image centre
                self.dec_d, self.dec_D, self.dec_M, self.dec_S = self.parseLine(f)

                # Parse the rotation parameter
                self.pos_angle_ref = self.parseLine(f)[0]

                # Parse the image scale (convert from arcsec/px to px/deg)
                self.F_scale = self.parseLine(f)[0]
                self.F_scale = 3600/self.F_scale

                # Load magnitude slope parameters
                self.mag_0, self.mag_lev = self.parseLine(f)

                # Load X axis polynomial parameters
                self.x_poly_fwd = self.x_poly_rev = np.zeros(shape=(self.poly_length,), dtype=np.float64)
                for i in range(self.poly_length):
                    self.x_poly_fwd[i] = self.x_poly_fwd[i] = self.parseLine(f)[0]

                # Load Y axis polynomial parameters
                self.y_poly_fwd = self.y_poly_rev = np.zeros(shape=(self.poly_length,), dtype=np.float64)
                for i in range(self.poly_length):
                    self.y_poly_fwd[i] = self.y_poly_rev[i] = self.parseLine(f)[0]

                # Read station code
                self.station_code = f.readline().replace('\r', '').replace('\n', '')


        # Add a default vignetting coefficient if it already doesn't exist
        self.addVignettingCoeff(use_flat)

        return fmt


    def jsonStr(self):
        """ Returns the JSON representation of the platepar as a string. """

        # Make a copy of the platepar object, which will be modified for writing
        self2 = copy.deepcopy(self)

        # Convert numpy arrays to list, which can be serialized
        self2.x_poly_fwd = self.x_poly_fwd.tolist()
        self2.x_poly_rev = self.x_poly_rev.tolist()
        self2.y_poly_fwd = self.y_poly_fwd.tolist()
        self2.y_poly_rev = self.y_poly_rev.tolist()
        del self2.time

        # For compatibility with old procedures, write the forward distortion parameters as x, y
        self2.x_poly = self.x_poly_fwd.tolist()
        self2.y_poly = self.y_poly_fwd.tolist()

        out_str = json.dumps(self2, default=lambda o: o.__dict__, indent=4, sort_keys=True)

        return out_str


    def write(self, file_path, fmt=None, fov=None, ret_written=False):
        """ Write platepar to file.

        Arguments:
            file_path: [str] Path and the name of the platepar to write.
        Keyword arguments:
            fmt: [str] Format of the platepar file. 'json' for JSON format and 'txt' for the usual CMN textual
                format. The format is JSON by default.
            fov: [tuple] Tuple of horizontal and vertical FOV size in degree. None by default.
            ret_written: [bool] If True, the JSON string of the platepar instead of writing it to disk.
        Return:
            fmt: [str] Platepar format.
        """

        # If the FOV size was given, store it
        if fov is not None:
            self.fov_h, self.fov_v = fov


        # Set JSON to be the defualt format
        if fmt is None:
            fmt = 'json'


        # If the format is JSON, write a JSON file
        if fmt == 'json':

            out_str = self.jsonStr()

            with open(file_path, 'w') as f:
                f.write(out_str)

            if ret_written:
                return fmt, out_str


        # Old CMN format
        else:

            with open(file_path, 'w') as f:

                # Write geo coords
                f.write('{:9.6f} {:9.6f} {:04d}\n'.format(self.lon, self.lat, int(self.elev)))

                # Calculate reference time from reference JD
                Y, M, D, h, m, s, ms = list(map(int, jd2Date(self.JD)))

                # Write the reference time
                f.write('{:02d} {:02d} {:04d} {:02d} {:02d} {:02d}\n'.format(D, M, Y, h, m, s))

                # Write resolution and focal length
                f.write('{:d} {:d} {:f}\n'.format(int(self.X_res), int(self.Y_res), self.focal_length))

                # Write reference RA
                self.RA_H = int(self.RA_d/15)
                self.RA_M = int((self.RA_d/15 - self.RA_H)*60)
                self.RA_S = int(((self.RA_d/15 - self.RA_H)*60 - self.RA_M)*60)

                f.write("{:7.3f} {:02d} {:02d} {:02d}\n".format(self.RA_d, self.RA_H, self.RA_M, self.RA_S))

                # Write reference Dec
                self.dec_D = int(self.dec_d)
                self.dec_M = int((self.dec_d - self.dec_D)*60)
                self.dec_S = int(((self.dec_d - self.dec_D)*60 - self.dec_M)*60)

                f.write("{:+7.3f} {:02d} {:02d} {:02d}\n".format(self.dec_d, self.dec_D, self.dec_M, self.dec_S))

                # Write rotation parameter
                f.write('{:<7.3f}\n'.format(self.pos_angle_ref))

                # Write F scale
                f.write('{:<5.1f}\n'.format(3600/self.F_scale))

                # Write magnitude fit
                f.write("{:.3f} {:.3f}\n".format(self.mag_0, self.mag_lev))

                # Write X distortion polynomial
                for x_elem in self.x_poly_fwd:
                    f.write('{:+E}\n'.format(x_elem))

                # Write y distortion polynomial
                for y_elem in self.y_poly_fwd:
                    f.write('{:+E}\n'.format(y_elem))

                # Write station code
                f.write(str(self.station_code) + '\n')

            if ret_written:
                with open(file_path) as f:
                    out_str = "\n".join(f.readlines())

                return fmt, out_str


        return fmt


    def updateRefAltAz(self):
        """ Update the reference apparent azimuth and altitude from the reference RA and Dec. """

        # Compute reference Alt/Az to apparent coordinates, epoch of date
        az_centre, alt_centre = cyTrueRaDec2ApparentAltAz( \
            np.radians(self.RA_d), np.radians(self.dec_d), self.JD, \
            np.radians(self.lat), np.radians(self.lon), self.refraction)
        self.az_centre, self.alt_centre = np.degrees(az_centre), np.degrees(alt_centre)

        # Update the rotation wrt horizon
        self.rotation_from_horiz = RMS.Astrometry.ApplyAstrometry.rotationWrtHorizon(self)


    def updateRefRADec(self, skip_rot_update=False, preserve_rotation=False):
        """ Update the reference RA and Dec (true in J2000) from Alt/Az (apparent in epoch of date). """

        if (not skip_rot_update) and (not preserve_rotation):

            # Save the current rotation w.r.t horizon value
            self.rotation_from_horiz = RMS.Astrometry.ApplyAstrometry.rotationWrtHorizon(self)


        # Convert the reference apparent Alt/Az in the epoch of date to true RA/Dec in J2000
        ra, dec = cyApparentAltAz2TrueRADec(\
            np.radians(self.az_centre), np.radians(self.alt_centre), self.JD, \
            np.radians(self.lat), np.radians(self.lon), self.refraction)


        # Assign the computed RA/Dec to platepar
        self.RA_d = np.degrees(ra)
        self.dec_d = np.degrees(dec)


        if not skip_rot_update:

            # Update the position angle so that the rotation wrt horizon doesn't change
            self.pos_angle_ref = RMS.Astrometry.ApplyAstrometry.rotationWrtHorizonToPosAngle(self, \
                self.rotation_from_horiz)


    def switchToGroundPicks(self):
        """ Switch the reference pointing so that points on the ground may be correctly measured. """

        # If the refraction was on, turn if off and correct the centre
        if self.refraction:
            
            self.refraction = False

            # Preserve the reference elevation of the pointing as the apparent pointing
            # self.alt_centre = np.degrees(pyRefractionTrueToApparent(np.radians(self.alt_centre)))

            self.updateRefRADec(preserve_rotation=True)



    def __repr__(self):

        # Compute alt/az pointing
        azim, elev = trueRaDec2ApparentAltAz(self.RA_d, self.dec_d, self.JD, self.lat, self.lon, \
            refraction=self.refraction)

        out_str  = "Platepar\n"
        out_str += "--------\n"
        out_str += "Camera info:\n"
        out_str += "    Lat (+N)  = {:+11.6f} deg\n".format(self.lat)
        out_str += "    Lon (+E)  = {:+11.6f} deg\n".format(self.lon)
        out_str += "    Ele (MSL) = {:11.2f} m\n".format(self.elev)
        out_str += "    FOV       = {:6.2f} x {:6.2f} deg\n".format(*RMS.Astrometry.ApplyAstrometry.computeFOVSize(self))
        out_str += "    Img res   = {:6d} x {:6d} px\n".format(self.X_res, self.Y_res)
        out_str += "Reference pointing - equatorial (J2000):\n"
        out_str += "    JD      = {:.10f} \n".format(self.JD)
        out_str += "    RA      = {:11.6f} deg\n".format(self.RA_d)
        out_str += "    Dec     = {:+11.6f} deg\n".format(self.dec_d)
        out_str += "    Pos ang = {:.6f} deg\n".format(self.pos_angle_ref)
        out_str += "    Pix scl = {:.2f} arcmin/px\n".format(60/self.F_scale)
        out_str += "Reference pointing - apparent azimuthal (date):\n"
        out_str += "    Azim    = {:.6f} deg (+E of N)\n".format(azim)
        out_str += "    Alt     = {:.6f} deg\n".format(elev)
        out_str += "    Rot/hor = {:.6f} deg\n".format(RMS.Astrometry.ApplyAstrometry.rotationWrtHorizon(self))
        out_str += "Distortion:\n"
        out_str += "    Type = {:s}\n".format(self.distortion_type)

        # If the polynomial is used, the X axis parameters are stored in x_poly, otherwise radials paramters
        #   are used
        if self.distortion_type.startswith("poly"):
            out_str += "    Distortion coeffs (polynomial):\n"
            dist_string = "X"

            # Poly parameters for printing (needed for radial which will be modified)
            x_poly_fwd_print = self.x_poly_fwd
            x_poly_rev_print = self.x_poly_rev

        # Radial coefficients
        else:
            out_str += "    Distortion coeffs (radial):\n"

            out_str += "           "
            if not self.force_distortion_centre:
                out_str += " x0 (px),  y0 (px), "

            if not self.equal_aspect:
                out_str += "aspect-1, "

            if self.asymmetry_corr:
                out_str += "      a1, a2 (deg), "

            out_str +="      k1,       k2,       k3,       k4\n"
            
            dist_string = ""



            x_poly_fwd_print = np.array(self.x_poly_fwd)
            x_poly_rev_print = np.array(self.x_poly_rev)

            if not self.force_distortion_centre:

                # Report x0 and y0 in px (unnormalize and wrap)
                x_poly_fwd_print[0] *= self.X_res/2
                x_poly_fwd_print[1] *= self.Y_res/2
                x_poly_rev_print[0] *= self.X_res/2
                x_poly_rev_print[1] *= self.Y_res/2
                x_poly_fwd_print[0] = -self.X_res/2.0 + (x_poly_fwd_print[0] + self.X_res/2.0)%self.X_res
                x_poly_fwd_print[1] = -self.Y_res/2.0 + (x_poly_fwd_print[1] + self.Y_res/2.0)%self.Y_res
                x_poly_rev_print[0] = -self.X_res/2.0 + (x_poly_rev_print[0] + self.X_res/2.0)%self.X_res
                x_poly_rev_print[1] = -self.Y_res/2.0 + (x_poly_rev_print[1] + self.Y_res/2.0)%self.Y_res

            
            # Convert the asymmetry correction parameter to degrees
            if self.asymmetry_corr:
                asym_ang_index = 4
                
                if self.force_distortion_centre:
                    asym_ang_index -= 2

                if self.equal_aspect:
                    asym_ang_index -= 1

                x_poly_fwd_print[asym_ang_index] = np.degrees((2*np.pi*x_poly_fwd_print[asym_ang_index]) \
                    %(2*np.pi))
                x_poly_rev_print[asym_ang_index] = np.degrees((2*np.pi*x_poly_rev_print[asym_ang_index]) \
                    %(2*np.pi))




        out_str += "img2sky {:s} = {:s}\n".format(dist_string, ", ".join(["{:+8.3f}".format(c) \
            if abs(c) > 10e-4 else "{:+8.1e}".format(c) for c in x_poly_fwd_print]))
        out_str += "sky2img {:s} = {:s}\n".format(dist_string, ", ".join(["{:+8.3f}".format(c) \
            if abs(c) > 10e-4 else "{:+8.1e}".format(c) for c in x_poly_rev_print]))

        # Only print the rest if the polynomial fit is used
        if self.distortion_type.startswith("poly"):
            out_str += "img2sky Y = {:s}\n".format(", ".join(["{:+8.3f}".format(c) \
                if abs(c) > 10e-4 else "{:+8.1e}".format(c) for c in self.y_poly_fwd]))
            out_str += "sky2img Y = {:s}\n".format(", ".join(["{:+8.3f}".format(c) \
                if abs(c) > 10e-4 else "{:+8.1e}".format(c) for c in self.y_poly_rev]))

        return out_str


Question: import os
import sys
import glob
import copy
import datetime
import json
import collections
import numpy as np
import matplotlib.pyplot as plt
import scipy.stats
import RMS.Formats.CALSTARS as CALSTARS
    import argparse
    import RMS.ConfigReader as cr
from RMS.Astrometry.ApplyAstrometry import xyToRaDecPP
from RMS.Astrometry.ApplyRecalibrate import applyRecalibrate
from RMS.Astrometry.Conversions import areaGeoPolygon, jd2Date, datetime2JD, J2000_JD, raDec2AltAz
from RMS.ExtractStars import extractStarsAndSave
from RMS.Formats import FFfile
from RMS.Formats.FTPdetectinfo import readFTPdetectinfo
from RMS.Formats import Platepar
from RMS.Math import angularSeparation
from RMS.Routines.FOVArea import xyHt2Geo, fovArea
from RMS.Routines.MaskImage import loadMask, MaskStructure
from RMS.Routines.SolarLongitude import jd2SolLonSteyaert
from Utils.ShowerAssociation import showerAssociation, heightModel

        # Limit of meteor's elevation above horizon (deg). 25 degrees by default.
        self.elev_limit = 25

        # Minimum radiant elevation in the time bin (deg). 25 degreees by default
        self.rad_elev_limit = 25

        # Minimum distance of the end of the meteor to the radiant (deg)
        self.rad_dist_min = 20

        # Subdivide the time bin into the given number of subbins
        self.sub_time_bins = 2

        # Minimum number of meteors in the time bin
        self.meteros_min = 3



def collectingArea(platepar, mask=None, side_points=20, ht_min=60, ht_max=130, dht=2, elev_limit=10):
    """ Compute the collecting area for the range of given heights.
    
    Arguments:
        platepar: [Platepar object]

    Keyword arguments:
        mask: [Mask object] Mask object, None by default.
        side_points: [int] How many points to use to evaluate the FOV on seach side of the image. Normalized
            to the longest side.
        ht_min: [float] Minimum height (km).
        ht_max: [float] Maximum height (km).
        dht: [float] Height delta (km).
        elev_limit: [float] Limit of elevation above horizon (deg). 10 degrees by default.

    Return:
        col_areas_ht: [dict] A dictionary where the keys are heights of area evaluation, and values are
            segment dictionaries. Segment dictionaries have keys which are tuples of (x, y) coordinates of
            segment midpoints, and values are segment collection areas corrected for sensor effects.

    """


    # If the mask is not given, make a dummy mask with all white pixels
    if mask is None:
        mask = MaskStructure(255 + np.zeros((platepar.Y_res, platepar.X_res), dtype=np.uint8))


    # Compute the number of samples for every image axis
    longer_side_points = side_points
    shorter_side_points = int(np.ceil(side_points*platepar.Y_res/platepar.X_res))

    # Compute pixel delta for every side
    longer_dpx = int(platepar.X_res//longer_side_points)
    shorter_dpx = int(platepar.Y_res//shorter_side_points)


    # Distionary of collection areas per height
    col_areas_ht = collections.OrderedDict()

    # Estimate the collection area for a given range of heights
    for ht in np.arange(ht_min, ht_max + dht, dht):

        # Convert the height to meters
        ht = 1000*ht

        print(ht/1000, "km")

        total_area = 0

        # Dictionary of computed sensor-corrected collection areas where X and Y are keys
        col_areas_xy = collections.OrderedDict()

        # Sample the image
        for x0 in np.linspace(0, platepar.X_res, longer_side_points, dtype=np.int, endpoint=False):
            for y0 in np.linspace(0, platepar.Y_res, shorter_side_points, dtype=np.int, endpoint=False):
                
                # Compute lower right corners of the segment
                xe = x0 + longer_dpx
                ye = y0 + shorter_dpx

                # Compute geo coordinates of the image corners (if the corner is below the elevation limit,
                #   the *_elev value will be -1)
                _, ul_lat, ul_lon, ul_ht = xyHt2Geo(platepar, x0, y0, ht, indicate_limit=True, \
                    elev_limit=elev_limit)
                _, ll_lat, ll_lon, ll_ht = xyHt2Geo(platepar, x0, ye, ht, indicate_limit=True, \
                    elev_limit=elev_limit)
                _, lr_lat, lr_lon, lr_ht = xyHt2Geo(platepar, xe, ye, ht, indicate_limit=True, \
                    elev_limit=elev_limit)
                _, ur_lat, ur_lon, ur_ht = xyHt2Geo(platepar, xe, y0, ht, indicate_limit=True, \
                    elev_limit=elev_limit)


                # Skip the block if all corners are hitting the lower apparent elevation limit
                if np.all([ul_ht < 0, ll_ht < 0, lr_ht < 0, ur_ht < 0]):
                    continue


                # Make a polygon (clockwise direction)
                lats = [ul_lat, ll_lat, lr_lat, ur_lat]
                lons = [ul_lon, ll_lon, lr_lon, ur_lon]

                # Compute the area of the polygon
                area = areaGeoPolygon(lats, lons, ht)


                ### Apply sensitivity corrections to the area ###

                # Compute ratio of masked portion of the segment
                mask_segment = mask.img[y0:ye, x0:xe]
                unmasked_ratio = 1 - np.count_nonzero(~mask_segment)/mask_segment.size


                ## Compute the pointing direction and the vignetting and extinction loss for the mean location

                x_mean = (x0 + xe)/2
                y_mean = (y0 + ye)/2

                # Use a test pixel sum
                test_px_sum = 400

                # Compute the pointing direction and magnitude corrected for vignetting and extinction

Answer:                 _, ra, dec, mag = xyToRaDecPP([jd2Date(J2000_JD.days)], [x_mean], [y_mean], [test_px_sum], \