Exemplo n.º 1
0
def main():
    """Repeatedly prints coordinates of camera frame center found by plate solving."""

    parser = track.ArgParser()
    parser.add_argument('--skip-solve',
                        help='skip plate solving',
                        action='store_true')
    cameras.add_program_arguments(parser, profile='align')
    args = parser.parse_args()

    camera = cameras.make_camera_from_args(args, profile='align')

    cv2.namedWindow('camera', cv2.WINDOW_NORMAL)
    cv2.resizeWindow('camera', 640, 480)

    frame_count = 0
    while True:
        print(f'Frame {frame_count:06d} at {datetime.now()}...',
              end='',
              flush=True)
        frame_count += 1
        frame = camera.get_frame()
        cv2.imshow('camera', frame)
        cv2.waitKey(10)

        if args.skip_solve:
            continue

        try:
            start_time = time.time()
            _, sc = track.plate_solve(frame,
                                      camera_width=camera.field_of_view[1])
            elapsed = time.time() - start_time
        except track.NoSolutionException:
            print('No solution found')
            continue

        print(
            f'Found solution at ({sc.to_string("decimal")}) in {elapsed} seconds'
        )
Exemplo n.º 2
0
def main():
    """See module docstring"""

    parser = track.ArgParser()
    parser.add_argument('--lat',
                        required=True,
                        help='latitude of observer (+N)')
    parser.add_argument('--lon',
                        required=True,
                        help='longitude of observer (+E)')
    parser.add_argument('--elevation',
                        required=False,
                        default=0.0,
                        help='elevation of observer (m)',
                        type=float)
    args = parser.parse_args()

    # Grab the latest space station TLE file from Celestrak
    stations = requests.get(
        'http://celestrak.com/NORAD/elements/stations.txt').text.splitlines()

    # Top entry in the station.txt file should be for ISS
    iss = ephem.readtle(str(stations[0]), str(stations[1]), str(stations[2]))

    # Create PyEphem Observer object with location information
    home = ephem.Observer()
    home.lon = args.lon
    home.lat = args.lat
    home.elevation = args.elevation

    # Print current Az-Alt of ISS once per second
    while True:
        home.date = ephem.now()
        iss.compute(home)
        print('Az: %s Alt: %s' % (iss.az, iss.alt))
        time.sleep(1)
