from __future__ import division, print_function, absolute_import

import os
import logging
import copy
from datetime import datetime
import pickle
import json
import math
from time import sleep
from collections import OrderedDict
import numbers

import pydash
import tensorflow as tf
import numpy as np
from astropy import units as u

from satsim.math import signal_to_noise_ratio, mean_degrees, diff_degrees, interp_degrees
from satsim.geometry.transform import rotate_and_translate, apply_wrap_around
from satsim.geometry.sprite import load_sprite_from_file
from satsim.image.fpa import analog_to_digital, mv_to_pe, pe_to_mv, add_patch, crop
from satsim.image.psf import gen_gaussian, eod_to_sigma, gen_from_poppy_configuration
from satsim.image.noise import add_photon_noise, add_read_noise
from satsim.image.render import render_piecewise, render_full
from satsim.geometry.draw import gen_line, gen_line_from_endpoints, gen_curve_from_points
from satsim.geometry.random import gen_random_points
from satsim.geometry.sstr7 import query_by_los
from satsim.geometry.csvsc import query_by_los as csvsc_query_by_los
from satsim.geometry.sgp4 import create_sgp4
from satsim.geometry.ephemeris import create_ephemeris_object
from satsim.geometry.astrometric import create_topocentric, gen_track, get_los, get_los_azel, GreatCircle
from skyfield.toposlib import _ltude
from satsim.geometry.photometric import model_to_mv
from satsim.geometry.twobody import create_twobody
from satsim.geometry.observation import create_observation
from satsim.io.satnet import write_frame, write_annotation, set_frame_annotation, init_annotation
from satsim.io.image import save_apng
from satsim.io.czml import save_czml
from satsim.util import tic, toc, MultithreadedTaskQueue, configure_eager, configure_single_gpu, merge_dicts
from satsim.geometry import analytic_obs
from satsim.io import analytical
from satsim.config import transform, save_debug, _transform, save_cache
from satsim.pipeline import _delta_t, _avg_t
from satsim import time

logger = logging.getLogger(__name__)


def gen_multi(ssp, eager=True, output_dir='./', input_dir='./', device=None, memory=None, pid=0, output_debug=False, folder_name=None):
    """Generates multiple sets of images. Number of sets is based on the
    parameters `ssp['sim']['samples']`.

    Examples::

        # load a template json file
        ssp = load_json('input/config.json')

        # edit some parameters
        ssp['sim']['samples'] = 50
        ssp['geometry']['obs']['list']['mv'] = 17.5

        # generate SatNet files to the output directory
        gen_multi(ssp, eager=True, output_dir='output/')

    Args:
        ssp: `dict`, static or dynamic satsim configuration and parameters.
        eager: `boolean`, Has no effect. `True` only.
        output_dir: `str`, output directory to save SatNet files.
        input_dir: `str`, typically the input directory of the configuration file.
        device: `array`, array of GPU device IDs to enable. If `None`, enable all.
        pid: `int`, an ID to associate this instance to.
        output_debug: `boolean`, output intermediate debug files.
        folder_name: `str`, Optional name for folder to save files to.
    """
    if(eager):
        configure_eager()

    if device is not None:
        logger.info('Starting process id {} on GPU {} with {} MB.'.format(pid, device, memory if memory is not None else 'MAX'))
        configure_single_gpu(device, memory)

    queue = MultithreadedTaskQueue()

    n = ssp['sim']['samples'] if 'samples' in ssp['sim'] else 1

    for set_num in range(n):

        tic('gen_set', set_num)

        logger.info('Generating set {} of {} on process {}.'.format(set_num + 1, n, pid))

        # transform the original satsim parameters (eval any random sampling)
        # do a deep copy to preserve the original configuration
        tssp, issp = transform(copy.deepcopy(ssp), input_dir, with_debug=True)

        # run the transformed parameters
        dir_name = gen_images(tssp, eager, output_dir, set_num, queue=queue, output_debug=output_debug, set_name=folder_name)

        # save intermediate transforms
        save_debug(issp, dir_name)

        logger.info('Finished set {} of {} in {} sec on process {}.'.format(set_num + 1, n, toc('gen_set', set_num), pid))

    queue.stop()
    queue.waitUntilEmpty()
    logger.info('SatSim process {} exiting.'.format(pid))


def gen_images(ssp, eager=True, output_dir='./', sample_num=0, output_debug=False, queue=None, set_name=None):
    """Generates a single set of images.

    Examples::

        # load a template json file
        ssp = load_json('input/config.json')
        ssp = transform(copy.deepcopy(ssp), 'input/')

        # generate SatNet files to the output directory
        gen_images(ssp, eager=True, output_dir='output/')

    Args:
        ssp: `dict`, static satsim configuration and parameters. Any dynamic
            parameters should already be transformed.
        eager: `boolean`, Has no effect. `True` only.
        output_dir: `str`, output directory to save SatNet files.
        sample_num: `int`, recorded to annotation files.
        output_debug: `boolean`, output intermediate debug files.
        queue: `MultithreadedTaskQueue`, if not None, files will be written.
        set_name: `str`, sets the directory name to save the images to, if None, is set to current time.

    Returns:
        A `str`, directory to where the output files are saved.
    """
    ssp_orig = copy.deepcopy(ssp)

    (num_frames, t_exposure, h_fpa_os, w_fpa_os, s_osf, y_ifov, x_ifov, a2d_dtype, height, width) = _parse_sensor_params(ssp)

    dt = datetime.now()
    if set_name is None:
        dir_name = os.path.join(output_dir, dt.isoformat().replace(':','-'))
        set_name = 'sat_{:05d}'.format(sample_num)
    else:
        dir_name = os.path.join(output_dir, set_name)

    # make output dirs
    os.makedirs(dir_name, exist_ok=True)
    dir_debug = os.path.join(dir_name,'Debug')
    if output_debug and not os.path.exists(dir_debug):
        os.makedirs(dir_debug, exist_ok=True)

    # init annotations
    meta_data = init_annotation(
        'dir.name',
        ['{}.{:04d}.json'.format(set_name,x) for x in range(num_frames)],
        height,
        width,
        y_ifov,
        x_ifov)

    astrometrics_list = []
    for fpa_digital, frame_num, astrometrics, obs_os_pix, fpa_conv_star, fpa_conv_targ, bg_tf, dc_tf, rn_tf, num_shot_noise_samples, obs_cache, ground_truth, star_os_pix, segmentation in image_generator(ssp, dir_name, output_debug, dir_debug, with_meta=True, num_sets=1):
        astrometrics_list.append(astrometrics)
        if fpa_digital is not None:
            snr = signal_to_noise_ratio(fpa_conv_targ, fpa_conv_star + bg_tf + dc_tf, rn_tf)
            if num_shot_noise_samples is not None:
                snr = snr * np.sqrt(num_shot_noise_samples)
            meta_data = set_frame_annotation(meta_data, frame_num, h_fpa_os, w_fpa_os, obs_os_pix, [20 * s_osf, 20 * s_osf], snr=snr, star_os_pix=star_os_pix, metadata=astrometrics)
            if queue is not None:
                queue.task(write_frame, {
                    'dir_name': dir_name,
                    'sat_name': set_name,
                    'fpa_digital': fpa_digital.numpy(),
                    'meta_data': copy.deepcopy(meta_data),
                    'frame_num': frame_num,
                    'exposure_time': t_exposure,
                    'time_stamp': dt,
                    'ssp': ssp_orig,
                    'show_obs_boxes': ssp['sim']['show_obs_boxes'],
                    'show_star_boxes': ssp['sim']['show_star_boxes'],
                    'astrometrics': astrometrics,
                    'save_pickle': ssp['sim']['save_pickle'],
                    'dtype': a2d_dtype,
                    'save_jpeg': ssp['sim']['save_jpeg'],
                    'ground_truth': ground_truth,
                    'segmentation': segmentation,
                }, tag=dir_name)
            if output_debug:
                with open(os.path.join(dir_debug, 'metadata_{}.json'.format(frame_num)), 'w') as jsonfile:
                    json.dump(meta_data, jsonfile, indent=2, default=str)

            logger.debug('Finished frame {} of {} in {} sec.'.format(frame_num + 1, num_frames, toc('gen_frame', frame_num)))
        else:
            if ssp['sim'].get('analytical_obs', False):
                meta_data = set_frame_annotation(
                    meta_data,
                    frame_num,
                    h_fpa_os,
                    w_fpa_os,
                    obs_os_pix,
                    [20 * s_osf, 20 * s_osf],
                    snr=None,
                    star_os_pix=None,
                    metadata=astrometrics,
                )
                if queue is not None:
                    queue.task(
                        write_annotation,
                        {
                            'dir_name': dir_name,
                            'sat_name': set_name,
                            'meta_data': copy.deepcopy(meta_data),
                            'frame_num': frame_num,
                            'ssp': ssp_orig,
                            'save_pickle': ssp['sim']['save_pickle'],
                        },
                        tag=dir_name,
                    )
            else:
                if queue is not None:
                    def f():
                        return None
                    queue.task(f, {}, tag=dir_name)
                logger.debug('Render mode off. Skipping frame generation.')

    # write movie
    def wait_and_run():
        while True:
            if queue.has_tag(dir_name):
                sleep(0.1)
            else:
                if ssp['sim']['save_movie']:
                    logger.debug('Saving PNG movie.')
                    save_apng(dirname=os.path.join(dir_name,'AnnotatedImages'), filename='movie.png')
                if ssp['sim']['save_czml']:
                    logger.debug('Saving CZML.')
                    save_czml(ssp, obs_cache, astrometrics_list, os.path.join(dir_name,'satsim.czml'))
                return

    if queue is not None:
        queue.task(wait_and_run, {})

    return dir_name


