Program Options

Important

This documentation is not meant to go over all the physics/astronomy or background knowledge required to fully understand what the various programs are doing.

IRAF Reduction

Note

The pipeline for this program is set up only for use at BSUO. This will be updated in the future.

Making heavy use of Astropy’s ccdproc and Photutils, this program provides an automatic data reduction process using Bias, Dark, and Flat frames to reduce science images.

The GUI panel accepts the following inputs:

  • Raw Images Path — folder containing the raw, unreduced FITS images

  • Calibrated Images Path — folder where reduced images will be saved

  • Location — telescope site identifier (e.g. BSUO, CTIO)

  • Use Dark Frames — checkbox to include or skip dark frame subtraction

  • Overscan Region — format [columns, rows], e.g. [2073:2115, :]

  • Trim Region — format [columns, rows], e.g. [20:2060, 12:2057]

A Open Bias Image button is provided to plot row 1000 of a selected bias frame, helping the user identify the overscan and trim regions before running the reduction.

Note

For ccdproc, if the same rows are entered for both the overscan and trim region, ccdproc will error out. The recommendation is to use all rows (:) for the overscan region and specify exact rows only for the trim region.

Default Values

The reduction uses the following default camera parameters which can be overridden in the GUI:

  • Gain — 1.43 e⁻/ADU

  • Read Noise — 10.83 e⁻

  • Memory Limit — 450 MB (450e6)

Reduction Functions

The main reduction stages are bias, dark, flat, and science. Each calls a shared reduction function for the actual image processing:

Bias

The bias reduction subtracts the overscan and trims each raw bias frame, then combines them into a master bias using sigma clipping:

def bias(files, calibrated_data, intermediate_dir, cfg: ReductionConfig,
         log, cancel_event, summary=None):
    """
    Overscan-correct, trim, and gain-correct each bias frame, then combine
    them into a master bias.

    If ``cfg.reuse_masters`` is True and ``zero.fits`` already exists and is
    fresher than every raw bias frame, the existing master is loaded and
    returned — skipping regeneration entirely.

    :param summary: Optional :class:`RunSummary` to record per-stage counts
        and per-frame failures into.
    :return: Combined master bias CCDData, or None if canceled
    :raises ValueError: If no bias frames are found
    """
    log("\nStarting bias calibration.")
    log(f"Overscan Region: {cfg.overscan_region}")
    log(f"Trim Region:     {cfg.trim_region}")

    bias_paths = files.files_filtered(imagetyp=cfg.headers.imagetyp_bias, include_path=True)
    n_total = len(bias_paths)
    if summary is not None:
        summary.stage("bias").n_total = n_total

    if n_total == 0:
        raise ValueError(
            f"No {cfg.headers.imagetyp_bias!r} frames found in '{files.location}'. "
            f"Check that IMAGETYP headers match cfg.headers.imagetyp_bias."
        )
    log(f"Found {n_total} bias frame(s).")

    # Fast path: reuse existing master bias if fresh
    master_bias_path = calibrated_data / cfg.master_bias_name
    if cfg.reuse_masters and _master_is_fresh(master_bias_path, bias_paths):
        log(f"Reusing existing master bias: {master_bias_path}")
        if summary is not None:
            _set_stage_done(summary, "bias", "skipped", 0, 0, "reused fresh master")
            summary.record_master_reused("bias")
        return _load_master(master_bias_path)

    calibrated_bias_paths, n_failed, reference_shape = _process_bias_frames(
        bias_paths, intermediate_dir, cfg, log, cancel_event, summary, n_total,
    )
    if calibrated_bias_paths is None:
        # Cancelled mid-loop; helper already recorded the cancelled state.
        return None

    if not calibrated_bias_paths:
        raise RuntimeError(
            f"All {n_total} bias frames failed to process. Cannot create master bias."
        )
    if n_failed:
        log(f"Warning: {n_failed}/{n_total} bias frame(s) failed to process.")

    log(f"\nCombining {len(calibrated_bias_paths)} bias frame(s) into master bias.")
    combined_bias = _combine_and_write_master(
        calibrated_bias_paths, master_bias_path, cfg,
    )
    log(f"Master bias created: {master_bias_path}")

    _set_stage_done(summary, "bias", "ok",
                    len(calibrated_bias_paths), n_failed)
    return combined_bias

The sigma_clip_dev_func computes the standard deviation about the central value. See ccdproc documentation for more details.

Dark

Once the master bias is created, dark frames are bias-subtracted and combined into a master dark. Dark subtraction can be skipped entirely using the Use Dark Frames checkbox, since modern cooled CCDs often have negligible thermal noise.

def dark(files, zero, calibrated_data, intermediate_dir, cfg: ReductionConfig,
         log, cancel_event, reference_shape, summary=None):
    """
    Bias-subtract each dark frame, then combine them into a master dark.
    Every frame is checked against reference_shape before writing.

    If ``cfg.reuse_masters`` is True and ``master_dark.fits`` already exists
    and is fresher than every raw dark frame, the existing master is loaded
    and returned — skipping regeneration entirely.

    :param summary: Optional :class:`RunSummary` to record per-stage counts
        and per-frame failures into.
    :return: (Combined master dark CCDData, max dark EXPTIME in seconds)
             or (None, None) if canceled
    :raises ValueError: If no dark frames are found
    """
    log("\nStarting dark calibration.")

    dark_paths = files.files_filtered(imagetyp=cfg.headers.imagetyp_dark, include_path=True)
    n_total = len(dark_paths)
    if summary is not None:
        summary.stage("dark").n_total = n_total

    if n_total == 0:
        raise ValueError(
            f"No {cfg.headers.imagetyp_dark!r} frames found in '{files.location}', "
            "but cfg.dark_bool=True. Either add dark frames, set dark_bool=False, "
            "or adjust cfg.headers.imagetyp_dark to match your data."
        )
    log(f"Found {n_total} dark frame(s).")

    # Fast path: reuse existing master dark if fresh. Also scan raw darks
    # for max exposure time so the later science-scaling warning still works.
    master_dark_path = calibrated_data / cfg.master_dark_name
    if cfg.reuse_masters and _master_is_fresh(master_dark_path, dark_paths):
        log(f"Reusing existing master dark: {master_dark_path}")
        max_dark_exp = _max_dark_exposure(dark_paths, exptime_key=cfg.headers.exptime_key)
        if max_dark_exp is not None:
            log(f"Longest dark exposure (from raw headers): {max_dark_exp:.1f} s")
        if summary is not None:
            _set_stage_done(summary, "dark", "skipped", 0, 0, "reused fresh master")
            summary.record_master_reused("dark")
        return _load_master(master_dark_path), max_dark_exp

    calibrated_dark_paths, n_failed = _process_dark_frames(
        dark_paths, intermediate_dir, zero, cfg, log,
        cancel_event, reference_shape, summary, n_total,
    )
    if calibrated_dark_paths is None:
        # Cancelled mid-loop
        return None, None

    if not calibrated_dark_paths:
        raise RuntimeError(
            f"All {n_total} dark frames failed to process. Cannot create master dark."
        )
    if n_failed:
        log(f"Warning: {n_failed}/{n_total} dark frame(s) failed to process.")

    log(f"\nCombining {len(calibrated_dark_paths)} dark frame(s) into master dark.")
    combined_dark = _combine_and_write_master(
        calibrated_dark_paths, master_dark_path, cfg,
    )
    log(f"Master dark created: {master_dark_path}")

    # Track longest dark exposure for later science-exposure scaling check
    max_dark_exp = _max_dark_exposure(calibrated_dark_paths, exptime_key=cfg.headers.exptime_key)
    if max_dark_exp is not None:
        log(f"Longest dark EXPTIME: {max_dark_exp:.1f} s")

    _set_stage_done(summary, "dark", "ok",
                    len(calibrated_dark_paths), n_failed)

    return combined_dark, max_dark_exp

Flat

The master bias and master dark are subtracted from each flat frame. Master flats are created per filter:

def flat(files, zero, combined_dark, calibrated_data, intermediate_dir,
         cfg: ReductionConfig, log, cancel_event, reference_shape, summary=None):
    """
    Bias- and dark-subtract each flat frame, then combine per filter into
    normalised master flats. Every frame is checked against reference_shape.

    If ``cfg.reuse_masters`` is True, existing master flats are reused when
    (a) every distinct filter present in the raw flats has a corresponding
    ``master_flat_<FILT>.fits``, and (b) every such master is fresher than
    every raw flat frame. If any master is missing or stale, all flats are
    regenerated (partial reuse is not supported to avoid stale/fresh mixes).

    :param summary: Optional :class:`RunSummary` to record per-stage counts
        and per-frame failures into.
    """
    log("\nStarting flat calibration.")

    # Attempt to reuse existing master flats before processing anything
    if cfg.reuse_masters:
        raw_flat_paths = files.files_filtered(imagetyp=cfg.headers.imagetyp_flat, include_path=True)
        if raw_flat_paths and _can_reuse_master_flats(
            raw_flat_paths, calibrated_data, cfg, log
        ):
            log("Reusing existing master flats.")
            if summary is not None:
                summary.stage("flat").status = "skipped"
                summary.stage("flat").detail = "reused fresh masters"
                # Record which filters' masters were reused (best-effort)
                for raw_path in raw_flat_paths:
                    try:
                        with fits.open(raw_path) as hdul:
                            filt = _normalize_filter(
                                hdul[0].header.get(cfg.headers.filter_key),
                                prefixes=cfg.headers.filter_prefix_strip,
                            )
                            summary.record_master_reused(f"flat:{filt}")
                    except Exception:
                        pass
            return

    paths_by_filter = _process_flats(
        files, zero, combined_dark, intermediate_dir,
        cfg, log, cancel_event, reference_shape, summary=summary,
    )
    if paths_by_filter is None:
        # Cancelled inside _process_flats; status already set there
        return
    if not paths_by_filter:
        raise ValueError(
            "No flat frames were successfully processed. Cannot create master flats."
        )

    _combine_flats(paths_by_filter, calibrated_data, cfg, log, cancel_event)

    if summary is not None and summary.stage("flat").status not in ("cancelled",):
        summary.stage("flat").status = "ok"

Science

Science images are bias-subtracted, dark-subtracted, and flat-divided using the master flat for the matching filter.

Adding to the Header

Each reduced image has the reduction parameters written to its FITS header by the add_header function:

def add_header(pathway, fname, imagetyp, cfg: ReductionConfig):
    """
    Write basic reduction-metadata keywords to a FITS file.

    This writes the bookkeeping headers — gain, read noise, observatory,
    image type, trim/overscan sections, epoch, and equinox — that should
    be present on every reduced frame. Time-dependent corrections (HJD,
    BJD_TDB, sidereal time, airmass, RA/DEC reformatting) are handled
    separately by :func:`correct_headers`.

    :param pathway: Directory containing the file
    :param fname: File name
    :param imagetyp: FITS IMAGETYP value to write (from ``cfg.headers``)
    :param cfg: ReductionConfig
    """
    image_name = pathway / fname
    h = cfg.headers
    fits.setval(image_name, h.gain_key,        value=cfg.gain,            comment="Units of e-/ADU")
    fits.setval(image_name, h.rdnoise_key,     value=cfg.rdnoise,         comment="Units of e-")
    fits.setval(image_name, h.observatory_key, value=cfg.location,        comment="Observing location")
    fits.setval(image_name, h.imagetyp_key,    value=imagetyp,            comment="Image type")
    fits.setval(image_name, h.datasec_key,     value=cfg.trim_region,     comment="Trim data section")
    fits.setval(image_name, h.biassec_key,     value=cfg.overscan_region, comment="Overscan section")
    fits.setval(image_name, h.epoch_key,       value=h.epoch_value,       comment="Epoch of image coordinates")
    fits.setval(image_name, h.equinox_key,     value=h.equinox_value,     comment="Equinox of image coordinates")

BJD_TDB

TESS uses BJD_TDB while BSUO and SARA use HJD. This conversion is included to provide a consistent time standard across multiple telescopes and satellites:

TESS Database Search/Download

TESS is a valuable resource for eclipsing binary research — if a target is in the database it typically has weeks to months of continuous photometry available.

The GUI panel accepts the following inputs:

  • System Name — the TIC ID or common name of the target (e.g. NSVS 896797)

  • Download Path — folder where sector data will be saved

  • Download Specific Sector — checkbox to select a single sector instead of all available sectors

When Download Specific Sector is checked, a Retrieve Sectors button appears. Clicking it queries TESS for available sectors and populates a dropdown for the user to select from. The sector table is also printed to the output log.

Searching TESS

Given an object name, the program queries TESS for available sector numbers:

def run_tess_search(system_name, download_all, specific_sector, download_path, write_callback, cancel_event):
    """
    Search for TESS data and download the specified sectors with cancel functionality.

    Parameters
    ----------
    system_name : str
        The TIC ID or system name.
    download_all : bool
        Whether to download all available sectors.
    specific_sector : int, optional
        The specific sector to download if `download_all` is False.
    download_path : str, optional
        The directory to save downloaded files.
    write_callback : function, optional
        Callback to log progress or errors to the GUI.
    """

    def log(message):
        """Log messages to the GUI if callback provided, otherwise print"""
        if write_callback:
            write_callback(message)
        else:
            print(message)
    try:
        # Check for cancellation
        if cancel_event.is_set():
            log("Task canceled before starting.")
            return

        # Validate system name
        log(f"Searching for sectors for system: {system_name}")
        sector_table = Tesscut.get_sectors(objectname=system_name)

        if not sector_table:
            raise ValueError(f"No TESS data found for system {system_name}.")

        # The ccd info comes from this paper:
        # https://archive.stsci.edu/files/live/sites/mast/files/home/missions-and-data/active-missions/tess/_documents/TESS_Instrument_Handbook_v0.1.pdf
        filename = pkg_resources.resource_filename(__name__, 'tess_ccd_info.txt')
        dc = pd.read_csv(filename, header=None, sep="\t", skiprows=[0])

        gain = dc[3]  # videoscale, gain for the individual camera/ccd
        tess_camera = dc[0]  # camera number
        tess_ccd = dc[1]  # ccd number
        # slice = dc[2]  # could be used in the future

        sector_camera = []
        sector_ccd = []
        for count, val in enumerate(sector_table["camera"]):
            sector_camera.append(val)
            sector_ccd.append(sector_table["ccd"][count])

        # create a tuple form of the sector and TESS camera/ccd data
        list_gain = []
        a = list(zip(tess_camera, tess_ccd))  # tess cam and ccd's
        b = list(zip(sector_camera, sector_ccd))  # sect cam and ccd's

        # compare the TESS and sector information
        for _, sect in enumerate(b):
            for y, tess in enumerate(a):
                if sect == tess:
                    list_gain.append(gain[y])

        # splits up list_gain into lists by every 1, 2, 3, or 4th value since there are 4 slices for each camera and ccd
        A = list_gain[::4]
        B = list_gain[1::4]
        C = list_gain[2::4]
        D = list_gain[3::4]

        # append the gain values to the sector table list
        sector_table.add_column(A, name="A gain")
        sector_table.add_column(B, name="B gain")
        sector_table.add_column(C, name="C gain")
        sector_table.add_column(D, name="D gain")

        # prints off the sector table to let the user know what sectors TESS has observed the object
        log("\nThe read noise for the cameras is between 7-11 electrons/pixel.")

        # Log the sector table
        formatted_table = "\n".join(sector_table.pformat(show_name=True, max_width=-1, align="^"))
        log("Sector Table:\n" + formatted_table)

        # Check specific_sector logic
        if not download_all:
            log("Downloading all available sectors.")
            for sector in sector_table["sector"]:
                if cancel_event.is_set():
                    log(f"Task canceled while processing Sector {sector}.")
                    return
                download_sector(system_name, sector, download_path, write_callback, cancel_event)
        else:
            if specific_sector:
                log(f"Downloading specific sector: {specific_sector}.")
                download_sector(system_name, specific_sector, download_path, write_callback, cancel_event)
            else:
                log("Error: Specific sector is not specified.")
                raise ValueError("Specific sector is not specified.")

        log("Finished downloading all sector data related to " + system_name + "\n")
    except Exception as e:
        log(f"An error occurred during TESS Database Search: {e}")

Downloading

Sectors are downloaded as 30x30 arcmin cutouts — the maximum size allowed by TESS. Each sector is saved to its own numbered subdirectory inside the download path:

def download_sector(system_name, sector, download_path, write_callback, cancel_event):
    """
    Download TESS sector data for a given system.

    Parameters
    ----------
    system_name : str
        The TIC ID or system name.
    sector : int
        The sector number to download.
    download_path : str
        The path to save downloaded data.
    write_callback: function
        to write log messages to the GUI
    """

    def log(message):
        """Log messages to the GUI if callback provided, otherwise print"""
        if write_callback:
            write_callback(message)
        else:
            print(message)

    try:
        if not exists(download_path):
            raise FileNotFoundError(f"The path '{download_path}' does not exist.")

        # Create sector-specific directory if it doesn't exist
        sector_path = os.path.join(download_path, str(sector))
        os.makedirs(sector_path, exist_ok=True)  # Create the directory if it doesn't exist
        log(f"Directory created or already exists: {sector_path}")

        log(f"Starting download for Sector {sector}.")
        manifest = Tesscut.download_cutouts(
            objectname=system_name, size=[30, 30] * u.arcmin, sector=sector, path=sector_path)
        process_tess_cutout(
            search_file=manifest,  # Replace with actual file
            pathway=sector_path,
            sector=sector,
            outprefix=f"{system_name}_S{sector}_",
            write_callback=write_callback,
            cancel_event=cancel_event
        )
        log(f"Completed download for Sector {sector}.")
    except Exception as e:
        log(f"Failed to download Sector {sector}: {e}")
        raise

TESSCut

The downloaded TESS file is a FITS file containing all images for a given sector. The tesscut.py file handles extracting individual images and reading the mid-exposure time in BJD_TDB from each image’s metadata:

def process_tess_cutout(search_file, pathway, sector, outprefix, write_callback, cancel_event):
    """
    Process TESS pixel data from a BINTABLE file and save individual images.

    Parameters
    ----------
    search_file : str
        The path to the TESS BINTABLE file.
    pathway : str
        The directory to save output FITS files.
    outprefix : str
        The prefix for the output file names.
    write_callback : function, optional
        A function to log messages to the GUI.
    sector: int
        The sector number being downloaded
    """
    def log(message):
        """Log messages to the GUI or print to the console."""
        if write_callback:
            write_callback(message)
        else:
            print(message)

    try:
        if cancel_event.is_set():
            log(f"Task canceled while processing Sector {sector}.")
            return

        # Extract file name and verify file paths
        infile = "tess" + str(search_file).strip().split("tess")[1]
        fits_path = os.path.join(pathway, infile)

        log(f"Processing file: {infile}")
        log(f"Output directory: {pathway}")

        if not os.path.exists(fits_path):
            raise FileNotFoundError(f"{fits_path} does not exist.")

        # Open FITS file
        inlist = pyfits.open(fits_path)
        inhdr2 = inlist[2].header
        newhdr = inhdr2

        # Remove problematic header fields
        for field in ["INSTRUME", "TELESCOP", "CHECKSUM", "EXTNAME"]:
            newhdr.pop(field, None)

        imagedata = inlist[1].data
        nimages = len(imagedata)

        log("Starting to process images. This may take a few minutes.")
        for i in range(nimages):
            inimage = imagedata[i][4]
            bjd1 = imagedata[i][0]
            quality_flag = imagedata[i][8]
            tess_ffi = imagedata[i][11]

            if np.isnan(bjd1):
                log(f"Image {i + 1} skipped: lacks valid BJD timestamp.")
                continue
            elif quality_flag != 0:
                log(f"Image {i + 1} skipped: poor quality.")
                continue

            # Calculate mid-exposure BJD and HJD
            bjd = 2457000. + bjd1
            split = infile.split("_")
            ra = conversion([str(float(split[1]) / 15)])[0]
            dec = conversion([split[2]])[0]
            hjd = bary_to_helio(ra, dec, bjd, "greenwich")

            # Create output FITS file
            outimage = inimage
            outlist = pyfits.PrimaryHDU(outimage.astype("float32"), newhdr)
            outhdr = outlist.header

            # Update header metadata
            outhdr["LST_UPDT"] = strftime("%Y-%m-%d %H:%M:%S", gmtime())
            outhdr["BJD_TDB"] = bjd
            outhdr["HJD"] = hjd.value
            outhdr["COMMENT"] = tess_ffi

            outfile = f"{pathway}/{outprefix}_tess_{i:05d}.fits"
            outlist.writeto(outfile, overwrite=True)
            log(f"Saved image {i + 1}: {outfile}")

        log("Finished processing all images.")
        inlist.close()
    except Exception as e:
        log(f"An error occurred during processing: {e}")
        raise

BJD to HJD

Takes RA, DEC, BJD_TDB, and location as inputs and returns the light-travel-time corrected HJD.

AIJ Comparison Star Selector

Cousins R

Note

Utilizes GPU acceleration through numba for the calculations function.

The Cousins R magnitude for each comparison star is calculated using the equation from Jester et al. 2005:

@jit(forceobj=True)
def calculations(B, V, g, r, i, e_B, e_V, e_g, e_r, e_i, count):
    """
    Calculates (O-C) values

    :param B: Johnson B magnitude
    :param V: Johnson V magnitude
    :param g: g' mag
    :param r: r' mag
    :param i: i' mag
    :param e_B: error of Johnson B mag
    :param e_V: error of Johnson V mag
    :param e_g: error of g' mag
    :param e_r: error of r' mag
    :param e_i: error of i' mag
    :param count: the number that the iteration is on to pick the correct values from lists

    :return: root_rc, val_rc, root_ic, val_ic - mag and error respectively for Rc and Ic
    """
    # predefined values DO NOT change.
    # See Rogers et al 2018, Improved Transformation Equations for Main Sequence Stars
    # See eqn (2):  https://arxiv.org/pdf/astro-ph/0609736
    alpha = 0.278
    e_alpha = 0.016
    beta = 1.321
    e_beta = 0.03
    gamma = 0.219

    # separates the equation out into more easily readable sections
    numerator = alpha * (float(B[count]) - float(V[count])) - gamma - float(g[count]) + float(r[count])
    div = numerator / beta
    val_rc = float(V[count]) + div

    b_v_err = np.sqrt(float(e_B[count]) ** 2 + float(e_V[count]) ** 2)
    b_v_alpha_err = np.abs(alpha * (float(B[count]) - float(V[count]))) * np.sqrt(
        (e_alpha / alpha) ** 2 + (b_v_err / (float(B[count]) - float(V[count]))) ** 2)

    numerator_err = np.sqrt(b_v_alpha_err ** 2 + float(e_g[count]) ** 2 + float(e_r[count]) ** 2)
    div_e = np.abs(div) * np.sqrt((numerator_err / numerator) ** 2 + (e_beta / beta) ** 2)

    root_rc = np.sqrt(div_e ** 2 + float(e_V[count]) ** 2)

    # predefined values DO NOT change.
    # See Rogers et al 2018, Improved Transformation Equations for Main Sequence Stars
    # See eqn (3):  https://arxiv.org/pdf/astro-ph/0609736
    delta = 1.000
    e_delta = 0.006
    epsilon = 0.212

    val_ic = val_rc - (float(r[count]) - float(i[count]) + epsilon) / delta

    e_ic_squared = root_rc ** 2 + (float(e_r[count]) ** 2 + float(e_i[count]) ** 2) / (delta ** 2) + \
                   (((float(r[count]) - float(i[count]) + epsilon) / (delta ** 2)) * e_delta) ** 2
    root_ic = np.sqrt(e_ic_squared)

    return root_rc, val_rc, root_ic, val_ic
	RA	DEC	BMag	e_BMag	VMag	e_VMag	Rc	e_Rc
0	0:30:25.656	78:45:18.119	13.63	0.17	11.71	0.06	10.74	0.15
1	0:25:42.589	78:44:33.436	13.73	0.15	12.3	0.05	11.55	0.12
2	0:23:39.389	78:46:55.326	12.84	0.42	12.14	0.36	11.72	0.55
3	0:27:1.938	78:47:5.212	13.31	0.14	11.99	0.05	11.31	0.12
4	0:28:58.742	78:47:55.597	13.61	0.16	12.08	0.05	11.28	0.13
5	0:28:22.105	78:48:11.434	10.92	0.2	10.43	0.06	10.10	0.14
6	0:26:11.727	78:50:23.521	12.2	0.14	11.29	0.05	10.77	0.12
7	0:24:45.491	78:53:35.117	11.49	0.12	10.25	0.05	9.67	0.10
8	0:23:20.030	78:49:20.363	12.6	0.17	12.22	0.07	11.99	0.14

Gaia

The comparison star selection also queries the Gaia DR3 catalog to calculate TESS magnitudes for each comparison star. References:

def tess_mag(ra, dec, write_callback, cancel_event, apass_vmag=None):
    """
    Calculates TESS magnitudes for comparison stars

    :param ra: List of RA's for comparison stars
    :param dec: List of DEC's for comparison stars
    :param write_callback: Function to write log messages to the GUI
    :param cancel_event:
    :param apass_vmag: APASS V magnitudes for dynamic TIC Tmag filtering

    :return: list of TESS magnitudes and errors
    """
    def log(message):
        """Log messages to the GUI if callback provided, otherwise print"""
        if write_callback:
            write_callback(message)
        else:
            print(message)
    try:
        if cancel_event and cancel_event.is_set():
            log("Task canceled.")
            return

        ra_hours = np.asarray(ra, dtype=float)
        dec_degrees = np.asarray(dec, dtype=float)

        if ra_hours.size != dec_degrees.size:
            raise ValueError("RA and DEC list lengths must match.")

        tmag_limit = _compute_tmag_limit(apass_vmag)

        log("Starting the query for the Vizier catalog.")
        if tmag_limit is not None:
            log(f"Applying TIC Tmag filter: Tmag < {tmag_limit:.3f}")

        tic_df = _query_tic_catalog(
            ra_hours,
            dec_degrees,
            tmag_limit=tmag_limit,
            write_callback=write_callback,
            cancel_event=cancel_event,
        )

        log("Finished querying the IV/39/tic82 Vizier Catalog.")
        log("Processing data from the Vizier catalog.")

        if cancel_event and cancel_event.is_set():
            log("Task canceled.")
            return

        t_list, t_err_list = _match_tic_magnitudes(ra_hours, dec_degrees, tic_df)

        log("Finished cataloging the Vizier results.")
        log("Finished TIC v8.2 search and extraction of TESS magnitudes.")

        return t_list, t_err_list
    except Exception as e:
        log(f"An error occurred: {e}")
        raise

If the TESS magnitude for a comparison star cannot be determined, its value and error are set to 99.999 so it will not be selected as a comparison star.

Creating RADEC Files

Four RADEC files are created — one each for Johnson B, Johnson V, Cousins R, and TESS — using Astro ImageJ (AIJ) formatting:

def create_radec(
    df,
    ra,
    dec,
    T_list,
    pipeline,
    folder_path,
    obj_name,
    write_callback,
    cancel_event,
    target_ra_text=None,
    target_dec_text=None
):
    """
    Creates a RADEC file for all 3 filters (Johnson B, V, Cousins R, and T)

    :param df: input catalog DataFrame
    :param ra: user entered RA for system
    :param dec: user entered DEC for system
    :param T_list: TESS magnitudes for comparison stars
    :param pipeline: True if pipeline, False if not
    :param folder_path: folder path for saving RADEC files
    :param obj_name: object name for saving RADEC files
    :param write_callback: Function to write log messages to the GUI
    :param cancel_event:

    :param target_ra_text: Optional original RA input string for header output
    :param target_dec_text: Optional original DEC input string for header output
    :return: None but saves the RADEC files to user specified locations
    """

    def log(message):
        """Log messages to the GUI if callback provided, otherwise print"""
        if write_callback:
            write_callback(message)
        else:
            print(message)

    try:
        if cancel_event.is_set():
            log("Task canceled.")
            return
        filters = ["B", "V", "R", "I", "g", "r", "i", "T"]
        mag_cols = [3, 5, 7, 9, 11, 13, 15, T_list]

        ra_list = df[1]
        dec_list = df[2]

        header_ra = target_ra_text if target_ra_text is not None else ra
        header_dec = target_dec_text if target_dec_text is not None else dec
        header = create_header(header_ra, header_dec)

        # to write lines to the file in order create new RADEC files for each filter
        file_list = []
        for fcount, filt in enumerate(filters):
            if cancel_event.is_set():
                log("Task canceled.")
                return
            if filt != "T":
                mag_list = df[mag_cols[fcount]]
            else:
                mag_list = mag_cols[fcount]

            lines = create_lines(ra_list, dec_list, mag_list, ra, dec, filt)

            output = header + lines

            # Use safe filenames for case-insensitive filesystems (macOS/Windows)
            # to prevent R/r and I/i from overwriting each other
            safe_filt = filt
            if filt == "g":
                safe_filt = "g_prime"
            elif filt == "r":
                safe_filt = "r_prime"
            elif filt == "i":
                safe_filt = "i_prime"

            outputfile = os.path.join(folder_path, obj_name + "_" + safe_filt)

            with open(outputfile + ".radec", "w") as file:
                file.write(output)

            file_list.append(outputfile + ".radec")

        log("Finished writing RADEC files for Johnson B, Johnson V, Cousins R, Cousins I, g', r', i', and TESS magnitudes.")

        return file_list
    except Exception as e:
        log(f"An error occurred: {e}")
        raise

Overlay

An optional overlay plot shows the locations of all comparison stars on a science image, with circles and index numbers marking each star:

def overlay(df, tar_ra, tar_dec, fits_file, folder_path: str = "", obj_name: str = "", write_callback=None):
    """
    Creates an overlay of a science image with APASS objects numbered as seen in the catalog file that
    was saved previously

    :param tar_dec: target declination
    :param tar_ra: target right ascension
    :param df: input catalog DataFrame
    :param folder_path: Directory path to save the overlay image
    :param obj_name: Object name used to generate the overlay filename
    :param write_callback: Optional callback to write log messages
    :return: File path to saved overlay image, or None when no FITS image is available
    """
    if not fits_file:
        if write_callback:
            write_callback("No science image provided. Skipping overlay.")
        else:
            print("No science image provided. Skipping overlay.")
        return

    if not os.path.exists(fits_file):
        if write_callback:
            write_callback(f"Science image path does not exist: {fits_file}. Skipping overlay.")
        else:
            print(f"Science image path does not exist: {fits_file}. Skipping overlay.")
        return

    # If a directory is given, find the first FITS file within it
    if os.path.isdir(fits_file):
        fits_extensions = ('.fits', '.fts', '.fit', '.FITS', '.FTS', '.FIT')
        candidates = sorted(f for f in os.listdir(fits_file) if f.endswith(fits_extensions))
        if not candidates:
            if write_callback:
                write_callback(f"No FITS files found in {fits_file}. Skipping overlay.")
            else:
                print(f"No FITS files found in {fits_file}. Skipping overlay.")
            return
        fits_file = os.path.join(fits_file, candidates[0])

    # get the image data for plotting purposes
    with fits.open(fits_file) as header_data_unit_list:
        image = header_data_unit_list[0].data
        header = header_data_unit_list[0].header

    # set variables to lists
    index_num = list(df[0])
    ra_catalog = list(df[1])
    dec_catalog = list(df[2])

    # convert the lists to degrees for plotting purposes
    ra_cat_new = (np.array([_to_decimal_coord(value) for value in ra_catalog], dtype=float) * 15) * u.deg
    dec_cat_new = np.array([_to_decimal_coord(value) for value in dec_catalog], dtype=float) * u.deg

    # text for the caption below the graph
    txt = "Number represents index value given in the final output catalog file."

    # Calculate the zscale interval for the image
    zscale = ZScaleInterval()

    # Calculate vmin and vmax for the image display
    vmin, vmax = zscale.get_limits(image)

    # plot the image and the overlays
    wcs = WCS(header)
    fig = plt.figure(figsize=(12, 8))
    fig.text(.5, 0.02, txt, ha='center')
    ax = plt.subplot(projection=wcs)
    plt.imshow(image, origin='lower', cmap='cividis', aspect='equal', vmin=vmin, vmax=vmax)
    plt.xlabel('RA')
    plt.ylabel('Dec')

    overlay = ax.get_coords_overlay('icrs')
    overlay.grid(color='white', ls='dotted')

    ax.scatter(ra_cat_new, dec_cat_new, transform=ax.get_transform('fk5'), s=200,
               edgecolor='red', facecolor='none', label="Potential Comparison Stars")
    ax.scatter((tar_ra * 15) * u.deg, tar_dec * u.deg, transform=ax.get_transform('fk5'), s=200,
               edgecolor='green', facecolor='none', label="Target Star")

    count = 0
    # annotates onto the image the index number and Johnson V magnitude
    for x, y in zip(ra_cat_new, dec_cat_new):
        px, py = wcs.wcs_world2pix(x, y, 0.)
        plt.annotate(str(index_num[count]), xy=(px + 30, py - 50), color="white", fontsize=12)
        count += 1

    plt.gca().invert_xaxis()
    plt.legend(bbox_to_anchor=(1.45, 1.01), fancybox=False, shadow=False)

    output_dir = folder_path if folder_path else os.path.dirname(os.path.abspath(fits_file))
    overlay_name = f"{obj_name}_overlay.png" if obj_name else "overlay.png"
    overlay_path = os.path.join(output_dir, overlay_name)
    os.makedirs(output_dir, exist_ok=True)
    plt.savefig(overlay_path, bbox_inches='tight', dpi=256)
    plt.close(fig)

    return overlay_path