Exemplo n.º 3
0
def main():
    """See module docstring"""
    def gamepad_callback(_tracker: Tracker) -> bool:
        """Callback for gamepad control.

        Allows manual control of the slew rate via a gamepad when the 'B' button is held down,
        overriding tracking behavior. This callback is registered with the Tracker object which
        calls it on each control cycle.

        Defined inside main() so this has easy access to objects that are within that scope.

        Args:
            _tracker: A reference to an object of type Tracker. Not used internally.

        Returns:
            True when the 'B' button is depressed which disables the normal control path. False
                otherwise, which leaves the normal control path enabled.
        """
        if game_pad.state.get('BTN_EAST', 0) == 1:
            gamepad_x, gamepad_y = game_pad.get_proportional()
            slew_rates = (mount.max_slew_rate * gamepad_x,
                          mount.max_slew_rate * gamepad_y)
            for idx, axis_name in enumerate(mount.AxisName):
                mount.slew(axis_name, slew_rates[idx])
            return True
        else:
            return False

    parser = track.ArgParser()
    targets.add_program_arguments(parser)
    laser.add_program_arguments(parser)
    mounts.add_program_arguments(parser, meridian_side_required=True)
    ntp.add_program_arguments(parser)
    telem.add_program_arguments(parser)
    control.add_program_arguments(parser)
    args = parser.parse_args()

    # Set priority of this thread to realtime. Do this before constructing objects since priority
    # is inherited and some critical threads are created by libraries we have no direct control
    # over.
    os.sched_setscheduler(0, os.SCHED_RR, os.sched_param(11))

    # Check if system clock is synchronized to GPS
    if args.check_time_sync:
        try:
            ntp.check_ntp_status()
        except ntp.NTPCheckFailure as e:
            print('NTP check failed: ' + str(e))
            if not click.confirm('Continue anyway?', default=True):
                print('Aborting')
                sys.exit(2)

    # Load a MountModel object
    try:
        mount_model = track.model.load_stored_model()
    except track.model.StaleParametersException:
        if click.confirm('Stored alignment parameters are stale. Use anyway?',
                         default=True):
            mount_model = track.model.load_stored_model(max_age=None)
        elif args.target_type == 'camera':
            if click.confirm(
                    'Use a default set of alignment parameters instead?',
                    default=True):
                guide_cam_orientation = click.prompt(
                    'Enter guide camera orientation in degrees, clockwise positive',
                    type=float)
                mount_model = track.model.load_default_model(
                    guide_cam_orientation=Longitude(guide_cam_orientation *
                                                    u.deg))

    if 'mount_model' not in locals():
        print(
            'Aborting: No model could be loaded. To refresh stored model run align program.'
        )
        sys.exit(1)

    # Create object with base type TelescopeMount
    mount = mounts.make_mount_from_args(args)

    telem_logger = telem.make_telem_logger_from_args(args)

    target = targets.make_target_from_args(
        args,
        mount,
        mount_model,
        MeridianSide[args.meridian_side.upper()],
        telem_logger=telem_logger,
    )

    tracker = Tracker(
        mount=mount,
        mount_model=mount_model,
        target=target,
        telem_logger=telem_logger,
    )

    try:
        laser_pointer = laser.make_laser_from_args(args)
    except OSError:
        print('Could not connect to laser pointer FTDI device.')
        laser_pointer = None

    telem_sources = {}
    try:
        # Create gamepad object and register callback
        game_pad = Gamepad()
        if laser_pointer is not None:
            game_pad.register_callback('BTN_SOUTH', laser_pointer.set)
        tracker.register_callback(gamepad_callback)
        telem_sources['gamepad'] = game_pad
        print('Gamepad found and registered.')
    except RuntimeError:
        print('No gamepads found.')

    if telem_logger is not None:
        telem_logger.register_sources(telem_sources)
        telem_logger.start()

    stopping_conditions = control.make_stop_conditions_from_args(args)
    try:
        tracker.run(stopping_conditions)
    except KeyboardInterrupt:
        print('Got CTRL-C, shutting down...')
    finally:
        # don't rely on destructors to safe mount!
        print('Safing mount...', end='', flush=True)
        if mount.safe():
            print('Mount safed successfully!')
        else:
            print('Warning: Mount may be in an unsafe state!')

        try:
            game_pad.stop()
        except UnboundLocalError:
            pass

        if telem_logger is not None:
            telem_logger.stop()
Exemplo n.º 4
0
def main():
    """See module docstring at the top of this file."""

    parser = track.ArgParser()
    parser.add_argument('--timestamp',
                        required=False,
                        help='UNIX timestamp',
                        type=float)

    subparsers = parser.add_subparsers(title='modes', dest='mode')

    parser_star = subparsers.add_parser('star', help='named star mode')
    parser_star.add_argument('name', help='name of star')

    parser_star = subparsers.add_parser('solarsystem',
                                        help='named solar system body mode')
    parser_star.add_argument('name', help='name of planet or moon')

    gps_client.add_program_arguments(parser)
    args = parser.parse_args()

    # Create a PyEphem Observer object
    location = gps_client.make_location_from_args(args)
    observer = ephem.Observer()
    observer.lat = location.lat.deg
    observer.lon = location.lon.deg
    observer.elevation = location.height.value

    # Get the PyEphem Body object corresonding to the given named star
    if args.mode == 'star':
        print('In named star mode: looking up \'{}\''.format(args.name))
        target = None
        for name, _ in ephem.stars.stars.items():
            if args.name.lower() == name.lower():
                print('Found named star: \'{}\''.format(name))
                target = ephem.star(name)
                break
        if target is None:
            raise Exception(
                'The named star \'{}\' isn\' present in PyEphem.'.format(
                    args.name))

    # Get the PyEphem Body object corresonding to the given named solar system body
    elif args.mode == 'solarsystem':
        print('In named solar system body mode: looking up \'{}\''.format(
            args.name))
        # pylint: disable=protected-access
        ss_objs = [
            name.lower() for _, _, name in ephem._libastro.builtin_planets()
        ]
        if args.name.lower() in ss_objs:
            body_type = None
            for attr in dir(ephem):
                if args.name.lower() == attr.lower():
                    body_type = getattr(ephem, attr)
                    print('Found solar system body: \'{}\''.format(attr))
                    break
            assert body_type is not None
            target = body_type()
        else:
            raise Exception(
                'The solar system body \'{}\' isn\'t present in PyEphem.'.
                format(args.name))
    else:
        print('You must specify a target.')
        sys.exit(1)

    if args.timestamp is not None:
        observer.date = ephem.Date(
            datetime.datetime.utcfromtimestamp(args.timestamp))
    else:
        observer.date = ephem.Date(datetime.datetime.utcnow())

    target.compute(observer)

    position = {
        'az': target.az * 180.0 / math.pi,
        'alt': target.alt * 180.0 / math.pi,
    }

    print('Expected position: ' + str(position))