def _parse_sensor_params(ssp):
    """ TODO Temporary.
    """
    s_osf = ssp['sim']['spacial_osf']

    y_ifov = ssp['fpa']['y_fov'] / ssp['fpa']['height']
    x_ifov = ssp['fpa']['x_fov'] / ssp['fpa']['width']

    if 'crop' in ssp['fpa']:
        cp = ssp['fpa']['crop']
        h_fpa_os = cp['height'] * s_osf
        w_fpa_os = cp['width'] * s_osf
        height = cp['height']
        width = cp['width']
    else:
        h_fpa_os = ssp['fpa']['height'] * s_osf
        w_fpa_os = ssp['fpa']['width'] * s_osf
        height = ssp['fpa']['height']
        width = ssp['fpa']['width']

    t_exposure = ssp['fpa']['time']['exposure']

    num_frames = ssp['fpa']['num_frames']

    if 'dtype' in ssp['fpa']['a2d']:
        a2d_dtype = ssp['fpa']['a2d']['dtype']
    else:
        a2d_dtype = 'uint16'

    return num_frames, t_exposure, h_fpa_os, w_fpa_os, s_osf, y_ifov, x_ifov, a2d_dtype, height, width


def image_generator(ssp, output_dir='.', output_debug=False, dir_debug='./Debug', with_meta=False, eager=True, num_sets=0):
    """Generator function for a single set of images.

    Examples::

        # load a template json file
        ssp = load_json('input/config.json')
        ssp = transform(copy.deepcopy(ssp), 'input/')

        # generate SatNet files to the output directory
        for results in image_generator(ssp, eager=True, output_dir='output/'):
            imshow(results.numpy())

    Args:
        ssp: `dict`, static satsim configuration and parameters. Any dynamic
            parameters should already be transformed.
        output_dir: `str`, root directory for SatSim configuration file.
        sample_num: `int`, recorded to annotation files.
        output_debug: `boolean`, output intermediate debug files.
        dir_debug: `str`, directory to output debug files.
        with_meta: `boolean`, add metadata to the return function.
        eager: `boolean`, Has no effect. `True` only.
        num_sets: `int`, number of sets until generator exits. Set to 0 to run forever.

    Returns:
        An `image`, the rendered image. If `with_meta` is set to `True`, additional values are returned:
            fpa_digital: `image`, rendered image.
            frame_num: `int`, frame number.
            astrometrics: `dict`, astrometry meta data.
            obs_os_pix: `dict`, observations meta data.
            fpa_conv_star: `image`, pristine image with stars.
            fpa_conv_targ: `image`, pristine image with targets.
            bg_tf: `image`, background image.
            dc_tf: `image`, dark current image.
            rn_tf: `image`, read noise image.
            num_shot_noise_samples: `int`, number of shot noise samples averaged
    """
    tic('init')
    logger.debug('Initializing variables.')

    # evaluate any python pipelines
    ssp = _transform(ssp, output_dir, False, True)

    astrometrics = {}

    h = ssp['fpa']['height']
    w = ssp['fpa']['width']

    s_osf = ssp['sim']['spacial_osf']
    t_osf = ssp['sim']['temporal_osf']

    h_pad_os = 2 * ssp['sim']['padding'] * s_osf
    w_pad_os = 2 * ssp['sim']['padding'] * s_osf

    h_pad_os_div2 = ssp['sim']['padding'] * s_osf
    w_pad_os_div2 = ssp['sim']['padding'] * s_osf

    y_ifov = ssp['fpa']['y_fov'] / ssp['fpa']['height']
    x_ifov = ssp['fpa']['x_fov'] / ssp['fpa']['width']

    y_ifov_os = ssp['fpa']['y_fov'] / ssp['fpa']['height'] / s_osf
    x_ifov_os = ssp['fpa']['x_fov'] / ssp['fpa']['width'] / s_osf

    h_fpa_os = ssp['fpa']['height'] * s_osf
    w_fpa_os = ssp['fpa']['width'] * s_osf

    h_fpa_pad_os = h_fpa_os + h_pad_os
    w_fpa_pad_os = w_fpa_os + w_pad_os

    y_fov = h_fpa_os * y_ifov_os
    x_fov = w_fpa_os * x_ifov_os

    y_fov_pad = h_fpa_pad_os * y_ifov_os
    x_fov_pad = w_fpa_pad_os * x_ifov_os

    if 'render_size' in ssp['sim']:
        h_sub = ssp['sim']['render_size'][0]
        w_sub = ssp['sim']['render_size'][1]
        render_mode = 'piecewise'
    else:
        h_sub = h
        w_sub = w
        render_mode = 'full'

    h_sub_os = h_sub * s_osf
    w_sub_os = w_sub * s_osf
    h_sub_pad_os = h_sub_os + h_pad_os
    w_sub_pad_os = w_sub_os + w_pad_os

    t_exposure = ssp['fpa']['time']['exposure']
    t_frame = ssp['fpa']['time']['gap'] + t_exposure

    num_frames = ssp['fpa']['num_frames']
    astrometrics['num_frames'] = num_frames

    zeropoint = ssp['fpa']['zeropoint']

    if 'flip_up_down' not in ssp['fpa']:
        ssp['fpa']['flip_up_down'] = False

    if 'flip_left_right' not in ssp['fpa']:
        ssp['fpa']['flip_left_right'] = False

    if 'detection' not in ssp['fpa']:
        ssp['fpa']['detection'] = {
            'snr_threshold': 0.0,
            'pixel_error': 0.0,
            'false_alarm_rate': 0.0,
            'max_false': 10,
        }
    else:
        ssp['fpa']['detection'].setdefault('snr_threshold', 0.0)
        ssp['fpa']['detection'].setdefault('pixel_error', 0.0)
        ssp['fpa']['detection'].setdefault('false_alarm_rate', 0.0)
        ssp['fpa']['detection'].setdefault('max_false', 10)

    star_mode = ssp['geometry']['stars']['mode']

    # TODO move defaults to a different file
    if 'velocity_units' in ssp['sim']:
        if ssp['sim']['velocity_units'] == 'arcsec':
            y_to_pix = y_ifov * 3600
            x_to_pix = x_ifov * 3600
    else:
        y_to_pix = 1.0
        x_to_pix = 1.0

    if 'star_render_mode' not in ssp['sim']:
        ssp['sim']['star_render_mode'] = 'transform'

    if 'show_obs_boxes' not in ssp['sim']:
        ssp['sim']['show_obs_boxes'] = True

    if 'show_star_boxes' not in ssp['sim']:
        ssp['sim']['show_star_boxes'] = False

    if 'enable_shot_noise' not in ssp['sim']:
        ssp['sim']['enable_shot_noise'] = True

    if 'num_shot_noise_samples' not in ssp['sim']:
        ssp['sim']['num_shot_noise_samples'] = None

    if 'star_catalog_query_mode' not in ssp['sim']:
        ssp['sim']['star_catalog_query_mode'] = 'frame'

    if 'apply_star_wrap_around' not in ssp['sim']:
        ssp['sim']['apply_star_wrap_around'] = False

    if 'num_target_samples' not in ssp['sim']:
        ssp['sim']['num_target_samples'] = 3

    if 'calculate_snr' not in ssp['sim']:
        ssp['sim']['calculate_snr'] = True

    if 'save_jpeg' not in ssp['sim']:
        ssp['sim']['save_jpeg'] = True

    if 'save_movie' not in ssp['sim']:
        ssp['sim']['save_movie'] = ssp['sim']['save_jpeg']

    if 'save_czml' not in ssp['sim']:
        ssp['sim']['save_czml'] = True

    if 'save_ground_truth' not in ssp['sim']:
        ssp['sim']['save_ground_truth'] = False

    if 'star_annotation_threshold' not in ssp['sim']:
        ssp['sim']['star_annotation_threshold'] = False

    if 'save_segmentation' not in ssp['sim']:
        ssp['sim']['save_segmentation'] = False

    if 'mode' not in ssp['sim']:
        ssp['sim']['mode'] = 'fftconv2p'

    if 'save_pickle' not in ssp['sim']:
        ssp['sim']['save_pickle'] = False

    if 'analytical_obs' not in ssp['sim']:
        ssp['sim']['analytical_obs'] = False

    if 'psf_sample_frequency' not in ssp['sim']:
        ssp['sim']['psf_sample_frequency'] = 'once'

    if star_mode == 'bins':
        star_dn = ssp['geometry']['stars']['mv']['density']
        star_pe = mv_to_pe(zeropoint, ssp['geometry']['stars']['mv']['bins']) * t_exposure
    elif star_mode == 'sstr7' or star_mode == 'csv':
        star_ra = ssp['geometry']['stars']['ra'] if 'ra' in ssp['geometry']['stars'] else 0
        star_dec = ssp['geometry']['stars']['dec'] if 'dec' in ssp['geometry']['stars'] else 0
        star_rot = ssp['geometry']['stars']['rotation'] if 'rotation' in ssp['geometry']['stars'] else 0
        star_path = ssp['geometry']['stars']['path']

    if ssp['geometry']['stars']['motion']['mode'] == 'affine':
        star_rot_rate = ssp['geometry']['stars']['motion']['rotation']
        star_tran_os = [ssp['geometry']['stars']['motion']['translation'][0],ssp['geometry']['stars']['motion']['translation'][1]]
        star_tran_os[0] = star_tran_os[0] / y_to_pix * s_osf
        star_tran_os[1] = star_tran_os[1] / x_to_pix * s_osf
    elif ssp['geometry']['stars']['motion']['mode'] == 'affine-polar':
        star_rot_rate = ssp['geometry']['stars']['motion']['rotation']
        star_vel_angle = ssp['geometry']['stars']['motion']['translation'][0] * math.pi / 180
        star_vel_mag = ssp['geometry']['stars']['motion']['translation'][1]
        star_tran_os = [star_vel_mag * math.sin(star_vel_angle), star_vel_mag * math.cos(star_vel_angle)]
        star_tran_os[0] = star_tran_os[0] / y_to_pix * s_osf
        star_tran_os[1] = star_tran_os[1] / x_to_pix * s_osf
    elif ssp['geometry']['stars']['motion']['mode'] == 'none':
        star_rot_rate = 0.0
        star_tran_os = [0.0, 0.0]

    # todo make this auto
    if 'pad' in ssp['geometry']['stars']:
        star_pad = ssp['geometry']['stars']['pad']
    else:
        star_pad = 1.2
    star_bounds = [[-star_pad * h_fpa_pad_os, (star_pad + 1) * h_fpa_pad_os], [-star_pad * w_fpa_pad_os, (star_pad + 1) * w_fpa_pad_os], [0, 0]]

    obs = ssp['geometry']['obs']['list']

    bg = mv_to_pe(zeropoint, ssp['background']['galactic']) * (y_ifov * 3600 * x_ifov * 3600) * t_exposure

    if 'stray' in ssp['background']:
        if isinstance(ssp['background']['stray'], dict) and 'mode' in ssp['background']['stray'] and ssp['background']['stray']['mode'] == 'none':
            pass  # for backward compat
        else:
            bg = bg + ssp['background']['stray'] * t_exposure

    dc = ssp['fpa']['dark_current'] * t_exposure
    rn = ssp['fpa']['noise']['read']
    en = ssp['fpa']['noise']['electronic']
    gain = ssp['fpa']['gain']
    bias = ssp['fpa']['bias']
    a2d_fwc = ssp['fpa']['a2d']['fwc']
    a2d_gain = ssp['fpa']['a2d']['gain']
    a2d_bias = ssp['fpa']['a2d']['bias']

    if 'dtype' in ssp['fpa']['a2d']:
        a2d_dtype = ssp['fpa']['a2d']['dtype']
    else:
        a2d_dtype = 'uint16'

    astrometrics['bias'] = a2d_bias

    # gen psf
    if ssp['sim']['psf_sample_frequency'] == 'once':
        psf_os = _gen_psf(ssp, h_sub_pad_os, w_sub_pad_os, y_ifov, x_ifov, s_osf)

    set_number = 0
    while num_sets <= 0 or set_number < num_sets:
        set_number = set_number + 1

        # time
        if 'time' in ssp['geometry']:
            tt = ssp['geometry']['time']
        else:
            tt = [2020, 1, 1, 0, 0, 0.0]

        ts_collect_start = time.utc_from_list(tt)
        ts_frame_end = time.utc_from_list(tt, t_exposure)
        ts_collect_end = time.utc_from_list(tt, t_frame * num_frames)

        # site
        if 'site' in ssp['geometry']:

            # note: stars will track horizontally where zenith is pointed up. focal plane rotation is simulated with the `rotation` variable
            star_rot = ssp['geometry']['site']['gimbal']['rotation']
            track_mode = ssp['geometry']['site']['track']['mode']
            lat = ssp['geometry']['site']['lat']
            lon = ssp['geometry']['site']['lon']
            astrometrics['lat'] = float(_ltude(lat, 'latitude', 'N', 'S')) if isinstance(lat, str) else float(lat)
            astrometrics['lon'] = float(_ltude(lon, 'longitude', 'E', 'W')) if isinstance(lon, str) else float(lon)
            astrometrics['alt'] = float(ssp['geometry']['site'].get('alt', 0))
            astrometrics['track_mode'] = _parse_track_mode(track_mode, 0, num_frames)
            observer = create_topocentric(astrometrics['lat'], astrometrics['lon'], astrometrics['alt'])

            if 'tle' in ssp['geometry']['site']['track']:
                track = create_sgp4(ssp['geometry']['site']['track']['tle'][0], ssp['geometry']['site']['track']['tle'][1])
            elif 'tle1' in ssp['geometry']['site']['track']:
                track = create_sgp4(ssp['geometry']['site']['track']['tle1'], ssp['geometry']['site']['track']['tle2'])
            elif 'epoch' in ssp['geometry']['site']['track']:
                track_epoch = time.utc_from_list_or_scalar(ssp['geometry']['site']['track']['epoch'])
                track = create_twobody(np.array(ssp['geometry']['site']['track']['position']) * u.km, np.array(ssp['geometry']['site']['track']['velocity']) * u.km / u.s, track_epoch)
            else:
                track = None

            astrometrics['object'] = pydash.objects.get(ssp, 'geometry.site.track.name', '')
            track_az = pydash.objects.get(ssp, 'geometry.site.track.az', 0)
            track_el = pydash.objects.get(ssp, 'geometry.site.track.el', 0)

            if type(track_az) is not list:
                track_az = [track_az, track_az]

            if type(track_el) is not list:
                track_el = [track_el, track_el]

            # deprecated
            if 'time' in ssp['geometry']['site']:
                logger.warning('geometry.site.time is deprecated. Use geometry.time instead.')
                tt = ssp['geometry']['site']['time']
                ts_collect_start = time.utc_from_list(tt)
                ts_frame_end = time.utc_from_list(tt, t_exposure)
                ts_collect_end = time.utc_from_list(tt, t_frame * num_frames)

            star_ra, star_dec, star_tran_os, star_rot_rate = _calculate_star_position_and_motion(ssp, astrometrics,
                                                                                                 ts_collect_start, ts_collect_end, ts_collect_start, ts_frame_end, t_exposure,
                                                                                                 h_fpa_pad_os, w_fpa_pad_os,
                                                                                                 y_fov_pad, x_fov_pad,
                                                                                                 y_fov, x_fov,
                                                                                                 y_ifov, x_ifov,
                                                                                                 observer, track, star_rot, astrometrics['track_mode'], track_az, track_el)
        else:
            observer = None
            track = None
            star_rot = None
            track_mode = None
            track_az = None
            track_el = None
            ssp['sim']['star_catalog_query_mode'] = 'at_start'

        if ssp['sim']['temporal_osf'] == 'auto':
            if star_mode != 'none':
                rrr, ccc = rotate_and_translate(h_fpa_pad_os, w_fpa_pad_os, [0,h_fpa_pad_os], [0,w_fpa_pad_os], t_exposure, star_rot_rate, star_tran_os)
                rrr -= [0, h_fpa_pad_os]
                ccc -= [0, w_fpa_pad_os]
                t_osf = tf.cast(max([tf.sqrt(rrr[0] * rrr[0] + ccc[0] * ccc[0]), tf.sqrt(rrr[1] * rrr[1] + ccc[1] * ccc[1])]) + 1, tf.int32)
                logger.debug('Auto temporal oversample factor set to {}.'.format(t_osf.numpy()))
            else:
                t_osf = 1

        # gen stars
        if star_mode == 'bins':
            r_stars_os, c_stars_os, pe_stars_os = gen_random_points(h_fpa_pad_os, w_fpa_pad_os, y_fov_pad, x_fov_pad, star_pe, star_dn, pad_mult=star_pad)
            ra_stars = np.array(range(len(r_stars_os)))
            dec_stars = np.zeros(len(r_stars_os))
            m_stars_os = pe_to_mv(zeropoint, pe_stars_os / t_exposure)
        else:
            r_stars_os, c_stars_os, m_stars_os = [], [], []
            pe_stars_os = []

        # gen psf
        if ssp['sim']['psf_sample_frequency'] == 'collect':
            psf_os = _gen_psf(ssp, h_sub_pad_os, w_sub_pad_os, y_ifov, x_ifov, s_osf)

        if pydash.objects.has(ssp, 'augment.background.stray'):
            bg = ssp['augment']['background']['stray'](bg)
            bg = tf.cast(bg, tf.float32)
            bg = tf.where(tf.math.is_nan(bg), tf.zeros_like(bg), bg)

        logger.debug('Exposure time {}.'.format(t_exposure))
        logger.debug('Background pe/pix {}.'.format(np.mean(bg)))
        logger.debug('Finished initializing variables in {} sec.'.format(toc('init')))

        star_uid_map = {}
        obs_cache = [None] * len(obs)
        gain_tf = tf.cast(gain, tf.float32)
        bg_tf = tf.cast(bg, tf.float32)
        dc_tf = tf.cast(dc, tf.float32)
        bias_tf = tf.cast(bias, tf.float32)
        _rn = tf.cast(rn, tf.float32)
        _en = tf.cast(en, tf.float32)
        rn_tf = tf.math.sqrt(_rn * _rn + _en * _en)

        # gen frame and yield
        for frame_num in range(num_frames):
            tic('gen_frame', frame_num)
            logger.debug('Generating frame {} of {}.'.format(frame_num + 1, num_frames))
            astrometrics['frame_num'] = frame_num + 1
            astrometrics['track_mode'] = _parse_track_mode(track_mode, frame_num, num_frames)

            if ssp['sim']['psf_sample_frequency'] == 'frame':
                psf_os = _gen_psf(ssp, h_sub_pad_os, w_sub_pad_os, y_ifov, x_ifov, s_osf)

            t_start = frame_num * t_frame
            t_end = t_start + t_exposure
            ts_start = time.utc_from_list(tt, t_start)
            ts_end = time.utc_from_list(tt, t_end)
            t_start_star = t_start
            t_end_star = t_end
            t_frame_track_start = _parse_start_track_time(track_mode, frame_num, num_frames, ts_collect_start, ts_start)

            # calculate object pixels
            r_obs_os, c_obs_os, pe_obs_os, obs_os_pix, obs_model = _gen_objects(ssp, render_mode,
                                                                                obs, obs_cache,
                                                                                t_frame_track_start, ts_collect_end, t_start, t_end, tt,
                                                                                observer, track, track_az, track_el,
                                                                                zeropoint, s_osf,
                                                                                h_fpa_os, w_fpa_os,
                                                                                h_pad_os, w_pad_os,
                                                                                h_pad_os_div2, w_pad_os_div2,
                                                                                h_fpa_pad_os, w_fpa_pad_os,
                                                                                y_fov, x_fov,
                                                                                y_to_pix, x_to_pix,
                                                                                star_rot, astrometrics['track_mode'])
            logger.debug('Number of objects {}.'.format(len(obs_os_pix)))

            if track_mode is not None:
                star_ra, star_dec, star_tran_os, star_rot_rate = _calculate_star_position_and_motion(ssp, astrometrics,
                                                                                                     t_frame_track_start, ts_collect_end, ts_start, ts_end, t_exposure,
                                                                                                     h_fpa_pad_os, w_fpa_pad_os,
                                                                                                     y_fov_pad, x_fov_pad,
                                                                                                     y_fov, x_fov,
                                                                                                     y_ifov, x_ifov,
                                                                                                     observer, track, star_rot, astrometrics['track_mode'], track_az, track_el)

            # if image rendering is disabled, optionally generate analytical observations and return
            if ssp['sim']['mode'] == 'none':
                if ssp['sim'].get('analytical_obs', False):
                    if 'crop' in ssp['fpa']:
                        cp = ssp['fpa']['crop']
                        for opp in obs_os_pix:
                            opp['rr'] = opp['rr'] - cp['height_offset'] * s_osf
                            opp['cc'] = opp['cc'] - cp['width_offset'] * s_osf
                            opp['rrr'] = opp['rrr'] - cp['height_offset']
                            opp['rcc'] = opp['rcc'] - cp['width_offset']

                    bg_val = float(tf.reduce_mean(bg_tf).numpy()) if hasattr(bg_tf, 'numpy') else float(np.mean(bg_tf))
                    dc_val = float(tf.reduce_mean(dc_tf).numpy()) if hasattr(dc_tf, 'numpy') else float(np.mean(dc_tf))
                    rn_val = float(rn)
                    en_val = float(en)
                    obs_list = analytic_obs.generate(
                        ssp,
                        obs_os_pix,
                        astrometrics,
                        bg_val,
                        dc_val,
                        rn_val,
                        en_val,
                    )
                    analytical.save(output_dir, frame_num, obs_list)
                if with_meta:
                    yield None, frame_num, astrometrics.copy(), obs_os_pix, None, None, None, None, None, None, obs_cache, None, None, None
                else:
                    yield None
                continue

            # refresh catalog stars
            if (star_mode == 'sstr7' or star_mode == 'csv') and (ssp['sim']['star_catalog_query_mode'] == 'frame' or frame_num == 0):
                if star_mode == 'sstr7':
                    # note star_ra and star_dec are apparent positions which includes stellar aberration
                    r_stars_os, c_stars_os, m_stars_os, ra_stars, dec_stars = query_by_los(h_fpa_pad_os, w_fpa_pad_os, y_fov_pad, x_fov_pad, star_ra, star_dec, rot=star_rot, rootPath=star_path, pad_mult=star_pad, flipud=ssp['fpa']['flip_up_down'], fliplr=ssp['fpa']['flip_left_right'])
                elif star_mode == 'csv':
                    r_stars_os, c_stars_os, m_stars_os, ra_stars, dec_stars = csvsc_query_by_los(h_fpa_pad_os, w_fpa_pad_os, y_fov_pad, x_fov_pad, star_ra, star_dec, rot=star_rot, rootPath=star_path, flipud=ssp['fpa']['flip_up_down'], fliplr=ssp['fpa']['flip_left_right'])

                pe_stars_os = mv_to_pe(zeropoint, m_stars_os) * t_exposure
                t_start_star = 0.0
                t_end_star = t_exposure
                if ssp['sim']['star_catalog_query_mode'] == 'frame':
                    ssp['sim']['apply_star_wrap_around'] = False

            # wrap stars around
            if ssp['sim']['apply_star_wrap_around']:
                r_stars_os, c_stars_os, star_bounds = apply_wrap_around(h_fpa_pad_os, w_fpa_pad_os, r_stars_os, c_stars_os, t_start, t_end, star_rot_rate, star_tran_os, star_bounds)

            logger.debug('Number of stars {}.'.format(len(r_stars_os)))

            t_start_star = tf.cast(t_start_star, tf.float32)
            t_end_star = tf.cast(t_end_star, tf.float32)
            r_stars_os = tf.cast(r_stars_os, tf.float32)
            c_stars_os = tf.cast(c_stars_os, tf.float32)
            pe_stars_os = tf.cast(pe_stars_os, tf.float32)

            r_obs_os = tf.cast(r_obs_os, tf.float32)
            c_obs_os = tf.cast(c_obs_os, tf.float32)
            pe_obs_os = tf.cast(pe_obs_os, tf.float32)

            # augment TODO abstract this
            if pydash.objects.has(ssp, 'augment.fpa.psf'):
                psf_os_curr = ssp['augment']['fpa']['psf'](psf_os)
                psf_os_curr = tf.cast(psf_os_curr, tf.float32)
            else:
                psf_os_curr = psf_os

            # helper function for image rendering and segmentation rendering
            def _render(r_obs_os, c_obs_os, pe_obs_os, r_stars_os, c_stars_os, pe_stars_os, render_separate):
                if render_mode == 'piecewise':
                    return render_piecewise(h, w, h_sub, w_sub, h_pad_os, w_pad_os, s_osf, psf_os_curr, r_obs_os, c_obs_os, pe_obs_os, r_stars_os, c_stars_os, pe_stars_os, t_start_star, t_end_star, t_osf, star_rot_rate, star_tran_os, render_separate=render_separate, star_render_mode=ssp['sim']['star_render_mode'])
                else:
                    return render_full(h_fpa_os, w_fpa_os, h_fpa_pad_os, w_fpa_pad_os, h_pad_os_div2, w_pad_os_div2, s_osf, psf_os_curr, r_obs_os, c_obs_os, pe_obs_os, r_stars_os, c_stars_os, pe_stars_os, t_start_star, t_end_star, t_osf, star_rot_rate, star_tran_os, render_separate=render_separate, obs_model=obs_model, star_render_mode=ssp['sim']['star_render_mode'])

            # render
            fpa_conv_star, fpa_conv_targ, fpa_os_w_targets, fpa_conv_os, fpa_conv_crop = _render(r_obs_os, c_obs_os, pe_obs_os, r_stars_os, c_stars_os, pe_stars_os, ssp['sim']['calculate_snr'])

            # add noise
            fpa_conv = (fpa_conv_star + fpa_conv_targ + bg_tf) * gain_tf + dc_tf
            if ssp['sim']['enable_shot_noise'] is True:
                fpa_conv_noise = add_photon_noise(fpa_conv, ssp['sim']['num_shot_noise_samples'])
            else:
                fpa_conv_noise = fpa_conv
            fpa, rn_gt = add_read_noise(fpa_conv_noise, rn, en)

            # analog to digital
            fpa_digital = analog_to_digital(fpa + bias_tf, a2d_gain, a2d_fwc, a2d_bias, dtype=a2d_dtype)

            # augment TODO abstract this
            if pydash.objects.has(ssp, 'augment.image'):
                if ssp['augment']['image']['post'] is None:
                    pass
                elif callable(ssp['augment']['image']['post']):
                    fpa_digital = ssp['augment']['image']['post'](fpa_digital)
                else:
                    fpa_digital = fpa_digital + ssp['augment']['image']['post']

            # save segmentation matadata
            segmentation = None
            seg_id_stars = None
            if ssp['sim']['save_segmentation']:

                star_threshold = 50.0
                if ssp['sim']['star_annotation_threshold'] is not False:
                    star_threshold = ssp['sim']['star_annotation_threshold']

                if 'crop' in ssp['fpa']:
                    cp = ssp['fpa']['crop']
                    segmentation = {
                        'star_segmentation': np.zeros((cp['height'], cp['width']), dtype=np.uint16),
                        'object_segmentation': np.zeros((cp['height'], cp['width']), dtype=np.uint16)
                    }
                else:
                    segmentation = {
                        'star_segmentation': np.zeros((h, w), dtype=np.uint16),
                        'object_segmentation': np.zeros((h, w), dtype=np.uint16)
                    }
                seg_id_stars = np.zeros(len(r_stars_os))
                min_dn = 1

                # sort stars by brightness to ensure bright stars are rendered last
                star_order = tf.argsort(pe_stars_os, direction='ASCENDING')
                r_stars_os_sort = tf.gather(r_stars_os, star_order)
                c_stars_os_sort = tf.gather(c_stars_os, star_order)
                pe_stars_os_sort = tf.gather(pe_stars_os, star_order)
                m_stars_os_sort = tf.gather(m_stars_os, star_order)
                ra_stars_sort = tf.gather(ra_stars, star_order)
                dec_stars_sort = tf.gather(dec_stars, star_order)

                # for each bright star, create a segmentation mask
                for i in range(len(ra_stars_sort)):
                    if m_stars_os_sort[i] < star_threshold:
                        fpa_segmentation, _, _, _, _ = _render([], [], [], [r_stars_os_sort[i]], [c_stars_os_sort[i]], [pe_stars_os_sort[i]], False)

                        if 'crop' in ssp['fpa']:
                            cp = ssp['fpa']['crop']
                            fpa_segmentation = crop(fpa_segmentation, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])

                        fpa_segmentation = analog_to_digital(fpa_segmentation, a2d_gain, a2d_fwc, 0, dtype=a2d_dtype)
                        fpa_segmentation = fpa_segmentation.numpy()
                        if np.max(fpa_segmentation.flatten()) > min_dn:
                            # need to assign a unique id to each star and keep track of it as they could pop in and out of the frame
                            star_uid = '{}, {}, {}'.format(ra_stars_sort[i], dec_stars_sort[i], m_stars_os_sort[i])
                            if star_uid in star_uid_map:
                                star_id = star_uid_map[star_uid]
                            else:
                                star_id = len(star_uid_map) + 1
                                star_uid_map[star_uid] = star_id

                            segmentation['star_segmentation'][fpa_segmentation > min_dn] = star_id
                            seg_id_stars[star_order[i]] = star_id
                            logger.debug('Generated star segmentation {}, {} mv.'.format(star_id, m_stars_os_sort[i]))

                # for each observation, create a segmentation mask
                obs_os_pix_sorted = sorted(obs_os_pix, key=lambda k: k['pe'], reverse=False)
                for ob in obs_os_pix_sorted:
                    fpa_segmentation, _, _, _, _ = _render(ob['rr'], ob['cc'], ob['pp'], [], [], [], False)
                    if 'crop' in ssp['fpa']:
                        cp = ssp['fpa']['crop']
                        fpa_segmentation = crop(fpa_segmentation, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])

                    fpa_segmentation = analog_to_digital(fpa_segmentation, a2d_gain, a2d_fwc, 0, dtype=a2d_dtype)
                    fpa_segmentation = fpa_segmentation.numpy()
                    if np.max(fpa_segmentation.flatten()) > min_dn:
                        segmentation['object_segmentation'][fpa_segmentation > min_dn] = ob['id']
                        logger.debug('Generated object segmentation {}, {} mv.'.format(ob['id'], ob['mv']))

            # cropped sensor
            crop_bg_tf = bg_tf
            crop_dc_tf = dc_tf
            crop_rn_tf = rn_tf
            crop_h_fpa_os = h_fpa_os
            crop_w_fpa_os = w_fpa_os
            crop_gain_tf = gain_tf
            crop_bias_tf = bias_tf
            if 'crop' in ssp['fpa']:
                cp = ssp['fpa']['crop']

                # crop images
                fpa_digital = crop(fpa_digital, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])
                fpa_conv_targ = crop(fpa_conv_targ, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])
                fpa_conv_star = crop(fpa_conv_star, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])
                if len(bg_tf.shape) == 2:
                    crop_bg_tf = crop(bg_tf, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])
                if len(dc_tf.shape) == 2:
                    crop_dc_tf = crop(dc_tf, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])
                if len(rn_tf.shape) == 2:
                    crop_rn_tf = crop(rn_tf, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])
                fpa_conv_noise = crop(fpa_conv_noise, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])
                fpa_conv = crop(fpa_conv, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])
                rn_gt = crop(rn_gt, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])
                if len(gain_tf.shape) == 2:
                    crop_gain_tf = crop(gain_tf, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])
                if len(bias_tf.shape) == 2:
                    crop_bias_tf = crop(bias_tf, cp['height_offset'], cp['width_offset'], cp['height'], cp['width'])

                # update star positions
                r_stars_os = r_stars_os - cp['height_offset'] * s_osf
                c_stars_os = c_stars_os - cp['width_offset'] * s_osf
                crop_h_fpa_os = cp['height'] * s_osf
                crop_w_fpa_os = cp['width'] * s_osf

                # update target positions
                for opp in obs_os_pix:
                    opp['rr'] = opp['rr'] - cp['height_offset'] * s_osf
                    opp['cc'] = opp['cc'] - cp['width_offset'] * s_osf
                    opp['rrr'] = opp['rrr'] - cp['height_offset']
                    opp['rcc'] = opp['rcc'] - cp['width_offset']

            if ssp['sim']['star_annotation_threshold'] is not False:
                pes_stars_os = pe_stars_os / t_exposure
                star_os_pix = {
                    'h': crop_h_fpa_os,
                    'w': crop_w_fpa_os,
                    'h_pad': h_pad_os_div2,
                    'w_pad': w_pad_os_div2,
                    'rr': r_stars_os,
                    'cc': c_stars_os,
                    'pe': pes_stars_os,
                    'mv': m_stars_os if m_stars_os is not None else pe_to_mv(zeropoint, pes_stars_os),
                    'ra': ra_stars,
                    'dec': dec_stars,
                    'seg_id': seg_id_stars,
                    't_start': t_start_star,
                    't_end': t_end_star,
                    'rot': star_rot_rate,
                    'tran': star_tran_os,
                    'min_mv': ssp['sim']['star_annotation_threshold'] if isinstance(ssp['sim']['star_annotation_threshold'], numbers.Number) else 15
                }
            else:
                star_os_pix = None

            if ssp['sim']['save_ground_truth']:
                ground_truth = OrderedDict()
                ground_truth['target_pe'] = fpa_conv_targ.numpy()
                ground_truth['star_pe'] = fpa_conv_star.numpy()
                ground_truth['background_pe'] = crop_bg_tf.numpy()
                ground_truth['dark_current_pe'] = crop_dc_tf.numpy()
                ground_truth['photon_noise_pe'] = (fpa_conv_noise - fpa_conv).numpy()
                ground_truth['read_noise_pe'] = rn_gt.numpy()
                ground_truth['gain'] = crop_gain_tf.numpy()
                ground_truth['bias_pe'] = crop_bias_tf.numpy()
            else:
                ground_truth = None

            if ssp['sim'].get('analytical_obs', False):
                bg_val = float(tf.reduce_mean(crop_bg_tf).numpy()) if hasattr(crop_bg_tf, 'numpy') else float(np.mean(crop_bg_tf))
                dc_val = float(tf.reduce_mean(crop_dc_tf).numpy()) if hasattr(crop_dc_tf, 'numpy') else float(np.mean(crop_dc_tf))
                rn_val = float(rn)
                en_val = float(en)

                obs_list = analytic_obs.generate(
                    ssp,
                    obs_os_pix,
                    astrometrics,
                    bg_val,
                    dc_val,
                    rn_val,
                    en_val,
                )
                analytical.save(output_dir, frame_num, obs_list)

            if output_debug:
                if fpa_os_w_targets is not None:
                    with open(os.path.join(dir_debug, 'fpa_os_{}.pickle'.format(frame_num)), 'wb') as picklefile:
                        pickle.dump(fpa_os_w_targets.numpy(), picklefile)
                if fpa_conv_os is not None:
                    with open(os.path.join(dir_debug, 'fpa_conv_os_{}.pickle'.format(frame_num)), 'wb') as picklefile:
                        pickle.dump(fpa_conv_os.numpy(), picklefile)
                if fpa_conv_crop is not None:
                    with open(os.path.join(dir_debug, 'fpa_conv_crop_{}.pickle'.format(frame_num)), 'wb') as picklefile:
                        pickle.dump(fpa_conv_crop.numpy(), picklefile)
                with open(os.path.join(dir_debug, 'fpa_conv_star_{}.pickle'.format(frame_num)), 'wb') as picklefile:
                    pickle.dump(fpa_conv_star.numpy(), picklefile)
                with open(os.path.join(dir_debug, 'fpa_conv_targ_{}.pickle'.format(frame_num)), 'wb') as picklefile:
                    pickle.dump(fpa_conv_targ.numpy(), picklefile)
                with open(os.path.join(dir_debug, 'fpa_conv_{}.pickle'.format(frame_num)), 'wb') as picklefile:
                    pickle.dump(fpa_conv.numpy(), picklefile)
                with open(os.path.join(dir_debug, 'fpa_conv_noise_{}.pickle'.format(frame_num)), 'wb') as picklefile:
                    pickle.dump(fpa_conv_noise.numpy(), picklefile)
                with open(os.path.join(dir_debug, 'fpa_{}.pickle'.format(frame_num)), 'wb') as picklefile:
                    pickle.dump(fpa.numpy(), picklefile)
                with open(os.path.join(dir_debug, 'fpa_digital_{}.pickle'.format(frame_num)), 'wb') as picklefile:
                    pickle.dump(fpa_digital.numpy(), picklefile)
                with open(os.path.join(dir_debug, 'stars_os_{}.pickle'.format(frame_num)), 'wb') as picklefile:
                    pickle.dump([r_stars_os.numpy(), c_stars_os.numpy(), pe_stars_os.numpy(), t_start_star, t_end_star, t_osf, star_rot_rate, star_tran_os], picklefile)

            if with_meta:
                yield fpa_digital, frame_num, astrometrics.copy(), obs_os_pix, fpa_conv_star, fpa_conv_targ, crop_bg_tf, crop_dc_tf, crop_rn_tf, ssp['sim']['num_shot_noise_samples'], obs_cache, ground_truth, star_os_pix, segmentation
            else:
                yield fpa_digital