_images/overlay_example.png

BSUO or SARA/TESS Night Filters

When using Astro ImageJ (AIJ), it produces .dat files containing magnitude and flux data for each night of observations. This program combines all nightly files into a single file per filter.

The program checks whether the .dat files contain five columns (magnitude only) or seven columns (magnitude and flux) and writes the combined output accordingly:

2458379.568521	11.151862	0.00268
2458379.57057	11.156097	0.002681
2458379.574054	11.154805999999999	0.003379
2458379.575709	11.171213999999999	0.003407
2458379.577341	11.175478	0.0034130000000000002
2458379.578996	11.18955	0.0034289999999999998
2458379.5806279997	11.193944	0.0034170000000000003
2458379.58226	11.204361	0.00344
2458379.583904	11.207133	0.0034270000000000004
2458379.585524	11.217825	0.003436
2458379.587156	11.242205	0.003474
2458379.588812	11.23937	0.00346
2458379.590444	11.2604	0.0034770000000000005
2458379.592076	11.271894	0.0035020000000000003
2458379.593708	11.282343	0.003532

O-C Plotting

The O-C plotting panel calculates Observed minus Calculated (O-C) values given a period and times of minimum (ToM), then fits linear and quadratic models to the data.

The GUI panel offers three modes selected by radio buttons:

  • BSUO/SARA — averages ToM across Johnson B, V, and Cousins R filters

  • TESS — processes a single TESS ToM file

  • All Data — merges multiple pre-calculated O-C files into one combined dataset

Common inputs across all modes:

  • Period — orbital period of the system in days

  • Output Folder — where all output files will be saved

  • I already have an Epoch value — checkbox to enter a known T0 and its error; if unchecked the first ToM in the data is used as T0

BSUO/SARA

Requires three ToM files, one per filter (B, V, R). The program averages the three filters for each epoch:

def BSUO(T0, T0_err, period, db, dv, dr, output_path, write_callback=None, cancel_event=None):
    """
    Uses BSUO/SARA filter ToMs to calculate an averaged ToM and O-C values.

    Parameters
    ----------
    T0 : float
        Initial epoch. If 0, the first averaged ToM is used as T0.
    T0_err : float
        Error on T0.
    period : float
        Orbital period of the system.
    db : pd.DataFrame
        B filter ToM data.
    dv : pd.DataFrame
        V filter ToM data.
    dr : pd.DataFrame
        R filter ToM data.
    output_path : str
        Base output folder path.
    write_callback : callable, optional
        Function to log messages.
    cancel_event : threading.Event, optional
        Event to check for cancellation.

    Returns
    -------
    str or None
        Path to the saved output file, or None if canceled.
    """
    def log(message):
        if write_callback:
            write_callback(message)
        else:
            print(message)

    strict_B = list(db[0])
    strict_B_err = list(db[2])
    strict_V = list(dv[0])
    strict_V_err = list(dv[2])
    strict_R = list(dr[0])
    strict_R_err = list(dr[2])

    E_est = []
    O_C = []
    O_C_err = []
    average_min = []
    average_err = []

    for count, val in enumerate(strict_B):
        if cancel_event and cancel_event.is_set():
            log("Task canceled during BSUO O-C calculation.")
            return None

        minimum = (val + strict_V[count] + strict_R[count]) / 3
        err = sqrt(strict_B_err[count] ** 2 + strict_V_err[count] ** 2 + strict_R_err[count] ** 2) / 3

        average_min.append("%.5f" % minimum)
        average_err.append(err)

        e, OC, OC_err, T0, T0_err = calculate_oc(minimum, err, T0, T0_err, period)
        E_est.append(e)
        O_C.append(OC)
        O_C_err.append(OC_err)

    dp = pd.DataFrame({
        "Minimums": average_min,
        "Epoch": E_est,
        "O-C": O_C,
        "O-C_Error": O_C_err
    })

    outfile = str(Path(output_path) / "BSUO_OC.txt")
    dp.to_csv(outfile, index=None, sep="\t")
    log(f"Finished saving BSUO O-C file to {outfile}")
    return outfile

TESS

Requires a single ToM file. No averaging is performed as only one filter is available:

def TESS_OC(T0, T0_err, period, df, output_path, write_callback=None, cancel_event=None):
    """
    Takes ToM data pre-gathered from TESS data and finds corresponding O-C values.

    Parameters
    ----------
    T0 : float
        Initial epoch. If 0, the first ToM is used as T0.
    T0_err : float
        Error on T0.
    period : float
        Orbital period of the system.
    df : pd.DataFrame
        DataFrame with ToM data (column 0 = ToM, column 2 = error).
    output_path : str
        Base output folder path.
    write_callback : callable, optional
        Function to log messages.
    cancel_event : threading.Event, optional
        Event to check for cancellation.

    Returns
    -------
    str or None
        Path to the saved output file, or None if canceled.
    """
    def log(message):
        if write_callback:
            write_callback(message)
        else:
            print(message)

    min_strict = list(df[0])
    min_strict_err = list(df[2])

    E_est = []
    O_C = []
    O_C_err = []

    for count, val in enumerate(min_strict):
        if cancel_event and cancel_event.is_set():
            log("Task canceled during TESS O-C calculation.")
            return None

        e, OC, OC_err, T0, T0_err = calculate_oc(val, min_strict_err[count], T0, T0_err, period)
        E_est.append(e)
        O_C.append(OC)
        O_C_err.append(OC_err)

    dp = pd.DataFrame({
        "Minimums": min_strict,
        "Epoch": E_est,
        "O-C": O_C,
        "O-C_Error": O_C_err
    })

    outfile = str(Path(output_path) / "TESS_OC.txt")
    dp.to_csv(outfile, index=None, sep="\t")
    log(f"Finished saving TESS O-C file to {outfile}")
    return outfile

All Data

Note

All input files must follow the format shown in example_OC_table.txt.

Accepts a comma-separated list of pre-calculated O-C files and merges them into a single output. A LaTeX-formatted table is also produced automatically:

def all_data(file_paths, period, output_path, write_callback=None, cancel_event=None):
    """
    Merges multiple O-C data files into a single combined output file and
    generates a LaTeX table.

    Parameters
    ----------
    file_paths : list of str
        List of paths to O-C data files to merge.
    period : float
        Orbital period of the system.
    output_path : str
        Base output folder path.
    write_callback : callable, optional
        Function to log messages.
    cancel_event : threading.Event, optional
        Event to check for cancellation.

    Returns
    -------
    str or None
        Path to the saved merged output file, or None if canceled.
    """
    def log(message):
        if write_callback:
            write_callback(message)
        else:
            print(message)

    minimum_list = []
    e_list = []
    o_c_list = []
    o_c_err_list = []

    for fname in file_paths:
        if cancel_event and cancel_event.is_set():
            log("Task canceled during All Data merge.")
            return None

        df = pd.read_csv(fname, header=None, skiprows=[0], sep=r"\s+")
        minimum = np.array(df[0])
        e = np.array(df[1])
        o_c = np.array(df[2])
        o_c_err = np.array(df[3])

        for num, val in enumerate(minimum):
            minimum_list.append("%.5f" % val)
            e_list.append(e[num])
            o_c_list.append("%.5f" % o_c[num])
            o_c_err_list.append("%.5f" % o_c_err[num])

    dp = pd.DataFrame({
        "Minimums": minimum_list,
        "Epoch": e_list,
        "O-C": o_c_list,
        "O-C_Error": o_c_err_list
    })

    outfile = str(Path(output_path) / "all_data_OC.txt")
    dp.to_csv(outfile, index=None, sep="\t")
    log(f"Finished saving merged O-C file to {outfile}")

    # LaTeX table
    table_header = r"\renewcommand{\baselinestretch}{1.00} \small\normalsize"
    table_header += "\n" + r"\begin{center}" + "\n" + r"\begin{longtable}{ccc}" + "\n"
    table_header += r"$BJD_{\rm TDB}$ & E & O-C \\" + "\n"
    table_header += r"\hline" + "\n" + r"\endfirsthead" + "\n"
    table_header += r"\multicolumn{3}{c}" + "\n"
    table_header += r"{\tablename~\thetable~-- \textit{Continued from previous page}} \\" + "\n"
    table_header += r"$BJD_{\rm TDB}$ & E & O-C \\" + "\n"
    table_header += r"\hline" + "\n" + r"\endhead" + "\n" + r"\hline" + "\n"
    table_header += r"\multicolumn{3}{c}{\textit{Continued on next page}} \\" + "\n"
    table_header += r"\endfoot" + "\n" + r"\endlastfoot" + "\n"

    output = table_header
    for i in range(len(minimum_list)):
        line = (str(minimum_list[i]) + " & " + str(e_list[i]) + " & $" +
                str(o_c_list[i]) + r" \pm " + str(o_c_err_list[i]) + r"$ \\" + "\n")
        output += line

    output += (r"\hline" + "\n" +
               r"\caption{O-C table. Column 1 is $BJD_{TDB}$, column 2 is epoch number, "
               r"column 3 is $(O-C)$ with 1$\sigma$ error.}" + "\n" +
               r"\label{tbl:OC}" + "\n" +
               r"\end{longtable}" + "\n" +
               r"\end{center}" + "\n")
    output += r"\renewcommand{\baselinestretch}{1.66} \small\normalsize"

    tex_file = str(Path(output_path) / "all_data_OC.tex")
    with open(tex_file, "w") as f:
        f.write(output)
    log(f"LaTeX table saved to {tex_file}")

    return outfile