Exemplo n.º 5
0
def main():
    """See module docstring at the top of this file."""

    parser = track.ArgParser()
    parser.add_argument('outdir', help='output directory')
    parser.add_argument('--mag-limit',
                        required=True,
                        help='magnitude cutoff for object passes',
                        type=float)
    time_group = parser.add_argument_group(
        title='Time Options',
        description='Options pertaining to time of observation',
    )
    time_group.add_argument(
        '--tz',
        required=True,
        help='time zone short code (use \'help\' for a list of codes)')
    time_group.add_argument('--year',
                            required=False,
                            help='observation year (default: now)',
                            type=int)
    time_group.add_argument('--month',
                            required=False,
                            help='observation month (default: now)',
                            type=int)
    time_group.add_argument('--day',
                            required=False,
                            help='observation day-of-month (default: now)',
                            type=int)
    time_group.add_argument('--ampm',
                            required=False,
                            help='morning or evening (\'AM\' or \'PM\')',
                            default='PM')
    gps_client.add_program_arguments(parser)
    args = parser.parse_args()

    if args.tz == 'help':
        print_timezone_help()
        return

    if args.ampm.upper() != 'AM' and args.ampm.upper() != 'PM':
        raise Exception('The AM/PM argument can only be \'AM\' or \'PM\'.')

    if args.year is not None and args.month is not None and args.day is not None:
        when = datetime.datetime(args.year, args.month, args.day).date()
    elif args.year is None and args.month is None and args.day is None:
        when = datetime.datetime.now().date()
    else:
        raise Exception(
            'If an explicit observation date is given, then year, month, and day must '
            'all be specified.')

    # Get location of observer from arguments or from GPS
    location = gps_client.make_location_from_args(args)

    base_url = 'http://www.heavens-above.com/'

    bright_sats_url = (
        base_url +
        f'AllSats.aspx?lat={location.lat.deg}&lng={location.lon.deg}'
        f'&alt={location.height.value}&tz={args.tz}')

    # do an initial page request so we can get the __VIEWSTATE and __VIEWSTATEGENERATOR values
    pre_soup = BeautifulSoup(requests.get(bright_sats_url).text, 'lxml')
    view_state = pre_soup.find('input', {
        'type': 'hidden',
        'name': '__VIEWSTATE'
    })['value']
    view_state_generator = pre_soup.find('input', {
        'type': 'hidden',
        'name': '__VIEWSTATEGENERATOR'
    })['value']

    post_data = [
        ('__EVENTTARGET', ''),
        ('__EVENTARGUMENT', ''),
        ('__LASTFOCUS', ''),
        ('__VIEWSTATE', view_state),
        ('__VIEWSTATEGENERATOR', view_state_generator),
        #        ('utcOffset',                                   '0'), # uhhhhhh...
        ('ctl00$ddlCulture', 'en'),
        ('ctl00$cph1$TimeSelectionControl1$comboMonth',
         str(date_to_monthnum(when))),
        ('ctl00$cph1$TimeSelectionControl1$comboDay', str(when.day)),
        ('ctl00$cph1$TimeSelectionControl1$radioAMPM', args.ampm.upper()),
        ('ctl00$cph1$TimeSelectionControl1$btnSubmit', 'Update'),
        ('ctl00$cph1$radioButtonsMag', '5.0'),
    ]

    bright_sats_soup = BeautifulSoup(
        requests.post(bright_sats_url, data=post_data).text, 'lxml')

    # find the rows in the table listing the satellite passes
    table = bright_sats_soup.find('table', {'class': 'standardTable'})
    rows = table.tbody.find_all('tr')

    # this number is deceptive because it's pre-magnitude-filtering
    print('Found {} (pre-filtered) satellite passes.'.format(len(rows)))

    for row in rows:

        cols = row.find_all('td')

        # We're not using all of these now, but preserving them is useful as documentation
        # pylint: disable=unused-variable
        sat = cols[0].string
        mag = float(cols[1].string)
        start_time = cols[2].string  # <-- in local time
        start_alt = cols[3].string
        start_az = cols[4].string
        high_time = cols[5].string  # <-- in local time
        high_alt = cols[6].string
        high_az = cols[7].string
        end_time = cols[8].string  # <-- in local time
        end_alt = cols[9].string
        end_az = cols[10].string

        # manually enforce the magnitude threshold
        if mag > args.mag_limit:
            continue

        # extract the satellite id from the onclick attribute of this table row
        onclick_str = row['onclick']
        url_suffix = re.findall(r"'([^']*)'", onclick_str)[0]
        satid = re.findall(r"satid=([0-9]*)", url_suffix)[0]

        print('Getting TLE for ' + sat + '...')

        # get the TLE from the orbit details page for this satellite
        orbit_url = base_url + 'orbit.aspx?satid=' + satid
        orbit_page = requests.get(orbit_url).text
        orbit_soup = BeautifulSoup(orbit_page, 'lxml')
        span_tags = orbit_soup.pre.find_all('span')
        assert len(span_tags) == 2
        tle = [sat]
        for span_tag in span_tags:
            assert span_tag['id'].startswith('ctl00_cph1_lblLine')
            tle.append(span_tag.string)

        os.makedirs(args.outdir, exist_ok=True)
        filename = os.path.join(os.path.normpath(args.outdir),
                                urlify(sat) + '.tle')
        with open(filename, 'w') as f:
            for line in tle:
                f.write(line + '\n')