def _gen_psf(ssp, height, width, y_ifov, x_ifov, s_osf):
    """Generate the point spread function (PSF) for the focal plane array (FPA). """
    psf_os = None
    if ssp['sim']['mode'] != 'none':
        if not isinstance(ssp['fpa']['psf'], dict):  # loaded from config
            psf_os = ssp['fpa']['psf']
            psf_os = tf.cast(psf_os, tf.float32)
        elif ssp['fpa']['psf']['mode'] == 'gaussian':
            logger.debug('Generating {} PSF.'.format(ssp['fpa']['psf']['mode']))
            eod = ssp['fpa']['psf']['eod']
            sigma = eod_to_sigma(eod, s_osf)
            psf_os = gen_gaussian(height, width, sigma)
            save_cache(ssp['fpa']['psf'], psf_os)
        elif ssp['fpa']['psf']['mode'] == 'poppy':
            logger.debug('Generating {} PSF.'.format(ssp['fpa']['psf']['mode']))
            psf_os = gen_from_poppy_configuration(height / s_osf, width / s_osf, y_ifov, x_ifov, s_osf, ssp['fpa']['psf'])
            save_cache(ssp['fpa']['psf'], psf_os)
            psf_os = tf.cast(psf_os, tf.float32)

    return psf_os


def _gen_objects(ssp, render_mode,
                 obs, obs_cache,
                 tc_start, tc_end, t_start, t_end, tt,
                 observer, track, track_az, track_el,
                 zeropoint, s_osf,
                 h_fpa_os, w_fpa_os,
                 h_pad_os, w_pad_os,
                 h_pad_os_div2, w_pad_os_div2,
                 h_fpa_pad_os, w_fpa_pad_os,
                 y_fov, x_fov,
                 y_to_pix, x_to_pix,
                 star_rot, track_mode):
    """Generate object pixels. TODO move to submodule
    """
    obs_model = []
    obs_os_pix = []
    orrr = np.array([], dtype=np.int32)
    occc = np.array([], dtype=np.int32)
    oppp = np.array([], dtype=np.float32)
    ts_start = time.utc_from_list(tt, t_start)
    ts_mid = time.utc_from_list(tt, t_start + 0.5 * (t_end - t_start))
    ts_end = time.utc_from_list(tt, t_end)

    for i, o in enumerate(obs):

        # TODO support in frame events
        updated = False
        target = None
        if 'events' in o:
            if 'create' in o['events']:
                ts_start_ob = time.utc_from_list_or_scalar(o['events']['create'], default_t=tt)
                if ts_end.tt <= ts_start_ob.tt:
                    continue
            if 'delete' in o['events']:
                ts_end_ob = time.utc_from_list_or_scalar(o['events']['delete'], default_t=tt)
                if ts_end.tt >= ts_end_ob.tt:
                    continue
            if 'update' in o['events']:
                for eu in o['events']['update']:
                    ts_start_ob = time.utc_from_list_or_scalar(eu['time'], default_t=tt)
                    if ts_end.tt >= ts_start_ob.tt:
                        merge_dicts(o, eu['values'])
                        updated = True

        if 'mv' in o:
            ope = mv_to_pe(zeropoint, o['mv']) if not callable(o['mv']) else 0.0
            pe_func = (lambda x, t: x) if not callable(o['mv']) else (lambda x, t: mv_to_pe(zeropoint, o['mv'](x, t)) * _delta_t(t))
        elif 'pe' in o:
            ope = o['pe'] if not callable(o['pe']) else 0.0
            pe_func = (lambda x, t: x) if not callable(o['pe']) else (lambda x, t: o['pe'](x, t) * _delta_t(t))
        else:
            ope = 0.0
            pe_func = (lambda x, t: x)

        if o['mode'] == 'line':
            ovrc = [o['velocity'][0] / y_to_pix * s_osf, o['velocity'][1] / x_to_pix * s_osf]
            epoch = o['epoch'] if 'epoch' in o else 0
            (orr, occ, opp, ott) = gen_line(h_fpa_os, w_fpa_os, o['origin'], ovrc, ope, t_start + epoch, t_end + epoch)
        elif o['mode'] == 'line-polar':
            o_vel_angle = o['velocity'][0] * math.pi / 180
            ovrc = [o['velocity'][1] * math.sin(o_vel_angle), o['velocity'][1] * math.cos(o_vel_angle)]
            ovrc = [ovrc[0] / y_to_pix * s_osf, ovrc[1] / x_to_pix * s_osf]
            epoch = o['epoch'] if 'epoch' in o else 0
            (orr, occ, opp, ott) = gen_line(h_fpa_os, w_fpa_os, o['origin'], ovrc, ope, t_start + epoch, t_end + epoch)
        elif o['mode'] == 'tle' or o['mode'] == 'twobody' or o['mode'] == 'gc' or o['mode'] == 'ephemeris' or o['mode'] == 'observation':
            if obs_cache[i] is None or updated:
                if o['mode'] == 'tle':
                    if 'tle' in o:
                        obs_cache[i] = [create_sgp4(o['tle'][0], o['tle'][1])]
                        if 'id' not in o:
                            o['id'] = o['tle'][0][2:7]
                    else:
                        obs_cache[i] = [create_sgp4(o['tle1'], o['tle2'])]
                        if 'id' not in o:
                            o['id'] = o['tle1'][2:7]

                elif o['mode'] == 'gc':
                    ts_epoch = time.utc_from_list_or_scalar(o['epoch'], default_t=tt)
                    if 'az' in o:
                        obs_cache[i] = [GreatCircle(o['az'], o['el'], o['heading'], o['velocity'], ts_epoch, observer)]
                    else:
                        obs_cache[i] = [GreatCircle(o['ra'], o['dec'], o['heading'], o['velocity'], ts_epoch, None)]
                elif o['mode'] == 'ephemeris':
                    ts_epoch = time.utc_from_list(o['epoch'])
                    obs_cache[i] = [create_ephemeris_object(o['positions'], o['velocities'], o['seconds_from_epoch'], ts_epoch)]
                elif o['mode'] == 'observation':
                    obs_cache[i] = [create_observation(o['ra'], o['dec'], time.utc_from_list(o['time']), observer, track, o.get('range', None))]
                else:
                    ts_epoch = time.utc_from_list_or_scalar(o['epoch'], default_t=tt)
                    obs_cache[i] = [create_twobody(np.array(o['position']) * u.km, np.array(o['velocity']) * u.km / u.s, ts_epoch)]

            o_offset = [0.0, 0.0]
            if 'offset' in o:
                o_offset = [o['offset'][0] * h_fpa_os, o['offset'][1] * w_fpa_os]

            target = obs_cache[i][0]
            az, el = _calculate_az_el(tc_start, tc_end, [ts_start, ts_mid, ts_end], track_az, track_el)

            try:
                [rr0, rr1, rr2], [cc0, cc1, cc2], _, _, _ = gen_track(
                    h_fpa_os,
                    w_fpa_os,
                    y_fov,
                    x_fov,
                    observer,
                    track,
                    [target],
                    [ope],
                    tc_start,
                    [ts_start, ts_mid, ts_end],
                    star_rot,
                    1,
                    track_mode,
                    offset=o_offset,
                    flipud=ssp['fpa']['flip_up_down'],
                    fliplr=ssp['fpa']['flip_left_right'],
                    az=az,
                    el=el,
                )
            except Exception:
                logger.exception("Error propagating target. {}".format(o))
                continue

            if len(rr0) > 0 and not math.isnan(rr0[0]) and not math.isnan(cc0[0]) and not math.isnan(rr2[0]) and not math.isnan(cc2[0]):

                if rr0[0] < -h_pad_os and rr2[0] < -h_pad_os:
                    continue
                elif rr0[0] > h_fpa_pad_os and rr2[0] > h_fpa_pad_os:
                    continue
                elif cc0[0] < -w_pad_os and cc2[0] < -w_pad_os:
                    continue
                elif cc0[0] > w_fpa_pad_os and cc2[0] > w_fpa_pad_os:
                    continue

                if ssp['sim']['num_target_samples'] == 2:
                    (orr, occ, opp, ott) = gen_line_from_endpoints(rr0[0], cc0[0], rr2[0], cc2[0], ope, t_start, t_end)
                else:
                    (orr, occ, opp, ott) = gen_curve_from_points(rr0[0], cc0[0], rr1[0], cc1[0], rr2[0], cc2[0], ope, t_start, t_end)
            else:
                continue
        elif o['mode'] == 'none':
            continue

        object_name = o.get('name', '')
        object_id = o.get('id', '')

        # non-sprite based brightness models
        has_brightness_model = 'model' in o and not callable(o['model']) and 'mode' in o['model'] and o['model']['mode'] != 'sprite'
        if has_brightness_model:
            pe_func = (lambda x, t: mv_to_pe(zeropoint, model_to_mv(observer, target, o['model'], time.utc_from_list(tt, _avg_t(t)))) * _delta_t(t))

        opp = pe_func(opp, ott)
        avg_pe = np.sum(opp) / (t_end - t_start)
        avg_mv = pe_to_mv(zeropoint, avg_pe)

        logger.debug('Average brightness for target: {:.2f} mv, {:.2f} pix.'.format(avg_mv, len(opp) / s_osf))

        # TODO generalize `model`
        # sprint based brightness models
        if 'model' in o and not has_brightness_model:
            if render_mode != 'piecewise':
                if callable(o['model']):
                    fpa_os_w_targets = tf.zeros([h_fpa_pad_os, w_fpa_pad_os], tf.float32)
                    patch = o['model'](x=None, t=ott[0])
                    fpa_os_w_targets = add_patch(fpa_os_w_targets, orr, occ, opp, patch, h_pad_os_div2, w_pad_os_div2)
                    obs_model.append(fpa_os_w_targets)
                    # TODO support sub-sample patching
                elif o['model']['mode'] == 'sprite':
                    patch = load_sprite_from_file(filename=o['model']['filename']) if 'mode' in o['model'] else o['model']
                    fpa_os_clear = tf.zeros([h_fpa_pad_os, w_fpa_pad_os], tf.float32)
                    fpa_os_w_targets = add_patch(fpa_os_clear, orr, occ, opp, patch, h_pad_os_div2, w_pad_os_div2)
                    obs_model.append(fpa_os_w_targets)
            else:
                logger.warning('Sprite models not supported for piecewise rendering.')
        else:
            orrr = np.concatenate((orrr, orr))
            occc = np.concatenate((occc, occ))
            oppp = np.concatenate((oppp, opp))

        if obs_cache[i] is not None:
            ra_mid, dec_mid, _, _, _, _ = get_los(
                observer,
                target,
                ts_mid,
                deflection=False,
                aberration=True,
                stellar_aberration=False,
            )
            ra_true, dec_true, _, _, _, _ = get_los(
                observer,
                target,
                ts_mid,
                deflection=False,
                aberration=False,
                stellar_aberration=False,
            )
            ra_mid = float(ra_mid)
            dec_mid = float(dec_mid)
            ra_true = float(ra_true)
            dec_true = float(dec_true)
        else:
            ra_mid = None
            dec_mid = None
            ra_true = None
            dec_true = None

        entry = {
            'rr': orr,
            'cc': occ,
            'pp': opp,
            'rrr': orr / s_osf,
            'rcc': occ / s_osf,
            'mv': avg_mv,
            'pe': avg_pe,
            'id': i + 1,  # 0 is reserved for the background for segmentation
            'object_name': object_name,
            'object_id': object_id,
        }
        if ra_mid is not None and dec_mid is not None:
            entry['ra_obs'] = ra_mid
            entry['dec_obs'] = dec_mid
            entry['ra'] = ra_true
            entry['dec'] = dec_true

        obs_os_pix.append(entry)

    return orrr, occc, oppp, obs_os_pix, obs_model