\renewcommand{\baselinestretch}{1.00} \small\normalsize\begin{center}
\begin{longtable}{ccc}
$BJD_{\rm TDB}$ & E & O-C \\ 
\hline
\endfirsthead
\multicolumn{3}{c}
{\tablename\ \thetable\ -- \textit{Continued from previous page}} \\ 
$BJD_{\rm TDB}$ & E & O-C \\ 
\hline
\endhead
\hline
\multicolumn{3}{c}{\textit{Continued on next page}} \\ 
\endfoot
\endlastfoot
2457143.76135 & 0.0 & $-0.00001 \pm 0.00020$ \\ 
2457143.91771 & 0.5 & $-0.00014 \pm 0.00026$ \\ 
2457161.75645 & 57.5 & $-0.00051 \pm 0.00020$ \\ 
2457161.91298 & 58.0 & $-0.00046 \pm 0.00019$ \\ 
\hline
\caption{NSVS 896797 O-C. The first column is the 
$BJD_{TDB}$ and column 2 is the epoch number with a whole number 
being a primary eclipse and a half integer value being a secondary 
eclipse. Column 3 is the $(O-C)$ value with the corresponding 
1$\sigma$ error.}
\label{tbl:896797_OC}
\end{longtable}
\end{center}
\renewcommand{\baselinestretch}{1.66} \small\normalsize

Calculations

The core O-C calculation is handled by the calculate_oc function:

@jit(forceobj=True)
def calculate_oc(m, err, T0, T0_err, p):
    """
    Calculates O-C values and errors and finds the eclipse number.

    Parameters
    ----------
    m : float
        Time of minimum.
    err : float
        Error on the time of minimum.
    T0 : float
        Reference epoch. If 0, set to m on first call.
    T0_err : float
        Error on T0.
    p : float
        Period of the system.

    Returns
    -------
    tuple
        (e, OC, OC_err, T0, T0_err)
    """
    if T0 == 0:
        T0 = m
        T0_err = err

    E_act = (m - T0) / p

    if E_act <= 0:
        e = ceil((E_act * 2) + 0.5) / 2
    else:
        e = floor((E_act * 2) + 0.5) / 2

    T_calc = T0 + (e * p)
    OC = "%.5f" % (m - T_calc)
    OC_err = "%.5f" % sqrt(T0_err ** 2 + err ** 2)

    return e, OC, OC_err, T0, T0_err

The Numba @jit decorator accelerates this function. The eclipse number is determined using floor (positive epoch) or ceiling (negative epoch), and O-C values are rounded to five decimal places.

Minimums	Eclipse_#	O-C	O-C_Error
2457143.76182	0.0	0.00000	0.00014
2457143.91854	0.5	-0.00008	0.00028
2457161.75726	57.5	-0.00088	0.00022
2457161.91375	58.0	-0.00088	0.00019
2458842.68058	5428.5	-0.03889	0.00051
2458842.83709	5429.0	-0.03885	0.00083
2458842.99348	5429.5	-0.03895	0.00050
2458843.14992	5430.0	-0.03900	0.00096
2458843.30633	5430.5	-0.03907	0.00046

Fitting and Output

After calculating O-C values, data_fit performs both a linear and quadratic weighted least squares fit. Output files written to the output folder:

  • [mode]_OC.txt — the O-C data table

  • [mode]_OC.png — the O-C plot with linear and quadratic fits

  • [mode]_OC.tex — regression tables formatted for LaTeX

def data_fit(input_file, period, write_callback=None, cancel_event=None):
    """
    Creates linear and quadratic fits to O-C data, saves the plot and
    regression tables to the same folder as the input file.

    Parameters
    ----------
    input_file : str
        Path to the O-C data file (tab-separated with header row).
    period : float
        Period of the system.
    write_callback : callable, optional
        Function to log messages.
    cancel_event : threading.Event, optional
        Event to check for cancellation.

    Returns
    -------
    float or None
        The adjusted period from the linear fit, or None if canceled.
    """
    def log(message):
        if write_callback:
            write_callback(message)
        else:
            print(message)

    df = pd.read_csv(input_file, header=0, sep=r"\s+")

    x = df["Epoch"]
    y = df["O-C"]
    y_err = df["O-C_Error"]
    weights = 1 / (y_err ** 2)

    x1_prim, y1_prim, y_err_prim = [], [], []
    x1_sec, y1_sec, y_err_sec = [], [], []

    for count, val in enumerate(x):
        if val % 1 == 0:
            x1_prim.append(float(val))
            y1_prim.append(float(y[count]))
            y_err_prim.append(float(y_err[count]))
        else:
            x1_sec.append(float(val))
            y1_sec.append(float(y[count]))
            y_err_sec.append(float(y_err[count]))

    x1_prim = np.array(x1_prim)
    y1_prim = np.array(y1_prim)
    y_err_prim = np.array(y_err_prim)
    x1_sec = np.array(x1_sec)
    y1_sec = np.array(y1_sec)
    y_err_sec = np.array(y_err_sec)

    xs = np.linspace(x.min(), x.max(), 1000)
    line_styles = [(0, (5, 5)), (0, (1, 1))]
    degree_list = ["Linear", "Quadratic"]

    # Output paths derived from input file location
    base = Path(input_file)
    tex_path = str(base.with_suffix(".tex"))
    plot_path = str(base.with_suffix(".png"))

    beginningtex = "\\documentclass{report}\n\\usepackage{booktabs}\n\\begin{document}\n"
    endtex = "\\end{document}"

    fig, ax = plt.subplots(figsize=(10, 6))
    i_string = ""
    new_period = period

    with open(tex_path, "w") as f:
        f.write(beginningtex)

        for i in range(1, 3):
            if cancel_event and cancel_event.is_set():
                log("Task canceled during data fitting.")
                plt.close(fig)
                return None

            model = Polynomial(np.polynomial.polynomial.polyfit(x1_prim, y1_prim, i))
            ax.plot(xs, model(xs), color="black", label=degree_list[i - 1] + " fit",
                    linestyle=line_styles[i - 1])

            if i >= 2:
                i_string += f" + I(x**{i})"
                mod = smf.wls(formula="y ~ x" + i_string, data=df, weights=weights)
                res = mod.fit()
                f.write(res.summary().as_latex())
            else:
                mod = smf.wls(formula="y ~ x", data=df, weights=weights)
                res = mod.fit()
                period_add = res.params[1]
                new_period = period + period_add
                log(f"Period correction from linear fit: {period_add:.8f} days")
                log(f"Adjusted period: {new_period:.8f} days")
                f.write(res.summary().as_latex())

        f.write(endtex)

    log(f"Regression tables saved to {tex_path}")

    fontsize = 14
    ax.errorbar(x1_prim, y1_prim, yerr=y_err_prim, fmt="o", color="blue", label="Primary")
    ax.errorbar(x1_sec, y1_sec, yerr=y_err_sec, fmt="s", color="green", label="Secondary")
    ax.legend(loc="upper right", fontsize=fontsize)
    ax.set_xlabel("Epoch", fontsize=fontsize)
    ax.set_ylabel("O-C (days)", fontsize=fontsize)
    ax.grid()

    fig.savefig(plot_path, bbox_inches="tight", dpi=150)
    plt.close(fig)
    log(f"O-C plot saved to {plot_path}")

    return new_period
_images/O_C_ex.png
\documentclass{report}
            \usepackage{booktabs}
            \begin{document}\begin{center}
\begin{tabular}{lclc}
\toprule
\textbf{Dep. Variable:}    &        y         & \textbf{  R-squared:         } &     0.982   \\
\textbf{Model:}            &       OLS        & \textbf{  Adj. R-squared:    } &     0.982   \\
\textbf{Method:}           &  Least Squares   & \textbf{  F-statistic:       } & 4.945e+04   \\
\textbf{Date:}             & Tue, 28 Mar 2023 & \textbf{  Prob (F-statistic):} &     0.00    \\
\textbf{Time:}             &     19:10:00     & \textbf{  Log-Likelihood:    } &    5410.8   \\
\textbf{No. Observations:} &         903      & \textbf{  AIC:               } & -1.082e+04  \\
\textbf{Df Residuals:}     &         901      & \textbf{  BIC:               } & -1.081e+04  \\
\textbf{Df Model:}         &           1      & \textbf{                     } &             \\
\textbf{Covariance Type:}  &    nonrobust     & \textbf{                     } &             \\
\bottomrule
\end{tabular}
\begin{tabular}{lcccccc}
                   & \textbf{coef} & \textbf{std err} & \textbf{t} & \textbf{P$> |$t$|$} & \textbf{[0.025} & \textbf{0.975]}  \\
\midrule
\textbf{Intercept} &       0.0001  &        0.000     &     0.895  &         0.371        &       -0.000    &        0.000     \\
\textbf{x}         &   -3.991e-06  &     1.79e-08     &  -222.382  &         0.000        &    -4.03e-06    &    -3.96e-06     \\
\bottomrule
\end{tabular}
\begin{tabular}{lclc}
\textbf{Omnibus:}       & 96.524 & \textbf{  Durbin-Watson:     } &     1.917  \\
\textbf{Prob(Omnibus):} &  0.000 & \textbf{  Jarque-Bera (JB):  } &   482.165  \\
\textbf{Skew:}          & -0.342 & \textbf{  Prob(JB):          } & 1.99e-105  \\
\textbf{Kurtosis:}      &  6.514 & \textbf{  Cond. No.          } &  4.27e+04  \\
\bottomrule
\end{tabular}
%\caption{OLS Regression Results}
\end{center}

O’Connell Effect

Note

Based on this paper and originally created by Alec J. Neal.

The GUI panel accepts the following inputs:

  • Number of Filters — radio buttons to select 1, 2, or 3 filters

  • File Path(s) — one file per selected filter (Johnson B, V, Cousins R)

  • HJD — reference epoch (first primary ToM)

  • Period — orbital period in days

  • System Name — used for output file naming

  • Output File Path — folder where results will be saved

Calculations

Magnitude data is converted to flux and phased using the period. The first and second halves of the phased light curve are then compared:

def Half_Comp(filter_files, Epoch, period,
              FT_order=10, sections=4, section_order=8,
              resolution=512, offset=0.25, save=False, outName='noname_halfcomp.png',
              title=None, filter_names=None, sans_font=False,
              write_callback=None, cancel_event=None):
    def log(message):
        """Log messages to the GUI if callback provided, otherwise print"""
        if write_callback:
            write_callback(message)
        else:
            print(message)

    if cancel_event.is_set():
        log("Task canceled.")
        return

    # Setting font family if not using sans font
    if sans_font == False:
        plt.rcParams['font.family'] = 'serif'  # Set font family to serif if sans_font is False
        plt.rcParams['mathtext.fontset'] = 'dejavuserif'  # Set math font to serif if sans_font is False

    # Calculate the number of bands
    bands = len(filter_files)

    # Create subplots for flux and dI
    axs, _ = plot.multiplot(figsize=(6, 9), dpi=512, height_ratios=[7 / 3 * bands, 3])
    flux = axs[0]  # Flux subplot
    dI = axs[1]  # dI subplot

    colors = ['blue', 'limegreen', 'red', 'm']  # Color list for plots
    styles = ['--', '-.', ':', '--']  # Line styles for plots

    R2_halves = []  # List to store R^2 values for each band

    # Draw horizontal line at y=0 on dI subplot
    dI.axhline(0, linestyle='-', color='black', linewidth=1)

    # Loop over each band
    for band in range(bands):
        # Binning of data
        a, b = binning.polybinner(filter_files[band], Epoch, period, sections=sections,
                                  norm_factor='alt', section_order=section_order)[0][:2:]

        half = int(0.5 * resolution) + 1  # Calculate half index for resolution

        # Compute Fourier Transform for positive and negative bins
        FT1 = FT.FT_plotlist(a, b, FT_order, resolution)
        FT2 = FT.FT_plotlist(a, -1 * b, FT_order, resolution)
        FTphase1, FTflux1 = FT1[0][:half:], FT1[1][:half:]  # Positive phase and flux
        FTphase2, FTflux2 = FT2[0][:half:], FT2[1][:half:]  # Negative phase and flux

        # Calculate coefficient of determination (R^2) between positive and negative fluxes
        R2_halves.append(calc.error.CoD(FTflux1, FTflux2))
        R2_halves.append(calc.error.CoD(FTflux2, FTflux1))

        dIlist = []  # List to store dI values
        for phase in FTphase1:
            dIlist.append(dI_phi(b, phase, FT_order))  # Calculate dI values

        # Adjust flux values for plotting
        FTflux1 = np.array(FTflux1) + (1 - band) * offset
        FTflux2 = np.array(FTflux2) + (1 - band) * offset

        # Plot flux and dI
        flux.plot(FTphase1, FTflux1, linestyle=styles[band], color=colors[band])
        flux.plot(FTphase2, FTflux2, '-', color=colors[band])

        # Add filter names to flux subplot if provided
        if filter_names is not None:
            if len(filter_names) == bands:
                flux.text(-0.12, FTflux1[0], filter_names[band], fontsize=18, rotation=0)

        # Plot dI
        dI.plot(FTphase1, dIlist, linestyle=styles[band], color=colors[band])

    if cancel_event.is_set():
        log("Task canceled.")
        return

    # Set x-axis limit for flux subplot
    plt.xlim(-0.025, 0.525)

    # Set y-axis label for flux subplot if filter_names is None
    if filter_names is None:
        flux.set_ylabel('Flux', fontsize=16)

    # Format subplots
    plot.sm_format(flux, numbersize=15, X=0.125, x=0.025, xbottom=False, bottomspine=False, Y=None)
    plot.sm_format(dI, numbersize=15, X=0.125, x=0.025, xtop=False, topspine=False)
    dI.set_xlabel(r'$\Phi$', fontsize=18)  # Set x-axis label for dI subplot
    dI.set_ylabel(r'$\Delta I(\Phi)_{\rm FT}$', fontsize=18)  # Set y-axis label for dI subplot

    # Set title for flux subplot if title is not empty
    if title != '':
        flux.set_title(title, fontsize=14.4, loc='left')

    # Save figure if save is True
    if save:
        plt.savefig(outName, bbox_inches='tight')
        log(outName + ' saved.')

    # plt.show()  # Show the plot

    # Reset font settings to default
    plt.rcParams['font.family'] = 'sans'
    plt.rcParams['mathtext.fontset'] = 'dejavusans'

    return None  # Return "Done" message

Statistical values (OER, LCA, ΔI) are calculated for each filter using Monte Carlo simulations (1000 by default):

def OConnell_total(inputFile, Epoch, period, order, sims=1000,
                   sections=4, section_order=8, norm_factor='alt',
                   starName='', filterName='', FT_order=10, FTres=500,
                   write_callback=None, cancel_event=None):
    """
    This function calculates various parameters related to the O'Connell Effect.
    It performs Monte Carlo simulations to estimate errors.
    Approximate runtime: ~ sims/1000 minutes.
    """

    def log(message):
        """Log messages to the GUI if callback provided, otherwise print"""
        if write_callback:
            write_callback(message)
        else:
            print(message)

    if cancel_event.is_set():
        log("Task canceled.")
        return

    """
    Generating master parameters from the observational data.
    """
    # ============================== DO NOT CHANGE ==============================
    # Binning the data
    PB = binning.polybinner(inputFile, Epoch, period, sections=sections, norm_factor=norm_factor,
                            section_order=section_order, FT_order=FT_order)
    c_MB = PB[1][0]  # Correct phase bins and fluxes
    nc_MB = PB[1][1]  # Non-corrected phase bins and fluxes
    ob_phaselist = c_MB[0][1]  # Corrected phase list
    ob_fluxlist = c_MB[1][1]  # Corrected flux list
    ob_fluxerr = c_MB[1][2]  # Corrected flux error list
    c_sec_phases = c_MB[5][0]  # Corrected section phases
    nc_sec_phases = nc_MB[5][0]  # Non-corrected section phases
    c_sec_index = c_MB[5][3]  # Corrected section index
    nc_sec_index = nc_MB[5][3]  # Non-corrected section index

    a = PB[0][0]  # Fourier coefficients (cosine terms)
    b = PB[0][1]  # Fourier coefficients (sine terms)
    # ==========================================================================

    FTsynth = FT.synth(a, b, ob_phaselist, FT_order)  # synthetic FT curve
    master_simflux = []
    for sim in tqdm(range(sims), desc='Simulating light curves', position=0):
        master_simflux.append(FT.sim_ob_flux(FTsynth, ob_fluxerr))
    # ============
    if cancel_event.is_set():
        log("Task canceled.")
        return
    # ============
    c_master_simflux = []
    nc_master_simflux = []
    master_polyflux = []
    """
    List of the resampled polynomial fluxes. Each embedded list is different 
    because the generating data has been Monte Carloed.
    """
    master_FTflux = []  # FT fluxes resulting from the simulations
    master_a = []
    master_b = []  # Lists of the a and b FT coefficients for each sim
    OERlist, LCAlist, dIlist, dIavelist = [], [], [], []

    # = begin sim loop =
    for sim in tqdm(range(sims), desc='Simulation processing', position=0):
        """
        Generating empty lists so that stuff can be inserted into the
        specified [sim] index. Otherwise when trying to append list[sim],
        it would throw an error (there'd be nothing to append to).
        """
        c_master_simflux.append([])
        nc_master_simflux.append([])

        # == begin section loop ===
        for section in range(sections):
            c_master_simflux[sim].append([])  # same as before
            nc_master_simflux[sim].append([])

            """
            'Replacing' the observational fluxes with the Monte Carlo fluxes
            based on the index of each data point when it was in ob_fluxlist
            (the MC fluxes are ordered the same as ob_fluxlist).
            """
            for i in range(len(c_sec_phases[section])):
                c_master_simflux[sim][section].append(master_simflux[sim][c_sec_index[section][i]])
            for i in range(len(nc_sec_phases[section])):
                nc_master_simflux[sim][section].append(master_simflux[sim][nc_sec_index[section][i]])
        # == end section loop == ; resume sim loop

        # Generating polynomial resampled fluxes for each simulation.
        minipoly = binning.minipolybinner(c_sec_phases, c_master_simflux[sim],
                                          nc_sec_phases, nc_master_simflux[sim],
                                          section_order)
        master_polyflux.append(minipoly[1])

        # Calculating various parameters for each simulation.
        FTcoef = FT.coefficients(master_polyflux[sim])
        master_a.append(FTcoef[1])
        master_b.append(FTcoef[2])
        master_FTflux.append(FT.FT_plotlist(master_a[sim], master_b[sim], FT_order, FTres)[1])
        OERlist.append(OConnell.OER_FT(master_a[sim], master_b[sim], FT_order))
        LCAlist.append(OConnell.LCA_FT(master_a[sim], master_b[sim], FT_order, 256))
        dIlist.append(OConnell.Delta_I_fixed(master_b[sim], FT_order))
        dIavelist.append(OConnell.Delta_I_mean_obs_noerror(ob_phaselist, ob_fluxlist, phase_range=0.05))
    # = end sim loop =

    if cancel_event.is_set():
        log("Task canceled.")
        return

    # === end Monte Carlo ===

    """
    Calculating FT model errors, as opposed to the errors calculated
    with the simulate light curves.
    """
    a0sig, a0rat = FT.a_sig_fast(a, b, 0, a[0], ob_phaselist, ob_fluxlist, ob_fluxerr, order)
    a_model_err = [a0sig]
    b_model_err = [0]
    a_rat = [a0rat]
    b_rat = [1]
    for n in tqdm(range(1, order + 1), position=0, desc='Calculating FT errors'):
        # TODO, dx0=1 isn't fullproof
        asig, ar = FT.a_sig_fast(a, b, n, a[n], ob_phaselist, ob_fluxlist, ob_fluxerr, order, dx0=1)
        bsig, br = FT.b_sig_fast(a, b, n, b[n], ob_phaselist, ob_fluxlist, ob_fluxerr, order, dx0=1)
        a_model_err.append(asig)
        b_model_err.append(bsig)
        a_rat.append(ar)
        b_rat.append(br)

    if cancel_event.is_set():
        log("Task canceled.")
        return

    # == FT coefficients ==
    a_MC_err = np.array(list(map(st.stdev, zip(*master_a))))
    b_MC_err = np.array(list(map(st.stdev, zip(*master_b))))
    a_total_err = (np.array(a_model_err) ** 2 + a_MC_err[:order + 1:] ** 2) ** 0.5
    b_total_err = (np.array(b_model_err) ** 2 + b_MC_err[:order + 1:] ** 2) ** 0.5

    a2_0125_a2 = a[2] * (0.125 - a[2])
    a2_0125_a2_err = sig_f(lambda x: x * (0.125 - x), a[2], a_total_err[2])

    # == OER ==
    OER = OConnell.OER_FT(a, b, order)
    OER_model_err = OConnell.OER_FT_error_fixed(a, b, a_model_err, b_model_err, order)
    OER_MC_err = st.stdev(OERlist)
    OER_total_err = (OER_model_err ** 2 + OER_MC_err ** 2) ** 0.5

    # == LCA ==
    LCA = OConnell.LCA_FT(a, b, order, 1024)
    LCA_model_err = OConnell.LCA_FT_error(a, b, a_model_err, b_model_err, order, 1024)[1]
    LCA_MC_err = st.stdev(LCAlist)
    LCA_total_err = (LCA_model_err ** 2 + LCA_MC_err ** 2) ** 0.5

    # == Delta_I ==
    Delta_I_025 = OConnell.Delta_I_fixed(b, order)
    Delta_I_025_model_err = OConnell.Delta_I_error_fixed(b_model_err, order)
    Delta_I_025_MC_err = st.stdev(dIlist)
    Delta_I_025_total_err = (Delta_I_025_model_err ** 2 + Delta_I_025_MC_err ** 2) ** 0.5

    # == Delta_I_ave ==
    DIave = OConnell.Delta_I_mean_obs(ob_phaselist, ob_fluxlist, ob_fluxerr, phase_range=0.05, weighted=False)
    Delta_I_ave = DIave[0]
    Delta_I_ave_err = DIave[1]

    # == print parameters ==
    r = lambda x: round(x, 5)

    valerr = lambda x, dx, label, PRECISION=6: print(label + ' =', round(x, PRECISION), '+/-', round(dx, PRECISION))

    # print('\n')
    valerr(a[1], a_total_err[1], 'a1')
    valerr(a[2], a_total_err[2], 'a2')
    valerr(a[4], a_total_err[4], 'a4')
    valerr(a[2] * (0.125 - a[2]),
           sig_f(lambda x: x * (0.125 - x), a[2], (a_model_err[2] ** 2 + a_MC_err[2] ** 2) ** 0.5), 'a2(0.125-a2)')
    # print('')
    valerr(OER, OER_total_err, 'OER')
    # print(r(OER_model_err), r(OER_MC_err), '\n')

    valerr(LCA, LCA_total_err, 'LCA')
    # print(r(LCA_model_err), r(LCA_MC_err), '\n')

    valerr(Delta_I_025, Delta_I_025_total_err, 'Delta_I')
    # print(r(Delta_I_025_model_err), r(Delta_I_025_MC_err), '\n')

    valerr(Delta_I_ave, Delta_I_ave_err, 'Delta_I_ave')

    return [a, a_total_err], [b, b_total_err], [Delta_I_025, Delta_I_025_total_err], [Delta_I_ave, Delta_I_ave_err], [
        OER, OER_total_err], [LCA, LCA_total_err], [a2_0125_a2, a2_0125_a2_err]