Exemplo n.º 6
0
def main():
    """Run the alignment procedure! See module docstring for a description."""

    parser = track.ArgParser()
    align_group = parser.add_argument_group(
        title='Alignment Configuration',
        description='Options that apply to alignment',
    )
    align_group.add_argument('--mount-pole-alt',
                             required=True,
                             help='altitude of mount pole above horizon (deg)',
                             type=float)
    align_group.add_argument(
        '--guide-cam-orientation',
        help='orientation of guidescope camera, clockwise positive (deg)',
        default=0.0,
        type=float,
    )
    align_group.add_argument(
        '--min-positions',
        help='minimum number of positions to add to mount alignment model',
        default=10,
        type=int)
    align_group.add_argument(
        '--timeout',
        help='max time to wait for mount to converge on a new position',
        default=120.0,
        type=float)
    align_group.add_argument(
        '--max-tries',
        help='max number of plate solving attempts at each position',
        default=3,
        type=int)
    align_group.add_argument(
        '--min-alt',
        help='minimum altitude of alignment positions in degrees',
        default=20.0,
        type=float)
    align_group.add_argument(
        '--max-rms-error',
        help='warning printed if RMS error in degrees is greater than this',
        default=0.2,
        type=float)
    gps_client.add_program_arguments(parser)
    mounts.add_program_arguments(parser)
    cameras.add_program_arguments(parser, profile='align')
    ntp.add_program_arguments(parser)
    telem.add_program_arguments(parser)
    args = parser.parse_args()

    # Check if system clock is synchronized to GPS
    if args.check_time_sync:
        try:
            ntp.check_ntp_status()
        except ntp.NTPCheckFailure as e:
            print('NTP check failed: ' + str(e))
            if not click.confirm('Continue anyway?', default=True):
                print('Aborting')
                sys.exit(2)

    mount = mounts.make_mount_from_args(args)

    # Get location of observer from arguments or from GPS
    location = gps_client.make_location_from_args(args)

    # This is meant to be just good enough that the model is able to tell roughly which direction
    # is up so that the positions used during alignment are all above the horizon.
    starter_mount_model = track.model.load_default_model(
        mount_pole_alt=Longitude(args.mount_pole_alt * u.deg),
        location=location)

    camera = cameras.make_camera_from_args(args, profile='align')

    telem_logger = telem.make_telem_logger_from_args(args)

    if args.meridian_side is not None:
        meridian_side = MeridianSide[args.meridian_side.upper()]
    else:
        meridian_side = None
    positions = generate_positions(min_positions=args.min_positions,
                                   mount_model=starter_mount_model,
                                   mount=mount,
                                   min_altitude=Angle(args.min_alt * u.deg),
                                   meridian_side=meridian_side)

    # add the park position to the start of the list to serve as the "depot" in the route
    park_encoder_positions = mount.get_position()
    positions.insert(
        0, Position(
            encoder_positions=park_encoder_positions,
            mount=mount,
        ))

    # use travelling salesman solver to sort positions in the order that will take the least time
    positions = solve_route(positions)

    # mount is already at the starting position so don't need to go there
    del positions[0]

    # directory in which to place observation data for debugging purposes
    observations_dir = os.path.join(
        DATA_PATH, 'alignment_' +
        datetime.utcnow().isoformat(timespec='seconds').replace(':', ''))
    os.makedirs(DATA_PATH, exist_ok=True)
    os.mkdir(observations_dir)

    # pylint: disable=broad-except
    try:
        observations = []
        num_solutions = 0
        for idx, position in enumerate(positions):

            target = FixedMountEncodersTarget(
                position.encoder_positions,
                starter_mount_model,
            )

            pos = target.get_position()
            print(f'Moving to position {idx + 1} of {len(positions)}: '
                  f'Az: {pos.topo.az.deg:.2f}, Alt: {pos.topo.alt.deg:.2f}')

            tracker = Tracker(
                mount=mount,
                mount_model=starter_mount_model,
                target=target,
                telem_logger=telem_logger,
            )
            if telem_logger is not None:
                telem_logger.register_sources({'tracker': tracker})
            stop_reason = tracker.run(
                tracker.StoppingConditions(timeout=args.timeout,
                                           error_threshold=Angle(2.0 * u.deg)))
            mount.safe()
            if tracker.StopReason.CONVERGED not in stop_reason:
                raise RuntimeError(
                    f'Unexpected tracker stop reason: {stop_reason}')
            time.sleep(1.0)

            print(
                'Converged on the target position. Attempting plate solving.')

            # plate solver doesn't always work on the first try
            for i in range(args.max_tries):

                print(f'\tPlate solver attempt {i + 1} of {args.max_tries}...',
                      end='',
                      flush=True)

                if attempt_plate_solving(mount=mount,
                                         camera=camera,
                                         location=location,
                                         observations=observations,
                                         observations_dir=observations_dir,
                                         num_solutions_so_far=num_solutions):
                    num_solutions += 1
                    break

        print('Plate solver found solutions at {} of {} positions.'.format(
            num_solutions, len(positions)))

        if num_solutions == 0:
            print(
                "Can't solve mount model without any usable observations. Aborting."
            )
            sys.exit(1)
        elif num_solutions < args.min_positions:
            if not click.confirm(
                    f'WARNING: You asked for at least {args.min_positions} positions '
                    f'but only {num_solutions} can be used. Pointing accuracy may be '
                    f'affected. Continue to solve for model parameters anyway?',
                    default=True):
                sys.exit(1)

        observations = pd.DataFrame(observations)

        observations_filename = os.path.join(observations_dir,
                                             'observations_dataframe.pickle')
        print(f'Saving observations to {observations_filename}')
        with open(observations_filename, 'wb') as f:
            pickle.dump(observations, f, pickle.HIGHEST_PROTOCOL)

        try:
            print('Solving for mount model parameters...', end='', flush=True)
            model_params, result = track.model.solve_model(observations)
            print('done.')
            print(result)

            rms_error = np.sqrt(2 * result.cost / len(observations))
            if rms_error > args.max_rms_error:
                if not click.confirm(
                        f'WARNING: RMS error {rms_error:.4f} > '
                        f'{args.max_rms_error:.4f} degrees, save this solution '
                        f'anyway?',
                        default=True):
                    sys.exit(1)
            else:
                print(f'RMS error: {rms_error:.4f} degrees')

            model_param_set = ModelParamSet(
                model_params=model_params,
                guide_cam_orientation=Longitude(args.guide_cam_orientation *
                                                u.deg),
                location=location,
                timestamp=time.time(),
            )

            params_filename = os.path.join(observations_dir,
                                           'model_params.pickle')
            print(f'Saving model parameters to {params_filename}')
            with open(params_filename, 'wb') as f:
                pickle.dump(model_param_set, f, pickle.HIGHEST_PROTOCOL)

            print('Making this set of model parameters the default')
            track.model.save_default_param_set(model_param_set)

        except track.model.NoSolutionException as e:
            print('failed: {}'.format(str(e)))

    except RuntimeError as e:
        print(str(e))
        print('Alignment was not completed.')
    except KeyboardInterrupt:
        print('Got CTRL-C, shutting down...')
    finally:
        # don't rely on destructors to safe mount!
        print('Safing mount...', end='', flush=True)
        if mount.safe():
            print('Mount safed successfully!')
        else:
            print('Warning: Mount may be in an unsafe state!')

        # remove observations directory if it is empty
        try:
            os.rmdir(observations_dir)
        except (OSError, NameError):
            pass
