#!/usr/pkg/bin/python3.12
#
# Copyright 2020 Ettus Research, a National Instruments Brand
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
"""
Utility to run power calibrations with USRPs
"""

import sys
import time
import math
import pickle
import argparse
import uhd

def parse_args():
    """ Parse args and return args object """
    parser = argparse.ArgumentParser(
        description="Run power level calibration for supported USRPs",
    )
    parser.add_argument(
        '--args', default="",
        help="USRP Device Args",
    )
    parser.add_argument(
        '-d', '--dir', default='tx',
        help="Direction: Must be either rx or tx. From the perspective of the "
             "device: rx == receive power levels, tx == transmit power levels.",
        choices=['tx', 'rx'])
    parser.add_argument(
        '--start',
        help='Start Frequency. Defaults to lowest available frequency on device. '
             'Note that this is only a hint for the device object, which can choose '
             'to override this value.', type=float)
    parser.add_argument(
        '--stop',
        help='Stop Frequency. Defaults to highest available frequency on device. '
             'Note that this is only a hint for the device object, which can choose '
             'to override this value.', type=float)
    parser.add_argument(
        '--step',
        help='Frequency Step. Defaults are device-specific. '
             'Note that this is only a hint for the device object, which can choose '
             'to override this value. Devices can also measure at non-regular '
             'frequencies, e.g., to more accurately cover differences between bands.',
        type=float)
    parser.add_argument(
        '--gain-step', type=float, default=1,
        help='Gain Step (dB). Defaults to 1 dB. '
             'Note that this is only a hint for the device object, which can choose '
             'to override this value. Devices can also measure at non-regular '
             'gain intervals.')
    parser.add_argument(
        '--lo-offset', type=float,
        help='LO Offset. This gets applied to every tune request. Note that for '
             'TX measurements, there is also an offset applied by --tone-freq. '
             'The default value is device-dependent.')
    parser.add_argument(
        '--amplitude', type=float, default=1./math.sqrt(2),
        help='Amplitude of the tone that is generated for tx measurements. '
             'Default is 1/sqrt(2), or -3 dBFS.')
    parser.add_argument(
        '--attenuation', type=float, default=0.0,
        help='Amount of attenuation between measurement device and DUT. This will '
             'be accounted for by simple addition, it is treated like a measurement error. '
             'The argument is generally positive, e.g. 30 means 30 dB of attenuation.')
    parser.add_argument(
        '--tone-freq', type=float, default=1e6,
        help='Frequency of the tone that is generated for Tx measurements. This '
             'has the same effect as setting an LO offset, except in software.')
    parser.add_argument(
        '--antenna', default="*",
        help="Select antenna port. A value of '*' means that the calibration "
             "will be repeated on all appropriate antenna ports.")
    parser.add_argument(
        '--channels', default="*",
        help="Select channel. A value of '*' means that the calibration "
             "will be repeated on all appropriate channels.")
    parser.add_argument(
        '--meas-dev', default='manual',
        help='Type of measurement device that is used')
    parser.add_argument(
        '-o', '--meas-option', default=[], action='append',
        help='Options that are passed to the measurement device')
    parser.add_argument(
        '--switch', default='manual',
        help='Type of switch to be used to connect antennas')
    parser.add_argument(
        '--switch-option', default=[], action='append',
        help='Options that are passed to the switch')
    parser.add_argument(
        '-r', '--rate', type=float,
        help='Sampling rate at which the calibration is performed')
    parser.add_argument(
        '--store', metavar='filename.pickle',
        help='If provided, will store intermediate cal data. This can be analyzed '
        'separately, or loaded into the tool with --load.')
    parser.add_argument(
        '--load', metavar='filename.pickle',
        help='If provided, will load intermediate cal data instead of running a '
        'measurement.')
    return parser.parse_args()


def sanitize_args(usrp, args, default_rate):
    """
    Check the args against the USRP object.
    """
    assert usrp.get_num_mboards() == 1, \
        "Power calibration tools are designed for a single motherboard!"
    available_chans = getattr(usrp, 'get_{}_num_channels'.format(args.dir))()
    if args.channels == '*':
        # * means all channels
        channels = list(range(available_chans))
    else:
        try:
            channels = [int(c) for c in args.channels.split(',')]
        except ValueError:
            raise ValueError("Invalid channel list: {}".format(args.channels))
        for chan in channels:
            assert chan in range(available_chans), \
                "ERROR: Invalid channel: {}. Should be in 0..{}.".format(
                    chan, available_chans)
    print("=== Calibrating for channels:", ", ".join([str(x) for x in channels]))
    available_ants = getattr(usrp, 'get_{}_antennas'.format(args.dir))()
    if args.antenna == '*':
        invalid_antennas = ('CAL', 'LOCAL', 'CAL_LOOPBACK', 'TERMINATION')
        antennas = [x for x in available_ants if x not in invalid_antennas]
    else:
        try:
            antennas = args.antenna.split(',')
        except ValueError:
            raise ValueError("Invalid antenna list: {}".format(args.antenna))
        for ant in antennas:
            assert ant in available_ants, \
                "Invalid antenna: {}. Should be in {}.".format(
                    ant, available_ants)
    print("=== Calibrating for antennas:", ", ".join([str(x) for x in antennas]))
    rate = args.rate or default_rate
    getattr(usrp, 'set_{}_rate'.format(args.dir))(rate)
    actual_rate = getattr(usrp, 'get_{}_rate'.format(args.dir))()
    print("=== Requested sampling rate: {} Msps, actual rate: {} Msps"
          .format(rate/1e6, actual_rate/1e6))
    if args.dir == 'tx' and abs(args.tone_freq) > actual_rate:
        raise ValueError(
            "The TX tone frequency offset of {} kHz is greater than the sampling rate."
            .format(args.tone_freq / 1e3))
    return channels, antennas, actual_rate