_images/OConnell_plot.png

See vseq_updated.py for the full mathematical implementations.

Output

A LaTeX-formatted table is produced containing all statistical values for each filter:

def multi_OConnell_total(filter_files, Epoch, period, order=10,
                         sims=1000, sections=4, section_order=8,
                         norm_factor='alt', starName='', filterNames=[r'$\rm B$', r'$\rm V$', r'$\rm R_C$'],
                         FT_order=10, FTres=500, plot_only=False,
                         plotoff=0.25, save=False, outName='noname.png',
                         write_callback=None, cancel_event=None):
    """
    This function generates a half-comparison plot and calculates various parameters related to the O'Connell Effect.
    If plot_only is set to True, only the half-comparison plot will be generated.
    """

    def log(message):
        """Log messages to the GUI if callback provided, otherwise print"""
        if write_callback:
            write_callback(message)
        else:
            print(message)

    if cancel_event.is_set():
        log("Task canceled.")
        return

    # Generate half-comparison plot
    Half_Comp(filter_files, Epoch, period, FT_order=order, sections=sections,
              section_order=section_order, offset=plotoff, save=save, outName=outName,
              filter_names=filterNames, write_callback=write_callback, cancel_event=cancel_event)

    if cancel_event.is_set():
        log("Task canceled.")
        return

    # Perform O'Connell analysis
    if not plot_only:
        filters = len(filter_files)
        a_all = []
        a_err_all = []
        b_all = []
        b_err_all = []
        dI_FT = []
        dI_FT_err = []
        dI_ave = []
        dI_ave_err = []
        OERs = []
        OERs_err = []
        LCAs = []
        LCAs_err = []
        a22s = []
        a22s_err = []

        # Running the O'Connell function on each filter
        for band in range(len(filter_files)):
            oc = OConnell_total(filter_files[band], Epoch, period, order, sims=sims,
                                sections=sections, section_order=section_order,
                                norm_factor=norm_factor, FT_order=order, FTres=FTres,
                                filterName='Filter ' + str(band + 1),
                                write_callback=write_callback, cancel_event=cancel_event)
            a_all.append(oc[0][0])
            a_err_all.append(oc[0][1])
            b_all.append(oc[1][0])
            b_err_all.append(oc[1][1])
            dI_FT.append(oc[2][0])
            dI_FT_err.append(oc[2][1])
            dI_ave.append(oc[3][0])
            dI_ave_err.append(oc[3][1])
            OERs.append(oc[4][0])
            OERs_err.append(oc[4][1])
            LCAs.append(oc[5][0])
            LCAs_err.append(oc[5][1])
            a22s.append(oc[6][0])
            a22s_err.append(oc[6][1])

        if cancel_event.is_set():
            log("Task canceled.")
            return

        # LaTeX table creation
        table_header = '\\begin{table}[H]\n' + '\\begin{center}\n' + '\\begin{tabular}{c|'
        for band in range(filters):
            table_header += 'c'
        table_header += '}\n' + '\\hline\\hline\n' + 'Parameter '
        for band in range(filters):
            table_header += '& Filter ' + str(band + 1)
        table_header += r'\\ ' + '\n' + '\\hline\n'

        a1_line = '$a_1$ '
        a2_line = '$a_2$ '
        a4_line = '$a_4$ '
        a22_line = '$a_2(0.125-a_2)$ '
        b1_line = '$2b_1$ '
        dIFT_line = r'$\Delta I_{\rm FT}$ '
        dIave_line = r'$\Delta I_{\rm ave}$ '
        OER_line = 'OER '
        LCA_line = 'LCA '

        strr = lambda x, e=5: str(round(x, e))
        for band in range(filters):
            a1_line += '& $' + strr(a_all[band][1]) + r'\pm ' + strr(a_err_all[band][1]) + '$ '
            a2_line += '& $' + strr(a_all[band][2]) + r'\pm ' + strr(a_err_all[band][2]) + '$ '
            a4_line += '& $' + strr(a_all[band][4]) + r'\pm ' + strr(a_err_all[band][4]) + '$ '
            a22_line += '& $' + strr(a22s[band]) + r'\pm ' + strr(a22s_err[band]) + '$ '
            b1_line += '& $' + strr(2 * b_all[band][1]) + r'\pm ' + strr(2 * b_err_all[band][1]) + '$ '
            dIFT_line += '& $' + strr(dI_FT[band]) + r'\pm ' + strr(dI_FT_err[band]) + '$ '
            dIave_line += '& $' + strr(dI_ave[band]) + r'\pm ' + strr(dI_ave_err[band]) + '$ '
            OER_line += '& $' + strr(OERs[band]) + r'\pm ' + strr(OERs_err[band]) + '$ '
            LCA_line += '& $' + strr(LCAs[band]) + r'\pm ' + strr(LCAs_err[band]) + '$ '

        if cancel_event.is_set():
            log("Task canceled.")
            return

        lines = [a1_line, a2_line, a4_line, a22_line, b1_line, dIFT_line, dIave_line, OER_line, LCA_line]
        for count, _ in enumerate(lines):
            lines[count] += r'\\ ' + '\n'

        output = table_header
        for _, line in enumerate(lines):
            output += line
        output += '\\hline\n' + '\\end{tabular}\n' + '\\caption{Fourier and O\'Connell stuff (' + str(
            sims) + ' sims)}\n' + '\\label{tbl:OConnell}\n' + '\\end{center}\n' + '\\end{table}\n'
        # End LaTeX table creation

        # print(output)  # Output LaTeX table to console
        # outputfile = input("Please enter an output file name without the extension: ")
        outputfile = outName.rstrip(".pdf")
        file = open(outputfile + ".txt", "w")
        file.write(output)  # Write LaTeX table to file
        file.close()

    return 'nada'