Exemplo n.º 7
0
def main():
    """Apply step functions of varying magnitudes to a mount axis and plot the responses."""

    parser = track.ArgParser()
    mounts.add_program_arguments(parser)
    parser.add_argument(
        '--axis',
        help='axis number (0 or 1)',
        default=0,
        type=int,
    )
    args = parser.parse_args()

    mount = mounts.make_mount_from_args(args)

    step_responses = {}
    direction = +1.0
    try:
        for step_magnitude in np.arange(0.5, 4.5, 0.5):
            positions = []
            position_start = mount.get_position()[args.axis]
            time_start = time.time()
            direction = -direction
            rate = 0.0
            while True:
                delta_position = abs(
                    Longitude(mount.get_position()[args.axis] - position_start,
                              wrap_angle=180 * u.deg))
                time_elapsed = time.time() - time_start
                positions.append({
                    'time': time_elapsed,
                    'position': delta_position.deg
                })
                mount.slew(args.axis, rate)
                if time_elapsed >= 1.0:
                    rate = step_magnitude * direction
                if time_elapsed >= 4.0:
                    mount.safe()
                    break
                time.sleep(0.01)

            step_responses[step_magnitude] = pd.DataFrame(positions)

    except KeyboardInterrupt:
        print('Got CTRL-C, shutting down...')
    finally:
        # don't rely on destructors to safe mount!
        print('Safing mount...')
        if mount.safe():
            print('Mount safed successfully!')
        else:
            print('Warning: Mount may be in an unsafe state!')

    # plot step responses
    for step_magnitude, response in step_responses.items():
        step_response = np.diff(response.position) / np.diff(response.time)
        plt.plot(response.time[:-1] - 1.0,
                 step_response,
                 label=f'{step_magnitude:.1f} deg/s')

    # plot acceleration limit line
    t = np.linspace(0, 0.5, 1e3)
    a = 10 * t  # 10 degrees per second squared -- the default for G11 mount when this was written
    plt.plot(t, a, 'r', label='accel limit')

    plt.title(f'Step Response for Axis {args.axis}')
    plt.xlabel('Time [s]')
    plt.ylabel('Slew Rate [deg/s]')
    plt.legend()
    plt.grid(True)
    plt.show()