def _calculate_az_el(tc_start, tc_end, t_curr, track_az, track_el):
    tt = [t.tt for t in t_curr]
    az = interp_degrees(tt, tc_start.tt, tc_end.tt, track_az[0], track_az[1])
    el = interp_degrees(tt, tc_start.tt, tc_end.tt, track_el[0], track_el[1], normalize_360=False)

    return az, el


def _calculate_star_position_and_motion(ssp, astrometrics,
                                        ts_collect_start, ts_collect_end, t_start, t_end, t_exposure,
                                        h_fpa_pad_os, w_fpa_pad_os,
                                        y_fov_pad, x_fov_pad,
                                        y_fov, x_fov,
                                        y_ifov, x_ifov,
                                        observer, track, star_rot, track_mode, track_az, track_el):
    az, el = _calculate_az_el(ts_collect_start, ts_collect_end, [t_start, t_end], track_az, track_el)
    if track_mode == 'rate':
        track_target = [track]
        star_ra0, star_dec0, _, _, _, _ = get_los(observer, track, t_start, deflection=False, aberration=True, stellar_aberration=True)
        star_ra1, star_dec1, _, _, _, _ = get_los(observer, track, t_end, deflection=False, aberration=True, stellar_aberration=True)
        ra0, dec0, dis0, az0, el0, los0 = get_los(observer, track, t_start, deflection=False, aberration=True, stellar_aberration=False)
        ra1, dec1, dis1, az1, el1, los1 = get_los(observer, track, t_end, deflection=False, aberration=True, stellar_aberration=False)
    elif track_mode == 'fixed':
        track_target = []
        star_ra0, star_dec0, _, _, _, _ = get_los_azel(observer, az[0], el[0], t_start, deflection=False, aberration=True, stellar_aberration=True)
        star_ra1, star_dec1, _, _, _, _ = get_los_azel(observer, az[1], el[1], t_end, deflection=False, aberration=True, stellar_aberration=True)
        ra0, dec0, dis0, az0, el0, los0 = get_los_azel(observer, az[0], el[0], t_start, deflection=False, aberration=True, stellar_aberration=False)
        ra1, dec1, dis1, az1, el1, los1 = get_los_azel(observer, az[1], el[1], t_end, deflection=False, aberration=True, stellar_aberration=False)
    elif track_mode == 'sidereal':
        track_target = [track]
        star_ra0, star_dec0, _, _, _, _ = get_los(observer, track, ts_collect_start, deflection=False, aberration=True, stellar_aberration=True)
        star_ra1, star_dec1, _, _, _, _ = get_los(observer, track, ts_collect_start, deflection=False, aberration=True, stellar_aberration=True)
        ra0, dec0, dis0, az0, el0, los0 = get_los(observer, track, ts_collect_start, deflection=False, aberration=True, stellar_aberration=False)
        ra1, dec1, dis1, az1, el1, los1 = get_los(observer, track, ts_collect_start, deflection=False, aberration=True, stellar_aberration=False)
    else:
        logger.error('Unknown track mode: {}.'.format(track_mode))

    [rr0, rr1], [cc0, cc1], drr, dcc, _ = gen_track(h_fpa_pad_os, w_fpa_pad_os, y_fov_pad, x_fov_pad, observer, track, track_target, [0], ts_collect_start, [t_start, t_end], star_rot, 1, track_mode, flipud=ssp['fpa']['flip_up_down'], fliplr=ssp['fpa']['flip_left_right'], az=az, el=el)
    star_tran_os = [-drr, -dcc]  # stars move in the opposite direction of target
    star_rot_rate = 0  # TODO

    astrometrics['ra'] = mean_degrees(ra0, ra1)
    astrometrics['dec'] = (dec0 + dec1) / 2
    astrometrics['ra_apparent'] = mean_degrees(star_ra0, star_ra1)
    astrometrics['dec_apparent'] = (star_dec0 + star_dec1) / 2
    astrometrics['range'] = (dis0 + dis1) / 2
    astrometrics['roll'] = star_rot
    astrometrics['ra_rate'] = diff_degrees(star_ra1, star_ra0) / t_exposure * 3600
    astrometrics['dec_rate'] = (star_dec1 - star_dec0) / t_exposure * 3600
    astrometrics['az'] = mean_degrees(az0, az1)
    astrometrics['el'] = (el0 + el1) / 2
    astrometrics['time'] = time.mid(t_start, t_end).utc_datetime()
    astrometrics['x_ifov'] = x_ifov
    astrometrics['y_ifov'] = y_ifov
    astrometrics['x_fov'] = x_fov
    astrometrics['y_fov'] = y_fov

    logger.debug('Boresight RA, Dec, Roll, Az, El: {}, {}, {}, {}, {}.'.format(astrometrics['ra'], astrometrics['dec'], astrometrics['roll'], astrometrics['az'], astrometrics['el']))

    return star_ra0, star_dec0, star_tran_os, star_rot_rate


def _parse_track_mode(track_mode, frame_num, total_frames):
    if track_mode is not None and track_mode == 'rate-sidereal':
        if frame_num < total_frames - 1:  # TODO make this more general, only supports last frame
            return 'rate'
        else:
            return 'sidereal'

    return track_mode


def _parse_start_track_time(track_mode, frame_num, total_frames, ts_collect_start, ts_start):
    if track_mode is not None and track_mode == 'rate-sidereal':
        if frame_num < total_frames - 1:  # TODO make this more general, only supports last frame
            return ts_collect_start
        else:
            return ts_start
    return ts_collect_start