def init_results(pickle_file):
    """
    Initialize results from pickle file, or empty dict
    """
    if pickle_file is None:
        return {}
    with open(pickle_file, 'rb') as results_file:
        return pickle.load(results_file)

class CalRunner:
    """
    Executor of the calibration routines.
    """
    def __init__(self, usrp, usrp_cal, meas_dev, args):
        self.usrp = usrp
        self.usrp_cal = usrp_cal
        self.meas_dev = meas_dev
        self.dir = args.dir
        self.tone_offset = args.tone_freq if args.dir == 'tx' else 0.0
        self.lo_offset = args.lo_offset if args.lo_offset else usrp_cal.lo_offset
        if self.lo_offset:
            print("=== Using USRP LO offset: {:.2f} MHz"
                  .format(self.lo_offset / 1e6))

    def run(self, chan, freq):
        """
        Run all cal steps for a single frequency
        """
        print("=== Running calibration at frequency {:.3f} MHz...".format(freq / 1e6))
        tune_req = uhd.types.TuneRequest(freq, self.lo_offset)
        getattr(self.usrp, 'set_{}_freq'.format(self.dir))(tune_req, chan)
        time.sleep(self.usrp_cal.tune_settling_time)
        actual_freq = getattr(self.usrp, 'get_{}_freq'.format(self.dir))(chan)
        if abs(actual_freq - freq) > 1.0:
            print("WARNING: Frequency was coerced from {:.2f} MHz to {:.2f} MHz!"
                  .format(freq / 1e6, actual_freq / 1e6))
        self.meas_dev.set_frequency(actual_freq + self.tone_offset)
        getattr(self.usrp_cal, 'run_{}_cal'.format(self.dir))(freq)

def main():
    """Go, go, go!"""
    args = parse_args()
    print("=== Detecting USRP...")
    usrp = uhd.usrp.MultiUSRP(args.args)
    print("=== Measurement direction:", args.dir)
    print("=== Initializing measurement device...")
    meas_dev = uhd.usrp.cal.get_meas_device(args.dir, args.meas_dev, args.meas_option)
    meas_dev.power_offset = args.attenuation
    # If we're transmitting, then we need to factor in the "attenuation" from us
    # not transmitting at full scale
    if args.dir == 'tx':
        meas_dev.power_offset -= 20 * math.log10(args.amplitude)
    print("=== Initializing port connector...")
    switch = uhd.usrp.cal.get_switch(args.dir, args.switch, args.switch_option)
    print("=== Initializing USRP calibration object...")
    usrp_cal = uhd.usrp.cal.get_usrp_calibrator(
        usrp, meas_dev, args.dir,
        gain_step=args.gain_step,
    )
    channels, antennas, rate = sanitize_args(usrp, args, usrp_cal.default_rate)
    results = init_results(args.load)
    usrp_cal.init(
        rate=rate,
        tone_freq=args.tone_freq,
        amplitude=args.amplitude,
    )
    print("=== Launching calibration...")
    cal_runner = CalRunner(usrp, usrp_cal, meas_dev, args)
    for chan in channels:
        if chan not in results:
            results[chan] = {}
        for ant in antennas:
            if ant in results[chan]:
                print("=== Using pickled data for channel {}, antenna {}."
                      .format(chan, ant))
                continue
            print("=== Running calibration for channel {}, antenna {}."
                  .format(chan, ant))
            # Set up all the objects
            getattr(usrp, 'set_{}_antenna'.format(args.dir))(ant, chan)
            switch.connect(chan, ant)
            usrp_cal.update_port(chan, ant)
            freqs = usrp_cal.init_frequencies(args.start, args.stop, args.step)
            usrp_cal.start() # This will activate siggen
            # Now calibrate
            for freq in freqs:
                try:
                    cal_runner.run(chan, freq)
                except RuntimeError as ex:
                    print("ERROR: Stopping calibration due to exception: {}"
                          .format(str(ex)))
                    usrp_cal.stop()
                    return 1
            # Store results for pickling and shut down for next antenna port
            results[chan][ant] = usrp_cal.results
            usrp_cal.stop() # This will deactivate siggen and store the data
    if args.store:
        print("=== Storing pickled calibration data to {}...".format(args.store))
        with open(args.store, 'wb') as results_file:
            pickle.dump(results, results_file)
    return 0

if __name__ == "__main__":
    try:
        sys.exit(main())
    except (RuntimeError, ValueError) as ex:
        print("ERROR:", str(ex))
        sys.exit(1)