Exemplo n.º 8
0
def main():
    """See module docstring"""

    parser = track.ArgParser()
    parser.add_argument(
        '--mount-type',
        help='select mount type (nexstar or gemini)',
        default='gemini'
    )
    parser.add_argument(
        '--mount-path',
        help='serial device node or hostname for mount command interface',
        default='/dev/ttyACM0'
    )
    args = parser.parse_args()

    # Create object with base type TelescopeMount
    if args.mount_type == 'nexstar':
        mount = track.NexStarMount(args.mount_path)
    elif args.mount_type == 'gemini':
        mount = track.LosmandyGeminiMount(args.mount_path)
    else:
        print('mount-type not supported: ' + args.mount_type)
        sys.exit(1)

    axes = mount.AxisName

    # pylint: disable=too-many-nested-blocks
    try:
        # rates to test in arcseconds per second
        rates = [2**x for x in range(14)]
        rates.append(16319)

        rate_est = {}
        for axis in axes:
            rate_est[axis] = []

        direction = +1

        for axis in axes:
            print('Testing ' + str(axis) + '...')

            for rate in rates:

                print('Commanding slew at ' + str(rate) + ' arcseconds per second...')

                time_start = time.time()
                while time.time() - time_start < SLEW_CHANGE_TIME:
                    mount.slew(axis, direction * rate / 3600.0)

                direction *= -1
                position_start = mount.get_position()
                time_start = time.time()
                while True:
                    position = mount.get_position()
                    time_elapsed = time.time() - time_start
                    position_change = abs(
                        Longitude(position[axis] - position_start[axis], wrap_angle=180*u.deg)
                    ).deg
                    if position_change > SLEW_LIMIT or time_elapsed > TIME_LIMIT:
                        break

                rate_est[axis].append(position_change / time_elapsed)
                print('\tmeasured rate: ' + str(rate_est[axis][-1] * 3600.0))

            mount.safe()

        print('Results:')
        for rate, rate_est_0, rate_est_1 in zip(rates, rate_est[axes[0]], rate_est[axes[1]]):
            print(str(rate) + ', ' + str(3600 * rate_est_0) + ', ' + str(3600 * rate_est_1))


    except KeyboardInterrupt:
        print('Got CTRL-C, shutting down...')
    finally:
        # don't rely on destructors to safe mount!
        print('Safing mount...')
        if mount.safe():
            print('Mount safed successfully!')
        else:
            print('Warning: Mount may be in an unsafe state!')