\begin{table}[H]
\begin{center}
\begin{tabular}{c|ccc}
\hline\hline
Parameter & Filter 1& Filter 2& Filter 3\\ 
\hline
$a_1$ & $-0.01769\pm 0.00029$ & $-0.01497\pm 0.00024$ & $-0.01428\pm 0.0016$ \\ 
$a_2$ & $-0.14912\pm 0.00064$ & $-0.14238\pm 0.00068$ & $-0.1405\pm 0.00125$ \\ 
$a_4$ & $-0.02487\pm 0.0003$ & $-0.02372\pm 0.00033$ & $-0.02319\pm 0.00143$ \\ 
$a_2(0.125-a_2)$ & $-0.04088\pm 0.00027$ & $-0.03807\pm 0.00028$ & $-0.0373\pm 0.00051$ \\ 
$2b_1$ & $0.00598\pm 0.00046$ & $0.00481\pm 0.00055$ & $0.00356\pm 0.00314$ \\ 
$\Delta I_{\rm FT}$ & $0.00646\pm 0.00141$ & $0.00462\pm 0.00177$ & $0.00415\pm 0.00784$ \\ 
$\Delta I_{\rm ave}$ & $0.00911\pm 0.00049$ & $0.00969\pm 0.00047$ & $0.0058\pm 0.00148$ \\ 
OER & $1.0138\pm 0.00151$ & $1.01268\pm 0.00205$ & $1.00894\pm 0.01198$ \\ 
LCA & $0.00456\pm 0.00141$ & $0.00351\pm 0.00151$ & $0.00531\pm 0.00458$ \\ 
\hline
\end{tabular}
\caption{Fourier and O'Connell stuff (1000 sims)}
\label{tbl:OConnell}
\end{center}
\end{table}

Color Light Curve

Note

Originally created by Alec J. Neal, updated for this package by Kyle Koeller.

The Color Light Curve panel calculates B-V and optionally V-R color indices and effective temperatures from multi-filter light curve data.

The GUI panel accepts the following inputs:

  • B-band File — Johnson B light curve file

  • V-band File — Johnson V light curve file

  • Period — orbital period in days

  • HJD (Epoch) — first primary ToM

  • Output Image Name — filename for the saved plot (must end in .png)

Subtract Light Curve

The subtract_LC function interpolates the B-band observations to the times of V-band observations, then calculates the instantaneous B-V color index:

def subtract_LC(Bfile, Vfile, Epoch, period,
                max_tol=0.03, lower_lim=0.05, FTinterp=True, quad_range=0.075, index="",
                write_callback=None, cancel_event=None):
    """
    This function actually creates the B-V and R-V values

    :param Bfile: input B file
    :param Vfile: input V file
    :param Epoch: epoch number
    :param period: period of the system
    :param max_tol: maximum tolerance
    :param lower_lim: lower limit
    :param FTinterp: interpolates number
    :param quad_range: ?
    :param index: type of color index to use

    :return: returns the B-V value and other assorted values
    """
    def log(message):
        """Log messages to the GUI if callback provided, otherwise print"""
        if write_callback:
            write_callback(message)
        else:
            print(message)

    B_HJD, B_mag, _ = io.importFile_pd(Bfile)[:3:]
    V_HJD, V_mag, _ = io.importFile_pd(Vfile)[:3:]

    Bpoly = binning.polybinner(Bfile, Epoch, period, sections=2, section_order=8)
    Bphase = Bpoly[1][0][0][1]
    aB = Bpoly[0][0]
    bB = Bpoly[0][1]
    Bnorm = Bpoly[1][0][4]
    Vphase = list(calc.astro.convert.HJD_phase(V_HJD, period, Epoch))
    B_flux = np.array(10 ** (-0.4 * np.array(B_mag))) / Bnorm
    obs = len(V_HJD)
    tolerance = best_tol(B_HJD, V_HJD, period, lower_lim=lower_lim, max_tol=max_tol)
    before_after = occ2(B_HJD, V_HJD, period, tolerance=tolerance)
    befores = before_after[1]
    afters = before_after[2]
    i_before = before_after[3]
    i_after = before_after[4]
    mean_diff = np.mean(before_after[5])

    B_interp_flux = []
    for n in range(obs):
        if len(befores[n]) == 0 or len(afters[n]) == 0:
            B_interp_flux.append(FT.sumatphase(Vphase[n], 10, aB, bB))
        else:
            B_interp_flux.append(lin_interp(V_HJD[n], befores[n][-1], afters[n][0],
                                            B_flux[i_before[n][-1]], B_flux[i_after[n][0]]))

    B_interp_mag = -2.5 * np.log10(np.array(B_interp_flux) * Bnorm)
    # quad_range=0.075
    BVquadphase = []
    BVquadmag = []
    # Vquad=[]
    for n in range(len(Vphase)):
        if 0.25 - quad_range < Vphase[n] < 0.25 + quad_range or 0.75 - quad_range < Vphase[n] < 0.75 + quad_range:
            BVquadphase.append(Vphase[n])
            BVquadmag.append(B_interp_mag[n] - V_mag[n])
    quadcolor = mean_mag(BVquadmag)
    colorerr = st.stdev(BVquadmag, xbar=quadcolor)
    log(str(index) + " " + str(quadcolor) + '+/-' + str(colorerr))

    B_minus_V = B_interp_mag - np.array(V_mag)
    BV_mean = mean_mag(B_minus_V)
    # print(B_minus_V)
    BV_err = st.stdev(B_minus_V, xbar=BV_mean)

    log('ave diff =' + str(round(mean_diff * 100, 3)) + '% of period')
    aVphase, aV_mag, aB_interp_mag = plot.aliasing2(Vphase, V_mag, B_interp_mag)[:3:]
    aBphase, aB_mag = plot.aliasing2(Bphase, B_mag, B_mag)[:2:]
    aB_minus_V = plot.aliasing2(Vphase, B_minus_V, B_minus_V)[1]
    B_V = [B_minus_V, BV_mean, BV_err, aB_minus_V]
    B = [aBphase, aB_mag, aB_interp_mag]
    V = [aVphase, aV_mag]

    # print('T =', vseq.Flower.T.Teff(quadcolor - (0.641 / 3.1)))
    if index == "BV":
        temp = []
        # temp = vseq.Flower.T.Teff(quadcolor - (0.641 / 3.1), colorerr)
        t1 = Flower.T.Teff(quadcolor - (0.641 / 3.1), colorerr)
        temp_high = Flower.T.Teff(quadcolor - (0.641 / 3.1) - colorerr, colorerr)
        temp_low = Flower.T.Teff(quadcolor - (0.641 / 3.1) + colorerr, colorerr)

        # temp_err = (temp_high[0] - temp_low[0])/2
        temp_err = np.sqrt(temp_high[1]**2 + temp_low[1]**2)
        temp.append(t1[0])
        temp.append(temp_err)
        log('T_BV =' + str(temp[0]) + '+/-' + str(temp[1]))
    elif index == "VR":
        t1 = Pecaut.T.Teff(quadcolor - (0.58 / 3.1), colorerr)
        temp = []
        # t1 = Flower.T.Teff(quadcolor - 0.561 * (0.641 / 3.1), colorerr)
        # E_V-R = 0.561*E_B-V
        temp_high = Pecaut.T.Teff(quadcolor - (0.58 / 3.1) - colorerr, colorerr)
        temp_low = Pecaut.T.Teff(quadcolor - (0.58 / 3.1) + colorerr, colorerr)
        # temp_high = Flower.T.Teff(quadcolor - 0.561* (0.641 / 3.1) - colorerr, colorerr)
        # temp_low = Flower.T.Teff(quadcolor - 0.561* (0.641 / 3.1) + colorerr, colorerr)

        # temp_err = (temp_high[0] - temp_low[0]) / 2
        temp_err = np.sqrt(temp_high[1] ** 2 + temp_low[1] ** 2)
        temp.append(t1[0])
        temp.append(temp_err)
        if temp[0] == 0:
            log("V-R color cannot be used to determine temperature.")
        else:
            log('T_VR =' + str(temp[0]) + '+/-' + str(temp[1]))

    return B_V, B, V, quadcolor, colorerr, temp

The effective temperature is derived from the color index using the polynomial fit from Flower 1996 with the Torres 2010 update:

        def Teff(BV, error):
            """
            Calculates the effective temperature (Teff) using the B-V color index.

            Parameters:
                BV (float): B-V color index.
                error (float): Error in B-V color index.

            Returns:
                tuple: A tuple containing the Teff value and its error.
            """
            # Calculate Teff using polynomial approximation
            temp = calc.poly.power(Flower.T.c, BV, 10)
            # Calculate error in Teff using error propagation
            err = calc.poly.t_eff_err(Flower.T.c, BV, error, temp)
            return temp, err

Note

The B-V polynomial is the one Flower specifically derived. Using the same polynomial for V-R is an approximation and should be treated with caution.

Plotting

The output log displays the color index, its error, and the derived effective temperature. The plot shows the phased light curves in the upper panel and the color index variation in the lower panel:

def color_plot(Bfile, Vfile, Epoch, period, max_tol=0.03, lower_lim=0.05, Rfile='', FTinterp=True,
               save=False, outName='noname_color.png', fs=12, write_callback=None, cancel_event=None):
    """
    This is a function version of the GUI and produces the same values but without the plotting aspect

    :param Bfile: input B text file
    :param Vfile: input V text file
    :param Epoch: epoch number
    :param period: period of the system
    :param max_tol: maximum tolerance
    :param lower_lim: lower limit
    :param Rfile: input R text file
    :param FTinterp: interpolate number
    :param save: save the output image
    :param outName: output image name
    :param fs:
    :return: assorted values
    """
    def log(message):
        """Log messages to the GUI if callback provided, otherwise print"""
        if write_callback:
            write_callback(message)
        else:
            print(message)

    B_V = subtract_LC(Bfile, Vfile, Epoch, period, max_tol=max_tol, lower_lim=lower_lim, FTinterp=FTinterp, index="BV", write_callback=write_callback, cancel_event=cancel_event)
    Bphase, Bmag, B_interp_mag = B_V[1][:3:]
    Vphase, Vmag = B_V[2][:2:]
    aB_minus_V = B_V[0][3]
    quadcolor, colorerr = B_V[3:5:]
    if Rfile == '':
        axs, _ = plot.multiplot((7, 7.5), height_ratios=[8, 4.5])
        mag = axs[0]
        bv = axs[1]
        mag.plot(Vphase, Vmag, 'og', ms=2)
        mag.plot(Bphase, Bmag, 'ob', ms=2)
        bv.plot(Vphase, aB_minus_V, 'ok', ms=2)
        bv.margins(y=0.1, x=1 / 24)
        mag.set_ylim(mag.get_ylim()[::-1])
        bv.set_ylim(bv.get_ylim()[::-1])
        plot.sm_format(mag, X=0.25, x=0.05, Y=None, numbersize=fs, xbottom=False, bottomspine=False, tickwidth=1,
                            Xsize=7, xsize=3.5)
        plot.sm_format(bv, X=0.25, x=0.05, numbersize=fs, xtop=False, topspine=False, tickwidth=1, Xsize=7,
                            xsize=3.5)

        maxtick = max(list(map(len, (list(map(str, np.array(mag.get_yticks()).round(8)))))))
        if maxtick == 5:
            ytickpad = -0.835
        else:
            ytickpad = -0.81
        mag.text(ytickpad, (max(Bmag) + min(Bmag)) / 2, 'B', rotation=90, fontsize=fs * 1.2)
        mag.text(ytickpad, (max(Vmag) + min(Vmag)) / 2, 'V', rotation=90, fontsize=fs * 1.2)
        # bv.set_xlabel('$\Phi$',fontsize=fs*1.2)
        bv.set_xlabel(r'$\Phi$', fontsize=fs * 1.5, usetex=False)
        bv.set_ylabel(r'$\rm B-V$', fontsize=fs * 1.2)
        # quadcolor,colorerr=B_V[3:5:]
        bv.axhline(quadcolor, color='gray', linewidth=None)
    else:
        V_R = subtract_LC(Vfile, Rfile, Epoch, period, max_tol, lower_lim=lower_lim, index="VR")
        Rphase, Rmag = V_R[2][:2:]
        # V_interp_mag = V_R[1][2]
        aV_minus_R = V_R[0][3]
        axs, fig = plot.multiplot((7, 9), height_ratios=[8, 3, 3])
        mag = axs[0]
        bv = axs[2]
        vr = axs[1]
        mag.plot(Vphase, Vmag, 'og', ms=2)
        mag.plot(Bphase, Bmag, 'ob', ms=2)
        mag.plot(Rphase, Rmag, 'or', ms=2)

        bv.plot(Vphase, aB_minus_V, 'ok', ms=3)
        vr.plot(Rphase, aV_minus_R, 'ok', ms=3)
        bv.margins(y=0.07, x=1 / 24)
        vr.margins(y=0.07)
        # mag.margins(y=0.09)
        mag.set_ylim(mag.get_ylim()[::-1])
        bv.set_ylim(bv.get_ylim()[::-1])
        vr.set_ylim(vr.get_ylim()[::-1])
        plot.sm_format(mag, X=0.25, x=0.05, numbersize=fs, xbottom=False, bottomspine=False)
        plot.sm_format(vr, X=0.25, x=0.05, numbersize=fs, xtop=False, topspine=False, xbottom=False,
                            bottomspine=False)
        plot.sm_format(bv, X=0.25, x=0.05, numbersize=fs, xtop=False, topspine=False)
        maxtick = max(list(map(len, (list(map(str, np.array(mag.get_yticks()).round(8)))))))
        if maxtick == 5:
            ytickpad = -0.835
        else:
            ytickpad = -0.81
        mag.text(ytickpad, (max(Bmag) + min(Bmag)) / 2, r'$\rm B$', rotation=90, fontsize=fs * 1.2)
        mag.text(ytickpad, (max(Vmag) + min(Vmag)) / 2, r'$\rm V$', rotation=90, fontsize=fs * 1.2)
        mag.text(ytickpad, (max(Rmag) + min(Rmag)) / 2, r'$\rm R_C$', rotation=90, fontsize=fs * 1.2)
        bv.set_ylabel(r'$\rm B-V$', fontsize=fs * 1.2)
        vr.set_ylabel(r'$\rm V-R_C$', fontsize=fs * 1.2)
        bv.set_xlabel(r'$\Phi$', fontsize=fs * 1.2)
    if save:
        plt.savefig(outName, bbox_inches='tight')
    # plt.show()
    log(f"Color Temp = {quadcolor} {colorerr}")
_images/color_light_curve_ex.png _images/light_curve_ex.png