"""Functions for metric learning for the face recognition network.
"""
# MIT License
#### copyright at Auther: mingzuheng, 25/03/2019 #########

# pylint: disable=missing-docstring
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function

import os
from subprocess import Popen, PIPE
import tensorflow as tf
from tensorflow.python.framework import ops
import numpy as np
from scipy import misc
import matplotlib.pyplot as plt
from scipy import interpolate
from tensorflow.python.training import training
import random
import re
from collections import Counter
import matplotlib.pyplot as plt
import cv2
import python_getdents
from scipy import spatial
from sklearn.decomposition import PCA
from itertools import islice
import itertools

#import h5py

def center_loss(features, label, alfa, nrof_classes):
    """Center loss based on the paper "A Discriminative Feature Learning Approach for Deep Face Recognition"
       (http://ydwen.github.io/papers/WenECCV16.pdf)

    """
     #nrof_features = features.get_shape()[1]
     #centers = tf.get_variable('centers', [nrof_classes, nrof_features], dtype=tf.float32,
     #    initializer=tf.constant_initializer(0), trainable=False)
     #label = tf.reshape(label, [-1])
     #centers_batch = tf.gather(centers, label)
     #diff = (1 - alfa) * (centers_batch - features)
     #diff = alfa * (centers_batch - features)
     #centers = tf.scatter_sub(centers, label, diff)
    # loss = tf.nn.l2_loss(features - centers_batch)
    # return loss, centers, diff, centers_batch

    """Center loss based on the paper "A Discriminative Feature Learning Approach for Deep Face Recognition"
       (http://ydwen.github.io/papers/WenECCV16.pdf)
       -- mzh 15/02/2017
       -- Correcting the center updating, center updates/shifts towards to the center of the correponding class with a weight:
       -- centers = centers- (1-alpha)(centers-sum(Xi)/Nj), where Xi is the elements of the class j, Nj is the number of the elements of class Nj
       -- code has been tested by the test script '../test/center_loss_test.py'
    """
    nrof_features = features.get_shape()[1]
    centers = tf.get_variable('centers', [nrof_classes, nrof_features], dtype=tf.float32, initializer=tf.constant_initializer(0), trainable=False)
    centers_cts = tf.get_variable('centers_cts', [nrof_classes], dtype=tf.float32, initializer=tf.constant_initializer(0), trainable=False)
    #centers_cts_init = tf.zeros_like(nrof_classes, tf.float32)
    label = tf.reshape(label, [-1])
    centers_batch = tf.gather(centers, label) #get the corresponding center of each element in features, the list of the centers is in the same order as the features
    loss_n = tf.reduce_sum(tf.square(features - centers_batch)/2, 1)
    loss = tf.nn.l2_loss(features - centers_batch)
    diff = (1 - alfa) * (centers_batch - features)

    ## update the centers
    label_unique, idx = tf.unique(label)
    zeros = tf.zeros_like(label_unique, tf.float32)
    ## calculation the repeat time of same label
    nrof_elements_per_class_clean = tf.scatter_update(centers_cts, label_unique, zeros)
    ones = tf.ones_like(label, tf.float32)
    ## counting the number elments in each class, the class is in the order of the [0,1,2,3,....] as initialzation
    nrof_elements_per_class_update = tf.scatter_add(nrof_elements_per_class_clean, label, ones)
    ## nrof_elements_per_class_list is the number of the elements in each class in the batch
    nrof_elements_per_class_batch = tf.gather(nrof_elements_per_class_update, label)
    nrof_elements_per_class_batch_reshape = tf.reshape(nrof_elements_per_class_batch, [-1, 1])## reshape the matrix as 1 coloum no matter the dimension of the row (-1)
    diff_mean = tf.div(diff, nrof_elements_per_class_batch_reshape)
    centers = tf.scatter_sub(centers, label, diff_mean)

    #return loss, centers, label, centers_batch, diff, centers_cts, centers_cts_batch, diff_mean,center_cts_clear, nrof_elements_per_class_batch_reshape
    return loss, loss_n, centers, nrof_elements_per_class_clean, nrof_elements_per_class_batch_reshape,diff_mean # facenet_expression_addcnns_simple_joint_v4_dynamic.py
    #return loss, centers, nrof_elements_per_class_clean, nrof_elements_per_class_batch_reshape,diff_mean ### facenet_train_classifier_expression_pretrainExpr_multidata_addcnns_simple.py

def center_loss_similarity(features, label, alfa, nrof_classes):
    ## center_loss on cosine distance =1 - similarity instead of the L2 norm, i.e. Euclidian distance

    ## normalisation as the embedding vectors in order to similarity distance
    features = tf.nn.l2_normalize(features, 1, 1e-10, name='feat_emb')

    nrof_features = features.get_shape()[1]
    centers = tf.get_variable('centers', [nrof_classes, nrof_features], dtype=tf.float32, initializer=tf.constant_initializer(0), trainable=False)
    centers_cts = tf.get_variable('centers_cts', [nrof_classes], dtype=tf.float32, initializer=tf.constant_initializer(0), trainable=False)
    #centers_cts_init = tf.zeros_like(nrof_classes, tf.float32)
    label = tf.reshape(label, [-1])
    centers_batch = tf.gather(centers, label) #get the corresponding center of each element in features, the list of the centers is in the same order as the features
    #loss = tf.nn.l2_loss(features - centers_batch) ## 0.5*(L2 norm)**2, L2 norm is the Euclidian distance
    similarity_all = tf.matmul(features, tf.transpose(tf.nn.l2_normalize(centers_batch, 1, 1e-10))) ## dot prodoct, cosine distance, similarity of x and y
    similarity_self = tf.diag_part(similarity_all)
    loss_x = tf.subtract(1.0, similarity_self)
    loss = tf.reduce_sum(loss_x) ## sum the cosine distance of each vector/tensor
    diff = (1 - alfa) * (centers_batch - features)
    ones = tf.ones_like(label, tf.float32)
    centers_cts = tf.scatter_add(centers_cts, label, ones) # counting the number of each class, the class is in the order of the [0,1,2,3,....] as initialzation
    centers_cts_batch = tf.gather(centers_cts, label)
    #centers_cts_batch_ext = tf.tile(centers_cts_batch, nrof_features)
    #centers_cts_batch_reshape = tf.reshape(centers_cts_batch_ext,[-1, nrof_features])
    centers_cts_batch_reshape = tf.reshape(centers_cts_batch, [-1,1])
    diff_mean = tf.div(diff, centers_cts_batch_reshape)
    centers = tf.scatter_sub(centers, label, diff_mean)
    zeros = tf.zeros_like(label, tf.float32)
    center_cts_clear = tf.scatter_update(centers_cts, label, zeros)
    #return loss, centers, label, centers_batch, diff, centers_cts, centers_cts_batch, diff_mean,center_cts_clear, centers_cts_batch_reshape
    #return loss, centers, loss_x, similarity_all, similarity_self
    return loss, centers

def center_inter_loss_tf(features, nrof_features, label, alfa, nrof_classes): # tensorflow version
    """ center_inter_loss = center_loss/||Xi - centers(0,1,2,...i-1,i+1,i+2,...)||
        --mzh 22022017
    """
    # dim_features = features.get_shape()[1]
    # nrof_features = features.get_shape()[0]
    dim_features = features.get_shape()[1].value
    #nrof_features = features.get_shape()[0].value
    # dim_features = features.shape[1]
    # nrof_features = features.shape[0]
    centers = tf.get_variable('centers', [nrof_classes, dim_features], dtype=tf.float32,
                              initializer=tf.constant_initializer(0), trainable=False)
    centers_cts = tf.get_variable('centers_cts', [nrof_classes], dtype=tf.float32,
                                  initializer=tf.constant_initializer(0), trainable=False)
    ## center_loss calculation
    label = tf.reshape(label, [-1])
    centers_batch = tf.gather(centers,label)  # get the corresponding center of each element in features, the list of the centers is in the same order as the features
    dist_centers = features - centers_batch
    dist_centers_sum = tf.reduce_sum(dist_centers**2,1)/2
    loss_center = tf.nn.l2_loss(dist_centers)

    ## calculation the repeat time of same label
    ones = tf.ones_like(label, tf.float32)
    centers_cts = tf.scatter_add(centers_cts, label, ones)  # counting the number of each class, the class is in the order of the [0,1,2,3,....] as initialzation
    centers_cts_batch = tf.gather(centers_cts, label)

    ## inter_center_loss calculation
    #label_unique, label_idx = tf.unique(label)
    #centers_batch1 = tf.gather(centers,label_unique)
    #nrof_classes_batch = centers_batch.get_shape()[0].value
    #centers_1D = tf.reshape(centers_batch1, [1, nrof_classes_batch * dim_features])
    centers_batch1 = tf.gather(centers,label)
    centers_1D = tf.reshape(centers_batch1, [1, nrof_features * dim_features])
    centers_2D = tf.tile(centers_1D, [nrof_features, 1])
    centers_3D = tf.reshape(centers_2D,[nrof_features, nrof_features, dim_features])
    features_3D = tf.reshape(features, [nrof_features, 1, dim_features])
    dist_inter_centers = features_3D - centers_3D
    dist_inter_centers_sum_dim = tf.reduce_sum(dist_inter_centers**2,2)/2
    centers_cts_batch_1D = tf.tile(centers_cts_batch,[nrof_features])
    centers_cts_batch_2D = tf.reshape(centers_cts_batch_1D, [nrof_features, nrof_features])
    dist_inter_centers_sum_unique = tf.div(dist_inter_centers_sum_dim, centers_cts_batch_2D)
    dist_inter_centers_sum_all = tf.reduce_sum(dist_inter_centers_sum_unique, 1)
    dist_inter_centers_sum = dist_inter_centers_sum_all - dist_centers_sum
    loss_inter_centers = tf.reduce_mean(dist_inter_centers_sum)

    ## total loss
    loss = tf.div(loss_center, loss_inter_centers)

    ## update centers
    diff = (1 - alfa) * (centers_batch - features)
#    ones = tf.ones_like(label, tf.float32)
#   centers_cts = tf.scatter_add(centers_cts, label, ones)  # counting the number of each class
#    centers_cts_batch = tf.gather(centers_cts, label)
    centers_cts_batch_reshape = tf.reshape(centers_cts_batch, [-1, 1])
    diff_mean = tf.div(diff, centers_cts_batch_reshape)
    centers = tf.scatter_sub(centers, label, diff_mean)
    zeros = tf.zeros_like(label, tf.float32)
    center_cts_clear = tf.scatter_update(centers_cts, label, zeros)
    # return loss, centers, label, centers_batch, diff, centers_cts, centers_cts_batch, diff_mean,center_cts_clear, centers_cts_batch_reshape
    return loss, centers,  loss_center, loss_inter_centers, center_cts_clear
    #return loss, centers, loss_center, loss_inter_centers, dist_inter_centers_sum_dim, centers_cts_batch_2D, dist_inter_centers_sum_unique, dist_inter_centers_sum_all, dist_inter_centers_sum, dist_inter_centers_sum, center_cts_clear


import numpy as np
import astropy.wcs as pywcs
import os
from collections import defaultdict
from regions import PixCoord
import warnings

from soxs.events import write_event_file
from soxs.instrument_registry import instrument_registry
from soxs.psf import psf_model_registry
from soxs.response import AuxiliaryResponseFile, RedistributionMatrixFile
from soxs.simput import read_simput_catalog, SimputPhotonList
from soxs.utils import mylog, parse_prng, parse_value, \
    get_rot_mat, create_region, get_data_file, ensure_numpy_array

def perform_dither(t, dither_dict):
    if dither_dict["dither_on"]:
        a = 2.0*np.pi/dither_dict["x_period"]
        b = 2.0*np.pi/dither_dict["y_period"]
        A = dither_dict["x_amp"]/dither_dict["plate_scale"]
        B = dither_dict["y_amp"]/dither_dict["plate_scale"]
        x_offset = A*np.sin(a*t)
        y_offset = B*np.sin(b*t)
    else:
        x_offset = np.zeros(t.size)
        y_offset = np.zeros(t.size)
    return x_offset, y_offset

def generate_events(source, exp_time, instrument, sky_center,
                    no_dither=False, dither_params=None,
                    roll_angle=0.0, subpixel_res=False,
                    aimpt_shift=None, prng=None):
    """
    Take unconvolved events and convolve them with instrumental responses. This
    function does the following:

    1. Determines which events are observed using the ARF
    2. Pixelizes the events, applying PSF effects and dithering
    3. Determines energy channels using the RMF

    This function is not meant to be called by the end-user but is used by
    the :func:`~soxs.instrument.instrument_simulator` function.

    Parameters
    ----------
    input_events : string, dict, or None
        The unconvolved events to be used as input. Can be one of the
        following:
        1. The name of a SIMPUT catalog file.
        2. A Python dictionary containing the following items:
        "ra": A NumPy array of right ascension values in degrees.
        "dec": A NumPy array of declination values in degrees.
        "energy": A NumPy array of energy values in keV.
        "flux": The flux of the entire source, in units of erg/cm**2/s.
    out_file : string
        The name of the event file to be written.
    exp_time : float, (value, unit) tuple, or :class:`~astropy.units.Quantity`
        The exposure time to use, in seconds.
    instrument : string
        The name of the instrument to use, which picks an instrument
        specification from the instrument registry.
    sky_center : array, tuple, or list
        The center RA, Dec coordinates of the observation, in degrees.
    no_dither : boolean, optional
        If True, turn off dithering entirely. Default: False
    dither_params : array-like of floats, optional
        The parameters to use to control the size and period of the dither
        pattern. The first two numbers are the dither amplitude in x and y
        detector coordinates in arcseconds, and the second two numbers are
        the dither period in x and y detector coordinates in seconds.
        Default: [8.0, 8.0, 1000.0, 707.0].
    roll_angle : float, (value, unit) tuple, or :class:`~astropy.units.Quantity`, optional
        The roll angle of the observation in degrees. Default: 0.0
    subpixel_res : boolean, optional
        If True, event positions are not randomized within the pixels
        within which they are detected. Default: False
    aimpt_shift : array-like, optional
        A two-float array-like object which shifts the aimpoint on the
        detector from the nominal position. Units are in arcseconds.
        Default: None, which results in no shift from the nominal aimpoint.
    prng : :class:`~numpy.random.RandomState` object, integer, or None
        A pseudo-random number generator. Typically will only
        be specified if you have a reason to generate the same
        set of random numbers, such as for a test. Default is None,
        which sets the seed based on the system time.
    """
    exp_time = parse_value(exp_time, "s")
    roll_angle = parse_value(roll_angle, "deg")
    prng = parse_prng(prng)
    if source is None:
        source_list = []
    elif isinstance(source, dict):
        parameters = {}
        for key in ["flux", "emin", "emax", "src_names"]:
            parameters[key] = source[key]
        source_list = []
        for i in range(len(parameters["flux"])):
            phlist = SimputPhotonList(source["ra"][i], source["dec"][i],
                                      source["energy"][i], parameters['flux'][i],
                                      parameters['src_names'][i])
            source_list.append(phlist)
    elif isinstance(source, str):
        # Assume this is a SIMPUT catalog
        source_list, parameters = read_simput_catalog(source)

    try:
        instrument_spec = instrument_registry[instrument]
    except KeyError:
        raise KeyError(f"Instrument {instrument} is not in the instrument registry!")
    if not instrument_spec["imaging"]:
        raise RuntimeError(f"Instrument '{instrument_spec['name']}' is not "
                           f"designed for imaging observations!")

    arf_file = get_data_file(instrument_spec["arf"])
    rmf_file = get_data_file(instrument_spec["rmf"])
    arf = AuxiliaryResponseFile(arf_file)
    rmf = RedistributionMatrixFile(rmf_file)

    nx = instrument_spec["num_pixels"]
    plate_scale = instrument_spec["fov"]/nx/60. # arcmin to deg
    plate_scale_arcsec = plate_scale * 3600.0

    if aimpt_shift is None:
        aimpt_shift = np.zeros(2)
    aimpt_shift = ensure_numpy_array(aimpt_shift).astype('float64')
    aimpt_shift /= plate_scale_arcsec

    if not instrument_spec["dither"]:
        dither_on = False
    else:
        dither_on = not no_dither
    if dither_params is None:
        dither_params = [8.0, 8.0, 1000.0, 707.0]
    dither_dict = {"x_amp": dither_params[0],
                   "y_amp": dither_params[1],
                   "x_period": dither_params[2],
                   "y_period": dither_params[3],
                   "dither_on": dither_on,
                   "plate_scale": plate_scale_arcsec}

    event_params = {"exposure_time": exp_time,
                    "arf": arf.filename,
                    "sky_center": sky_center,
                    "pix_center": np.array([0.5*(2*nx+1)]*2),
                    "num_pixels": nx,
                    "plate_scale": plate_scale,
                    "rmf": rmf.filename,
                    "channel_type": rmf.chan_type.upper(),
                    "telescope": rmf.header["TELESCOP"],
                    "instrument": instrument_spec['name'],
                    "mission": rmf.header.get("MISSION", ""),
                    "nchan": rmf.n_ch,
                    "roll_angle": roll_angle,
                    "fov": instrument_spec["fov"],
                    "chan_lim": [rmf.cmin, rmf.cmax],
                    "chips": instrument_spec["chips"],
                    "dither_params": dither_dict,
                    "aimpt_coords": instrument_spec["aimpt_coords"],
                    "aimpt_shift": aimpt_shift}

    # Set up WCS

    w = pywcs.WCS(naxis=2)
    w.wcs.crval = event_params["sky_center"]
    w.wcs.crpix = event_params["pix_center"]
    w.wcs.cdelt = [-plate_scale, plate_scale]
    w.wcs.ctype = ["RA---TAN","DEC--TAN"]
    w.wcs.cunit = ["deg"]*2

    # Determine rotation matrix
    rot_mat = get_rot_mat(roll_angle)

    # Set up PSF
    psf_type = instrument_spec["psf"][0]
    psf_class = psf_model_registry[psf_type]
    psf = psf_class(instrument_spec, prng=prng)

    all_events = defaultdict(list)

    for i, src in enumerate(source_list):

        mylog.info(f"Detecting events from source {parameters['src_names'][i]}")

        # Step 1: Use ARF to determine which photons are observed

        mylog.info(f"Applying energy-dependent effective area from "
                   f"{os.path.split(arf.filename)[-1]}.")
        refband = [parameters["emin"][i], parameters["emax"][i]]
        if src.src_type == "phlist":
            events = arf.detect_events_phlist(src.events.copy(), exp_time,
                                              parameters["flux"][i],
                                              refband, prng=prng)
        elif src.src_type.endswith("spectrum"):
            events = arf.detect_events_spec(src, exp_time, refband, prng=prng)

        n_evt = events["energy"].size

        if n_evt == 0:
            mylog.warning("No events were observed for this source!!!")
        else:

            # Step 2: Assign pixel coordinates to events. Apply dithering and
            # PSF. Clip events that don't fall within the detection region.

            mylog.info("Pixeling events.")

            # Convert RA, Dec to pixel coordinates
            xpix, ypix = w.wcs_world2pix(events["ra"], events["dec"], 1)

            xpix -= event_params["pix_center"][0]
            ypix -= event_params["pix_center"][1]

            events.pop("ra")
            events.pop("dec")

            n_evt = xpix.size

            # Rotate physical coordinates to detector coordinates

            det = np.dot(rot_mat, np.array([xpix, ypix]))
            detx = det[0, :] + event_params["aimpt_coords"][0] + aimpt_shift[0]
            dety = det[1, :] + event_params["aimpt_coords"][1] + aimpt_shift[1]

            # Add times to events
            events['time'] = prng.uniform(size=n_evt, low=0.0,
                                          high=event_params["exposure_time"])

            # Apply dithering

            x_offset, y_offset = perform_dither(events["time"], dither_dict)

            detx -= x_offset
            dety -= y_offset

            # PSF scattering of detector coordinates

            mylog.info(f"Scattering events with a {psf}-based PSF.")
            detx, dety = psf.scatter(detx, dety, events["energy"])

            # Convert detector coordinates to chip coordinates.
            # Throw out events that don't fall on any chip.

            cx = np.trunc(detx)+0.5*np.sign(detx)
            cy = np.trunc(dety)+0.5*np.sign(dety)

            events["chip_id"] = -np.ones(n_evt, dtype='int')
            for i, chip in enumerate(event_params["chips"]):
                rtype = chip[0]
                args = chip[1:]
                r, _ = create_region(rtype, args, 0.0, 0.0)
                inside = r.contains(PixCoord(cx, cy))
                events["chip_id"][inside] = i
            keep = events["chip_id"] > -1

            mylog.info(f"{n_evt-keep.sum()} events were rejected because "
                       f"they do not fall on any CCD.")
            n_evt = keep.sum()

            if n_evt == 0:
                mylog.warning("No events are within the field "
                              "of view for this source!!!")
            else:

                # Keep only those events which fall on a chip

                for key in events:
                    events[key] = events[key][keep]

                # Convert chip coordinates back to detector coordinates,
                # unless the user has specified that they want subpixel
                # resolution

                if subpixel_res:
                    events["detx"] = detx[keep]
                    events["dety"] = dety[keep]
                else:
                    events["detx"] = cx[keep] + \
                                     prng.uniform(low=-0.5, high=0.5, size=n_evt)
                    events["dety"] = cy[keep] + \
                                     prng.uniform(low=-0.5, high=0.5, size=n_evt)

                # Convert detector coordinates back to pixel coordinates by
                # adding the dither offsets back in and applying the rotation
                # matrix again

                det = np.array([events["detx"] + x_offset[keep] -
                                event_params["aimpt_coords"][0] -
                                aimpt_shift[0],
                                events["dety"] + y_offset[keep] -
                                event_params["aimpt_coords"][1] -
                                aimpt_shift[1]])
                pix = np.dot(rot_mat.T, det)

                events["xpix"] = pix[0,:] + event_params['pix_center'][0]
                events["ypix"] = pix[1,:] + event_params['pix_center'][1]

        if n_evt > 0:
            for key in events:
                all_events[key] = np.concatenate([all_events[key], events[key]])

    if len(all_events["energy"]) == 0:
        mylog.warning("No events from any of the sources in "
                      "the catalog were detected!")
        for key in ["xpix", "ypix", "detx", "dety", "time",
                    "chip_id", event_params["channel_type"]]:
            all_events[key] = np.array([])
    else:
        # Step 4: Scatter energies with RMF
        mylog.info(f"Scattering energies with "
                   f"RMF {os.path.split(rmf.filename)[-1]}.")
        all_events = rmf.scatter_energies(all_events, prng=prng)

    return all_events, event_params

def make_background(exp_time, instrument, sky_center, foreground=True,
                    ptsrc_bkgnd=True, instr_bkgnd=True, no_dither=False,
                    dither_params=None, roll_angle=0.0, subpixel_res=False,
                    input_pt_sources=None, aimpt_shift=None, prng=None,
                    **kwargs):
    """
    Make background events.

    Parameters
    ----------
    exp_time : float, (value, unit) tuple, or :class:`~astropy.units.Quantity`
        The exposure time to use, in seconds.
    instrument : string
        The name of the instrument to use, which picks an instrument
        specification from the instrument registry.
    sky_center : array, tuple, or list
        The center RA, Dec coordinates of the observation, in degrees.
    foreground : boolean, optional
        Whether or not to include the Galactic foreground. Default: True
    instr_bkgnd : boolean, optional
        Whether or not to include the instrumental background. Default: True
    no_dither : boolean, optional
        If True, turn off dithering entirely. Default: False
    dither_params : array-like of floats, optional
        The parameters to use to control the size and period of the dither
        pattern. The first two numbers are the dither amplitude in x and y
        detector coordinates in arcseconds, and the second two numbers are
        the dither period in x and y detector coordinates in seconds.
        Default: [8.0, 8.0, 1000.0, 707.0].
    ptsrc_bkgnd : boolean, optional
        Whether or not to include the point-source background. Default: True
    roll_angle : float, (value, unit) tuple, or :class:`~astropy.units.Quantity`, optional
        The roll angle of the observation in degrees. Default: 0.0
    subpixel_res: boolean, optional
        If True, event positions are not randomized within the pixels
        within which they are detected. Default: False
    input_pt_sources : string, optional
        If set to a filename, input the point source positions, fluxes,
        and spectral indices from an ASCII table instead of generating
        them. Default: None
    aimpt_shift : array-like, optional
        A two-float array-like object which shifts the aimpoint on the
        detector from the nominal position. Units are in arcseconds.
        Default: None, which results in no shift from the nominal aimpoint.
    prng : :class:`~numpy.random.RandomState` object, integer, or None
        A pseudo-random number generator. Typically will only
        be specified if you have a reason to generate the same
        set of random numbers, such as for a test. Default is None,
        which sets the seed based on the system time.
    """
    if "nH" in kwargs or "absorb_model" in kwargs:
        warnings.warn("The 'nH' and 'absorb_model' keyword arguments"
                      "have been omitted. Please set the 'bkgnd_nH' "
                      "and 'bkgnd_absorb_model' values in the SOXS"
                      "configuration file if you want to change these "
                      "values. ",
                      DeprecationWarning)
    from soxs.background import make_diffuse_background, \
        make_ptsrc_background
    prng = parse_prng(prng)
    exp_time = parse_value(exp_time, "s")
    roll_angle = parse_value(roll_angle, "deg")
    try:
        instrument_spec = instrument_registry[instrument]
    except KeyError:
        raise KeyError(f"Instrument {instrument} is not in the "
                       f"instrument registry!")
    if not instrument_spec["imaging"]:
        raise RuntimeError(f"Instrument '{instrument_spec['name']}' is not "
                           f"designed for imaging observations!")
    fov = instrument_spec["fov"]

    input_events = defaultdict(list)

    arf_file = get_data_file(instrument_spec["arf"])
    arf = AuxiliaryResponseFile(arf_file)
    rmf_file = get_data_file(instrument_spec["rmf"])
    rmf = RedistributionMatrixFile(rmf_file)

    if ptsrc_bkgnd:
        mylog.info("Adding in point-source background.")
        ptsrc_events = make_ptsrc_background(exp_time, fov, sky_center,
                                             area=1.2*arf.max_area,
                                             input_sources=input_pt_sources,
                                             prng=prng)
        for key in ["ra", "dec", "energy"]:
            input_events[key].append(ptsrc_events[key])
        input_events["flux"].append(ptsrc_events["flux"])
        input_events["emin"].append(ptsrc_events["energy"].min())
        input_events["emax"].append(ptsrc_events["energy"].max())
        input_events["src_names"].append("ptsrc_bkgnd")
        events, event_params = generate_events(input_events, exp_time,
                                               instrument, sky_center,
                                               no_dither=no_dither,
                                               dither_params=dither_params,
                                               roll_angle=roll_angle,
                                               subpixel_res=subpixel_res,
                                               aimpt_shift=aimpt_shift,
                                               prng=prng)
        mylog.info(f"Generated {events['energy'].size} photons from "
                   f"the point-source background.")
    else:
        nx = instrument_spec["num_pixels"]
        plate_scale = instrument_spec["fov"]/nx/60.0
        plate_scale_arcsec = plate_scale*3600.0
        if aimpt_shift is None:
            aimpt_shift = np.zeros(2)
        aimpt_shift = ensure_numpy_array(aimpt_shift).astype('float64')
        aimpt_shift /= plate_scale_arcsec
        events = defaultdict(list)
        if not instrument_spec["dither"]:
            dither_on = False
        else:
            dither_on = not no_dither
        if dither_params is None:
            dither_params = [8.0, 8.0, 1000.0, 707.0]
        dither_dict = {"x_amp": dither_params[0],
                       "y_amp": dither_params[1],
                       "x_period": dither_params[2],
                       "y_period": dither_params[3],
                       "dither_on": dither_on,
                       "plate_scale": instrument_spec["fov"]/nx*60.0}
        event_params = {"exposure_time": exp_time,
                        "fov": instrument_spec["fov"],
                        "num_pixels": nx,
                        "pix_center": np.array([0.5*(2*nx+1)]*2),
                        "channel_type": rmf.header["CHANTYPE"].upper(),
                        "sky_center": sky_center,
                        "dither_params": dither_dict,
                        "plate_scale": plate_scale,
                        "chan_lim": [rmf.cmin, rmf.cmax],
                        "rmf": rmf_file, "arf": arf_file,
                        "telescope": rmf.header["TELESCOP"],
                        "instrument": instrument_spec['name'],
                        "mission": rmf.header.get("MISSION", ""),
                        "nchan": rmf.n_ch,
                        "roll_angle": roll_angle,
                        "aimpt_coords": instrument_spec["aimpt_coords"],
                        "aimpt_shift": aimpt_shift}

    if "chips" not in event_params:
        event_params["chips"] = instrument_spec["chips"]

    instr_bkgnd &= instrument_spec["bkgnd"] is not None

    if foreground or instr_bkgnd:
        bkg_events = make_diffuse_background(foreground, instr_bkgnd,
            instrument_spec, event_params, arf, rmf, prng=prng)
        for key in bkg_events:
            events[key] = np.concatenate([events[key], bkg_events[key]])

    return events, event_params

def make_background_file(out_file, exp_time, instrument, sky_center,
                         overwrite=False, foreground=True, instr_bkgnd=True,
                         ptsrc_bkgnd=True, no_dither=False, dither_params=None,
                         subpixel_res=False, input_pt_sources=None,
                         prng=None, **kwargs):
    """
    Make an event file consisting entirely of background events. This will be
    useful for creating backgrounds that can be added to simulations of sources.

    Parameters
    ----------
    exp_time : float, (value, unit) tuple, or :class:`~astropy.units.Quantity`
        The exposure time to use, in seconds.
    instrument : string
        The name of the instrument to use, which picks an instrument
        specification from the instrument registry.
    sky_center : array, tuple, or list
        The center RA, Dec coordinates of the observation, in degrees.
    overwrite : boolean, optional
        Whether or not to overwrite an existing file with the same name.
        Default: False
    foreground : boolean, optional
        Whether or not to include the Galactic foreground. Default: True
    instr_bkgnd : boolean, optional
        Whether or not to include the instrumental background. Default: True
    ptsrc_bkgnd : boolean, optional
        Whether or not to include the point-source background. Default: True
    no_dither : boolean, optional
        If True, turn off dithering entirely. Default: False
    dither_params : array-like of floats, optional
        The parameters to use to control the size and period of the dither
        pattern. The first two numbers are the dither amplitude in x and y
        detector coordinates in arcseconds, and the second two numbers are
        the dither period in x and y detector coordinates in seconds.
        Default: [8.0, 8.0, 1000.0, 707.0].
    subpixel_res: boolean, optional
        If True, event positions are not randomized within the pixels
        within which they are detected. Default: False
    input_pt_sources : string, optional
        If set to a filename, input the point source positions, fluxes,
        and spectral indices from an ASCII table instead of generating
        them. Default: None
    prng : :class:`~numpy.random.RandomState` object, integer, or None
        A pseudo-random number generator. Typically will only
        be specified if you have a reason to generate the same
        set of random numbers, such as for a test. Default is None,
        which sets the seed based on the system time.
    """
    if "nH" in kwargs or "absorb_model" in kwargs:
        warnings.warn("The 'nH' and 'absorb_model' keyword arguments"
                      "have been omitted. Please set the 'bkgnd_nH' "
                      "and 'bkgnd_absorb_model' values in the SOXS"
                      "configuration file if you want to change these "
                      "values. ",
                      DeprecationWarning)
    if "input_sources" in kwargs:
        warnings.warn("The 'input_sources' keyword argument has been changed "
                      "to 'input_pt_sources' and is deprecated.",
                      DeprecationWarning)
        input_pt_sources = kwargs.pop("input_sources")
    prng = parse_prng(prng)
    events, event_params = make_background(exp_time, instrument, sky_center,
                                           ptsrc_bkgnd=ptsrc_bkgnd,
                                           foreground=foreground,
                                           instr_bkgnd=instr_bkgnd,
                                           no_dither=no_dither,
                                           dither_params=dither_params,
                                           subpixel_res=subpixel_res,
                                           input_pt_sources=input_pt_sources,
                                           prng=prng)
    write_event_file(events, event_params, out_file, overwrite=overwrite)

def instrument_simulator(input_events, out_file, exp_time, instrument,
                         sky_center, overwrite=False, instr_bkgnd=True,
                         foreground=True, ptsrc_bkgnd=True,
                         bkgnd_file=None, no_dither=False,
                         dither_params=None, roll_angle=0.0,
                         subpixel_res=False, aimpt_shift=None,
                         input_pt_sources=None, prng=None):
    """
    Take unconvolved events and create an event file from them. This
    function calls generate_events to do the following:

    1. Determines which events are observed using the ARF
    2. Pixelizes the events, applying PSF effects and dithering
    3. Determines energy channels using the RMF

    and then calls make_background to add instrumental and astrophysical
    backgrounds, unless a background file is provided, in which case
    the background events are read from this file. The events are
    then written out to a file.

    Parameters
    ----------
    input_events : string, dict, or None
        The unconvolved events to be used as input. Can be one of the
        following:
        1. The name of a SIMPUT catalog file.
        2. A Python dictionary containing the following items:
        "ra": A NumPy array of right ascension values in degrees.
        "dec": A NumPy array of declination values in degrees.
        "energy": A NumPy array of energy values in keV.
        "flux": The flux of the entire source, in units of erg/cm**2/s.
    out_file : string
        The name of the event file to be written.
    exp_time : float, (value, unit) tuple, or :class:`~astropy.units.Quantity`
        The exposure time to use, in seconds.
    instrument : string
        The name of the instrument to use, which picks an instrument
        specification from the instrument registry.
    sky_center : array, tuple, or list
        The center RA, Dec coordinates of the observation, in degrees.
    overwrite : boolean, optional
        Whether or not to overwrite an existing file with the same name.
        Default: False
    instr_bkgnd : boolean, optional
        Whether or not to include the instrumental/particle background.
        Default: True
    foreground : boolean, optional
        Whether or not to include the local foreground.
        Default: True
    ptsrc_bkgnd : boolean, optional
        Whether or not to include the point-source background.
        Default: True
    bkgnd_file : string, optional
        If set, backgrounds will be loaded from this file and not generated
        on the fly. Default: None
    no_dither : boolean, optional
        If True, turn off dithering entirely. Default: False
    dither_params : array-like of floats, optional
        The parameters to use to control the size and period of the dither
        pattern. The first two numbers are the dither amplitude in x and y
        detector coordinates in arcseconds, and the second two numbers are
        the dither period in x and y detector coordinates in seconds.
        Default: [8.0, 8.0, 1000.0, 707.0].
    roll_angle : float, (value, unit) tuple, or :class:`~astropy.units.Quantity`, optional
        The roll angle of the observation in degrees. Default: 0.0
    subpixel_res: boolean, optional
        If True, event positions are not randomized within the pixels
        within which they are detected. Default: False
    aimpt_shift : array-like, optional
        A two-float array-like object which shifts the aimpoint on the
        detector from the nominal position. Units are in arcseconds.
        Default: None, which results in no shift from the nominal aimpoint.
    input_pt_sources : string, optional
        If set to a filename, input the point source positions, fluxes,
        and spectral indices from an ASCII table instead of generating
        them. Default: None
    prng : :class:`~numpy.random.RandomState` object, integer, or None
        A pseudo-random number generator. Typically will only
        be specified if you have a reason to generate the same
        set of random numbers, such as for a test. Default is None,
        which sets the seed based on the system time.

    Examples
    --------
    >>> instrument_simulator("sloshing_simput.fits", "sloshing_evt.fits",
    ...                      300000.0, "lynx_hdxi", [30., 45.], overwrite=True)
    """
    from soxs.background import add_background_from_file
    if not out_file.endswith(".fits"):
        out_file += ".fits"
    mylog.info(f"Making observation of source in {out_file}.")
    # Make the source first
    events, event_params = generate_events(input_events, exp_time, instrument, sky_center,
                                           no_dither=no_dither, dither_params=dither_params,
                                           roll_angle=roll_angle, subpixel_res=subpixel_res,
                                           aimpt_shift=aimpt_shift, prng=prng)
    # If the user wants backgrounds, either make the background or add an already existing
    # background event file. It may be necessary to reproject events to a new coordinate system.
    if bkgnd_file is None:
        if not instr_bkgnd and not ptsrc_bkgnd and not foreground:
            mylog.info("No backgrounds will be added to this observation.")
        else:
            mylog.info("Adding background events.")
            bkg_events, _ = make_background(
                exp_time, instrument, sky_center, foreground=foreground,
                instr_bkgnd=instr_bkgnd, no_dither=no_dither,
                dither_params=dither_params, ptsrc_bkgnd=ptsrc_bkgnd, prng=prng,
                subpixel_res=subpixel_res, roll_angle=roll_angle,
                aimpt_shift=aimpt_shift, input_pt_sources=input_pt_sources)
            for key in events:
                events[key] = np.concatenate([events[key], bkg_events[key]])
    else:
        mylog.info(f"Adding background events from the file {bkgnd_file}.")
        if not os.path.exists(bkgnd_file):
            raise IOError(f"Cannot find the background event file {bkgnd_file}!")
        events = add_background_from_file(events, event_params, bkgnd_file)
    if len(events["energy"]) == 0:
        mylog.warning("No events were detected from source or background!! We "
                      "will not write an event file.")
    else:
        write_event_file(events, event_params, out_file, overwrite=overwrite)
    mylog.info("Observation complete.")

def simulate_spectrum(spec, instrument, exp_time, out_file,
                      instr_bkgnd=False, foreground=False,
                      ptsrc_bkgnd=False, bkgnd_area=None,
                      overwrite=False, prng=None, **kwargs):
    """
    Generate a PI or PHA spectrum from a :class:`~soxs.spectra.Spectrum`
    by convolving it with responses. To be used if one wants to
    create a spectrum without worrying about spatial response. Similar
    to XSPEC's "fakeit".

    Parameters
    ----------
    spec : :class:`~soxs.spectra.Spectrum`
        The spectrum to be convolved. If None is supplied, only backgrounds
        will be simulated (if they are turned on).
    instrument : string
        The name of the instrument to use, which picks an instrument
        specification from the instrument registry.
    exp_time : float, (value, unit) tuple, or :class:`~astropy.units.Quantity`
        The exposure time in seconds.
    out_file : string
        The file to write the spectrum to.
    instr_bkgnd : boolean, optional
        Whether or not to include the instrumental/particle background.
        Default: False
    foreground : boolean, optional
        Whether or not to include the local foreground.
        Default: False
    ptsrc_bkgnd : boolean, optional
        Whether or not to include the unresolved point-source background.
        Default: False
    bkgnd_area : float, (value, unit) tuple, or :class:`~astropy.units.Quantity`
        The area on the sky for the background components, in square arcminutes.
        Default: None, necessary to specify if any of the background components
        are turned on.
    overwrite : boolean, optional
        Whether or not to overwrite an existing file. Default: False
    prng : :class:`~numpy.random.RandomState` object, integer, or None
        A pseudo-random number generator. Typically will only
        be specified if you have a reason to generate the same
        set of random numbers, such as for a test. Default is None,
        which sets the seed based on the system time.

    Examples
    --------
    >>> spec = soxs.Spectrum.from_file("my_spectrum.txt")
    >>> soxs.simulate_spectrum(spec, "lynx_lxm", 100000.0,
    ...                        "my_spec.pi", overwrite=True)
    """
    from soxs.events import _write_spectrum
    from soxs.response import RedistributionMatrixFile, \
        AuxiliaryResponseFile
    from soxs.spectra import ConvolvedSpectrum
    from soxs.background.spectra import BackgroundSpectrum
    from soxs.background.diffuse import read_instr_spectrum, \
        make_frgnd_spectrum, generate_channel_spectrum
    from soxs.utils import soxs_cfg
    if "nH" in kwargs or "absorb_model" in kwargs:
        warnings.warn("The 'nH' and 'absorb_model' keyword arguments"
                      "have been omitted. Please set the 'bkgnd_nH' "
                      "and 'bkgnd_absorb_model' values in the SOXS"
                      "configuration file if you want to change these "
                      "values. ",
                      DeprecationWarning)
    prng = parse_prng(prng)
    exp_time = parse_value(exp_time, "s")
    try:
        instrument_spec = instrument_registry[instrument]
    except KeyError:
        raise KeyError(f"Instrument {instrument} is not in the instrument registry!")
    if foreground or instr_bkgnd or ptsrc_bkgnd:
        if instrument_spec["grating"]:
            raise NotImplementedError("Backgrounds cannot be included in simulations "
                                      "of gratings spectra at this time!")
        if bkgnd_area is None:
            raise RuntimeError("The 'bkgnd_area' argument must be set if one wants "
                               "to simulate backgrounds! Specify a value in square "
                               "arcminutes.")
        bkgnd_area = np.sqrt(parse_value(bkgnd_area, "arcmin**2"))
    elif spec is None:
        raise RuntimeError("You have specified no source spectrum and no backgrounds!")
    arf_file = get_data_file(instrument_spec["arf"])
    rmf_file = get_data_file(instrument_spec["rmf"])
    arf = AuxiliaryResponseFile(arf_file)
    rmf = RedistributionMatrixFile(rmf_file)

    event_params = {"RESPFILE": os.path.split(rmf.filename)[-1],
                    "ANCRFILE": os.path.split(arf.filename)[-1],
                    "TELESCOP": rmf.header["TELESCOP"],
                    "INSTRUME": rmf.header["INSTRUME"],
                    "MISSION": rmf.header.get("MISSION", "")}

    out_spec = np.zeros(rmf.n_ch)

    if spec is not None:
        cspec = ConvolvedSpectrum.convolve(spec, arf)
        out_spec += rmf.convolve_spectrum(cspec, exp_time, prng=prng)

    fov = None if bkgnd_area is None else np.sqrt(bkgnd_area)

    if foreground:
        mylog.info("Adding in astrophysical foreground.")
        frgnd_spec = rmf.convolve_spectrum(make_frgnd_spectrum(arf, rmf),
            exp_time, noisy=False, rate=True)
        out_spec += generate_channel_spectrum(frgnd_spec, exp_time, bkgnd_area,
                                              prng=prng)
    if instr_bkgnd and instrument_spec["bkgnd"] is not None:
        mylog.info("Adding in instrumental background.")
        bkgnd_spec = instrument_spec["bkgnd"]
        # Temporary hack for ACIS-S
        if "aciss" in instrument_spec["name"]:
            bkgnd_spec = bkgnd_spec[1]
        bkgnd_spec = read_instr_spectrum(bkgnd_spec[0], bkgnd_spec[1])
        out_spec += generate_channel_spectrum(bkgnd_spec, exp_time, bkgnd_area,
                                              prng=prng)
    if ptsrc_bkgnd:
        mylog.info("Adding in background from unresolved point-sources.")
        bkgnd_nH = float(soxs_cfg.get("soxs", "bkgnd_nH"))
        absorb_model = soxs_cfg.get("soxs", "bkgnd_absorb_model")
        spec_plaw = BackgroundSpectrum.from_powerlaw(1.52, 0.0, 2.0e-7, emin=0.01,
                                                     emax=10.0, nbins=300000)
        spec_plaw.apply_foreground_absorption(bkgnd_nH, model=absorb_model)
        cspec_plaw = ConvolvedSpectrum.convolve(spec_plaw.to_spectrum(fov), arf)
        out_spec += rmf.convolve_spectrum(cspec_plaw, exp_time, prng=prng)

    bins = (np.arange(rmf.n_ch)+rmf.cmin).astype("int32")

    _write_spectrum(bins, out_spec, exp_time, rmf.header["CHANTYPE"],
                    event_params, out_file, overwrite=overwrite)


import flavio
from wilson import Wilson, wcxf
from flavio.statistics.likelihood import Likelihood, FastLikelihood
from flavio.statistics.probability import NormalDistribution
from flavio.statistics.functions import pull, pvalue
import warnings
import pandas as pd
import numpy as np
from collections import OrderedDict
from math import ceil
from .util import tree, get_datapath, multithreading_map
from .ckm import get_ckm_schemes
from multipledispatch import dispatch
from copy import copy
import os
from functools import partial
from operator import itemgetter
from numbers import Number
import inspect
from flavio.math.optimize import minimize_robust
from smelli import _flavio_up_to_date
from itertools import chain

# by default, smelli uses leading log accuracy for SMEFT running!
Wilson.set_default_option('smeft_accuracy', 'leadinglog')

class GlobalLikelihood(object):
    """Class that provides a global likelihood in SMEFT Wilson
    coefficient space.

    User methods:

    - `log_likelihood`: return an instance of LieklihoodResult
    given a dictionary of Wilson coefficients at a given scale
    - `log_likelihood_wcxf`: return an instance of LieklihoodResult
    given the path to a WCxf file
    - `log_likelihood_wilson`: return an instance of LieklihoodResult+
    given an instance of `wilson.Wilson`

    Utility methods:

    - `make_measurement`: compute the SM covariances. Note that it is only
    necessary to call this method when changes to the default
    parameters/uncertainties have been made
    - `save_sm_covariances`, `load_sm_covariances`: Save the calculated SM
    covariances or load them from data files
    - `save_exp_covariances`, `load_exp_covariances`: Save the calculated
    experimental central values and covariances or load them from data files

    """

    _default_bases = {'SMEFT': 'Warsaw', 'WET': 'flavio'}

    _fast_likelihoods_yaml_fixckm = [
        'fast_likelihood_quarks_fixckm.yaml',
        'fast_likelihood_leptons.yaml'
    ]

    _fast_likelihoods_yaml = [
        'fast_likelihood_quarks.yaml',
        'fast_likelihood_leptons.yaml'
    ]

    _likelihoods_yaml = [
        'likelihood_ewpt.yaml',
        'likelihood_eeww.yaml',
        'likelihood_lept.yaml',
        'likelihood_rd_rds.yaml',
        'likelihood_lfu_fccc.yaml',
        'likelihood_lfu_fcnc.yaml',
        'likelihood_bcpv.yaml',
        'likelihood_bqnunu.yaml',
        'likelihood_lfv.yaml',
        'likelihood_zlfv.yaml',
        'likelihood_higgs.yaml',
    ]

    def __init__(self, eft='SMEFT', basis=None,
                 par_dict=None,
                 include_likelihoods=None,
                 exclude_likelihoods=None,
                 Nexp=5000,
                 exp_cov_folder=None,
                 sm_cov_folder=None,
                 custom_likelihoods=None,
                 fix_ckm=False,
                 ckm_scheme='CKMSchemeRmuBtaunuBxlnuDeltaM'):
        """Initialize the likelihood.

        Optionally, a dictionary of parameters can be passed as `par_dict`.
        If not given (or not complete), flavio default parameter values will
        be used. Note that the CKM elements in `par_dict` will be ignored as
        the "true" CKM elements will be extracted for each parameter point
        from the measurement of four input observables:
        - `'RKpi(P+->munu)'`
        - `'BR(B+->taunu)'`
        - `'BR(B->Xcenu)'`
        - `'DeltaM_d/DeltaM_s'`

        Parameters:

        - eft: a WCxf EFT, must be one of 'SMEFT' (default) or 'WET'.
        - basis: a WCxf basis, defaults to 'Warsaw' for SMEFT and 'flavio'
          for WET.
        - include_likelihoods: a list of strings specifying the likelihoods
          to be included (default: all of them). Note that this cannot be used
          to add likelihoods.
        - exclude_likelihoods: a list of strings specifying the likelihoods
          to be excluded (default: none of them).
        - Nexp: number of random evaluations of the experimental likelihood
          used to extract the covariance matrix for "fast likelihood"
          instances. Defaults to 5000.
        - exp_cov_folder: directory containing saved expererimental
          covariances. The data files have to be in the format exported by
          `save_exp_covariances`.
        - sm_cov_folder: directory containing saved SM
          covariances. The data files have to be in the format exported by
          `save_sm_covariances`.
        - custom_likelihoods: a dictionary in which each value is a list of
          observables and each key is a string that serves as user-defined
          name. For each item of the dictionary, a custom likelihood will be
          computed.
        - fix_ckm: If False (default), automatically determine the CKM elements
          in the presence of new physics in processes used to determine these
          elements in the SM. If set to True, the CKM elements are fixed to
          their SM values, which can lead to inconsistent results, but also
          to a significant speedup in specific cases.
        - ckm_scheme: A string with the name of the class defining the CKM
          scheme.
        """
        self.eft = eft
        self.basis = basis or self._default_bases[self.eft]
        par_dict = par_dict or {}  # initialize empty if not given
        # take missing parameters from flavio defaults
        self.par_dict_default = flavio.default_parameters.get_central_all()
        self.par_dict_default.update(par_dict)
        self._par_dict_sm = None
        self._observables = None
        self.fix_ckm = fix_ckm
        if self.fix_ckm:
            self._fast_likelihoods_yaml = self._fast_likelihoods_yaml_fixckm
        try:
            self._ckm_scheme = get_ckm_schemes()[ckm_scheme]
            self._ckm_scheme_name = ckm_scheme
        except:
            raise ValueError("CKM scheme '{}' is not defined.".format(ckm_scheme))
        self.likelihoods = {}
        self.fast_likelihoods = {}
        self._custom_likelihoods_dict = custom_likelihoods or {}
        self.custom_likelihoods = {}
        self._load_likelihoods(include_likelihoods=include_likelihoods,
                               exclude_likelihoods=exclude_likelihoods)
        self._all_likelihoods = {
            'global': _global_llh(self),
            **self.fast_likelihoods,
            **self.likelihoods,
            **self.custom_likelihoods,
        }
        self._Nexp = Nexp
        if exp_cov_folder is not None:
            self.load_exp_covariances(exp_cov_folder)
        self._sm_cov_loaded = False
        try:
            if sm_cov_folder is None:
                self.load_sm_covariances(get_datapath('smelli', 'data/cache'))
            else:
                self.load_sm_covariances(sm_cov_folder)
            self._sm_cov_loaded = True
            self.make_measurement()
        except (KeyboardInterrupt, SystemExit):
            raise
        except:
            warnings.warn("There was a problem loading the SM covariances. "
                          "Please recompute them with `make_measurement`.")
        self._log_likelihood_sm = None
        self._obstable_sm = None

    def _load_likelihoods(self,
                          include_likelihoods=None,
                          exclude_likelihoods=None):
        if include_likelihoods is not None and exclude_likelihoods is not None:
            raise ValueError("include_likelihoods and exclude_likelihoods "
                             "should not be specified simultaneously.")
        for argument_name, argument in [('exclude_likelihoods', exclude_likelihoods), ('include_likelihoods',include_likelihoods)]:
            if argument:
                unknown_likelihoods = set(argument) - set(self._fast_likelihoods_yaml + self._likelihoods_yaml)
                if unknown_likelihoods:
                    raise ValueError("{} contains unknown likelihoods: {}".format(argument_name,unknown_likelihoods))
        # load ckm parameters for given CKM scheme
        par_ckm_file = 'par_ckm_{}.yaml'.format(self._ckm_scheme_name)
        with open(self._get_yaml_path(par_ckm_file), 'r') as f:
            par_ckm_dict = flavio.io.yaml.load_include(f)
        for fn in self._fast_likelihoods_yaml:
            if include_likelihoods is not None and fn not in include_likelihoods:
                continue
            if exclude_likelihoods is not None and fn in exclude_likelihoods:
                continue
            with open(self._get_yaml_path(fn), 'r') as f:
                yaml_dict = flavio.io.yaml.load_include(f)
                if not self.fix_ckm:
                    yaml_dict['par_obj'] = par_ckm_dict
                try:
                    L = FastLikelihood.load_dict(yaml_dict)
                    meas_yaml = set(yaml_dict['include_measurements'])
                    meas_loaded = set(L.full_measurement_likelihood.get_measurements)
                    meas_missing = meas_yaml-meas_loaded
                except AssertionError as e:
                    to_upgrade = 'smelli' if _flavio_up_to_date else 'flavio'
                    raise AssertionError('{}. Please upgrade {} to the latest version.'.format(e,to_upgrade))
                if meas_missing:
                    to_upgrade = 'smelli' if _flavio_up_to_date else 'flavio'
                    raise AssertionError('The measurements {} have not been found. Please upgrade {} to the latest version.'.format(meas_missing,to_upgrade))
            self.fast_likelihoods[fn] = L
        for fn in self._likelihoods_yaml:
            if include_likelihoods is not None and fn not in include_likelihoods:
                continue
            if exclude_likelihoods is not None and fn in exclude_likelihoods:
                continue
            if self.eft != 'SMEFT' and fn in ['likelihood_ewpt.yaml',
                                              'likelihood_eeww.yaml',
                                              'likelihood_zlfv.yaml',
                                              'likelihood_higgs.yaml',]:
                continue
            with open(self._get_yaml_path(fn), 'r') as f:
                yaml_dict = flavio.io.yaml.load_include(f)
                try:
                    L = Likelihood.load_dict(yaml_dict)
                    meas_yaml = set(yaml_dict['include_measurements'])
                    meas_loaded = set(L.measurement_likelihood.get_measurements)
                    meas_missing = meas_yaml-meas_loaded
                except AssertionError as e:
                    to_upgrade = 'smelli' if _flavio_up_to_date else 'flavio'
                    raise AssertionError('{}. Please upgrade {} to the latest version.'.format(e,to_upgrade))
                if meas_missing:
                    to_upgrade = 'smelli' if _flavio_up_to_date else 'flavio'
                    raise AssertionError('The measurements {} have not been found. Please upgrade {} to the latest version.'.format(meas_missing,to_upgrade))
            self.likelihoods[fn] = L
        for name, observables in self._custom_likelihoods_dict.items():
            L = CustomLikelihood(self, observables)
            self.custom_likelihoods['custom_' + name] = L

    def _get_yaml_path(self, name):
        """Return a path for the YAML file specified by `name`.
        If a YAML file with that name is found in the package's data
        directory, that is used. Otherwise, `name` is assumed to be a path.

        Raises `FileNotFoundError` if path does not exists.
        """
        path = get_datapath('smelli', 'data/yaml/' + name)
        if os.path.exists(path):
            return path
        path = get_datapath('smelli', 'data/yaml/' + name + '.yaml')
        if os.path.exists(path):
            return path
        if os.path.exists(name):
            return name
        if os.path.exists(name + '.yaml'):
            return name + '.yaml'
        else:
            raise FileNotFoundError("Likelihood YAML file '{}' was not found".format(name))

    def make_measurement(self, *args, **kwargs):
        """Initialize the likelihood by producing a pseudo-measurement containing both
        experimental uncertainties as well as theory uncertainties stemming
        from nuisance parameters.

        Optional parameters:

        - `N`: number of random computations for the SM covariance (computing
          time is proportional to it; more means less random fluctuations.)
        - `Nexp`: number of random computations for the experimental covariance.
          This is much less expensive than the theory covariance, so a large
          number can be afforded (default: 5000).
        - `threads`: number of parallel threads for the SM
          covariance computation. Defaults to 1 (no parallelization).
        - `force`: if True, will recompute SM covariance even if it
          already has been computed. Defaults to False.
        - `force_exp`: if True, will recompute experimental central values and
          covariance even if they have already been computed. Defaults to False.
        """
        if 'Nexp' not in kwargs:
            kwargs['Nexp'] = self._Nexp
        for name, flh in self.fast_likelihoods.items():
            flh.make_measurement(*args, **kwargs)
        self._sm_cov_loaded = True

    def save_sm_covariances(self, folder):
        for name, flh in self.fast_likelihoods.items():
            if self.fix_ckm:
                name = name + '_fix_ckm'
            else:
                name = name + '_' + self._ckm_scheme_name
            filename = os.path.join(folder, name + '.p')
            flh.sm_covariance.save(filename)

    def load_sm_covariances(self, folder):
        for name, flh in self.fast_likelihoods.items():
            if self.fix_ckm:
                name = name + '_fix_ckm'
            else:
                name = name + '_' + self._ckm_scheme_name
            filename = os.path.join(folder, name + '.p')
            flh.sm_covariance.load(filename)

    def save_exp_covariances(self, folder):
        for name, flh in self.fast_likelihoods.items():
            filename = os.path.join(folder, name + '.p')
            flh.exp_covariance.save(filename)

    def load_exp_covariances(self, folder):
        for name, flh in self.fast_likelihoods.items():
            filename = os.path.join(folder, name + '.p')
            flh.exp_covariance.load(filename)

    @property
    def observables(self):
        if self._observables is None:
            self._observables = set.union(*(
                set(lh.observables) for lh in chain(
                    self.fast_likelihoods.values(),
                    self.likelihoods.values()
                )
            ))
        return self._observables

    @property
    def log_likelihood_sm(self):
        if self._log_likelihood_sm is None:
            self._log_likelihood_sm = self._log_likelihood(self.par_dict_sm, flavio.WilsonCoefficients())
        return self._log_likelihood_sm

    def _check_sm_cov_loaded(self):
        """Check if the SM covariances have been computed or loaded."""
        if not self._sm_cov_loaded:
            raise ValueError("Please load or compute the SM covariances first"
                             " by calling `make_measurement`.")

    def get_ckm_sm(self):
        Vus, Vcb, Vub, delta = self._ckm_scheme.ckm_np(w=None)
        return {'Vus': Vus, 'Vcb': Vcb, 'Vub': Vub, 'delta': delta}

    @property
    def par_dict_sm(self):
        """Return the dictionary of parameters where the four CKM parameters
        `Vus`, `Vcb`, `Vub`, `delta` have been replaced by their
        "true" values extracted assuming the SM.
        They should be almost (but not exactly) equal to the default
        flavio CKM parameters.

        Note that if `fix_ckm` is set to `True`, this method actually
        returns the default parameter values.
        """
        if self.fix_ckm:
            return self.par_dict_default
        if self._par_dict_sm is None:
            par_dict_sm = self.par_dict_default.copy()
            par_dict_sm.update(self.get_ckm_sm())
            self._par_dict_sm = par_dict_sm
        return self._par_dict_sm

    @property
    def obstable_sm(self):
        self._check_sm_cov_loaded()
        if self._obstable_sm is None:
            info = tree()  # nested dict
            for flh_name, flh in self.fast_likelihoods.items():
                # loop over fast likelihoods: they only have a single "measurement"
                m = flh.pseudo_measurement
                ml = flh.full_measurement_likelihood
                pred_sm = ml.get_predictions_par(self.par_dict_sm,
                                                 flavio.WilsonCoefficients())
                sm_cov = flh.sm_covariance.get(force=False)
                _, exp_cov = flh.exp_covariance.get(force=False)
                inspire_dict = self._get_inspire_dict(flh.observables, ml)
                for i, obs in enumerate(flh.observables):
                    info[obs]['lh_name'] = flh_name
                    info[obs]['name'] = obs if isinstance(obs, str) else obs[0]
                    info[obs]['th. unc.'] = np.sqrt(sm_cov[i, i])
                    info[obs]['experiment'] = m.get_central(obs)
                    info[obs]['exp. unc.'] = np.sqrt(exp_cov[i, i])
                    info[obs]['exp. PDF'] = NormalDistribution(m.get_central(obs), np.sqrt(exp_cov[i, i]))
                    info[obs]['inspire'] = sorted(set(inspire_dict[obs]))
                    info[obs]['ll_sm'] = m.get_logprobability_single(obs, pred_sm[obs])
                    info[obs]['ll_central'] = m.get_logprobability_single(obs, m.get_central(obs))
            for lh_name, lh in self.likelihoods.items():
                # loop over "normal" likelihoods
                ml = lh.measurement_likelihood
                pred_sm = ml.get_predictions_par(self.par_dict_sm,
                                                 flavio.WilsonCoefficients())
                inspire_dict = self._get_inspire_dict(lh.observables, ml)
                for i, obs in enumerate(lh.observables):
                    obs_dict = flavio.Observable.argument_format(obs, 'dict')
                    obs_name = obs_dict.pop('name')
                    with warnings.catch_warnings():
                        warnings.simplefilter("ignore")
                        p_comb = flavio.combine_measurements(
                            obs_name,
                            include_measurements=ml.get_measurements,
                            **obs_dict)
                    info[obs]['experiment'] = p_comb.central_value
                    info[obs]['exp. unc.'] = max(p_comb.error_left, p_comb.error_right)
                    info[obs]['exp. PDF'] = p_comb
                    info[obs]['inspire'] = sorted(set(inspire_dict[obs]))
                    info[obs]['th. unc.'] = 0
                    info[obs]['lh_name'] = lh_name
                    info[obs]['name'] = obs if isinstance(obs, str) else obs[0]
                    info[obs]['ll_sm'] = p_comb.logpdf([pred_sm[obs]])
                    if info[obs]['ll_sm'] == -np.inf:
                        info[obs]['ll_sm'] = -1e100
                    info[obs]['ll_central'] = p_comb.logpdf([p_comb.central_value])
            self._obstable_sm = info
        return self._obstable_sm

    def get_wilson(self, wc_dict, scale):
        return Wilson(wc_dict, scale=scale, eft=self.eft, basis=self.basis)

    def _log_likelihood(self, par_dict, w):
        """Return the log-likelihood as a dictionary for an instance of
        `wilson.Wilson`."""
        ll = {}
        for name, flh in self.fast_likelihoods.items():
            ll[name] = flh.log_likelihood(par_dict, w, delta=True)
        for name, lh in self.likelihoods.items():
            ll[name] = lh.log_likelihood(par_dict, w, delta=True)
        for name, clh in self.custom_likelihoods.items():
            ll[name] = clh.log_likelihood(par_dict, w, delta=True)
        return ll

    @dispatch(dict)
    def parameter_point(self, wc_dict, scale=None):
        """Choose a point in parameter space by providing a dictionary of
        Wilson coefficient values (with keys corresponding to WCxf Wilson
        coefficient names) and the input scale."""
        if not scale:
            raise ValueError("You need to provide a scale")
        w = self.get_wilson(wc_dict, scale)
        return GlobalLikelihoodPoint(self, w, fix_ckm=self.fix_ckm)

    @dispatch(dict, (int, float))
    def parameter_point(self, wc_dict, scale):
        """Choose a point in parameter space by providing a dictionary of
        Wilson coefficient values (with keys corresponding to WCxf Wilson
        coefficient names) and the input scale."""
        w = self.get_wilson(wc_dict, scale)
        return GlobalLikelihoodPoint(self, w, fix_ckm=self.fix_ckm)

    @dispatch(str)
    def parameter_point(self, filename):
        """Choose a point in parameter space by providing the path to a WCxf
        file."""
        with open(filename, 'r') as f:
            wc = wcxf.WC.load(f)
        w = Wilson.from_wc(wc)
        return GlobalLikelihoodPoint(self, w, fix_ckm=self.fix_ckm)

    @dispatch(Wilson)
    def parameter_point(self, w):
        """Choose a point in parameter space by providing an instance
        of `wilson.Wilson`."""
        return GlobalLikelihoodPoint(self, w, fix_ckm=self.fix_ckm)

    @staticmethod
    def _get_inspire_dict(observables, ml):
        inspire_dict = {}
        obs_set = set(observables)
        for m_name in ml.get_measurements:
            m_obj = flavio.Measurement[m_name]
            for obs in set(m_obj.all_parameters) & obs_set:
                if obs in inspire_dict:
                    inspire_dict[obs].append(m_obj.inspire)
                else:
                    inspire_dict[obs]=[m_obj.inspire]
        return inspire_dict

    def number_observations_dict(self, exclude_observables=None):
        """Get a dictionary of the number of "observations" for each
        sublikelihood.

        Here, an "observation" is defined as an individual measurment
        of an observable. Thus, the number of observations is always
        >= the number of observables.
        """
        nobs_dict = {}
        for name, flh in self.fast_likelihoods.items():
            nobs_dict[name] = len(set(flh.observables) - set(exclude_observables or []))
        for name, lh in self.likelihoods.items():
            ml =  lh.measurement_likelihood
            nobs_dict[name] = ml.get_number_observations(
                exclude_observables=exclude_observables
            )
        for name, clh in self.custom_likelihoods.items():
            nobs_dict[name] = clh.get_number_observations()
        nobs_dict['global'] = sum([v for k, v in nobs_dict.items() if 'custom_' not in k])
        return nobs_dict

    def plot_data_2d(self,
                     wc_fct,
                     scale,
                     x_min, x_max, y_min, y_max,
                     x_log=False, y_log=False,
                     steps=20,
                     threads=1,
                     pool=None):
        """Compute the likelihood on a grid of two non-zero Wilson coefficients.

        This method is meant for producing contour plots with `flavio.plots`
        and `matplotlib` in the plane of two Wilson coefficients.

        Parameters:

        - `wc_fct`: function with two arguments x and y returning a dictionary
                    with Wilson coefficients
        - `scale`: either a function with two arguments x and y returning the
                   renormalization scale in GeV, or a numerical value fixing the
                   scale
        - `x_min`: minimum value of Wilson coefficient on x axis
        - `x_max`: maximum value of Wilson coefficient on x axis
        - `y_min`: minimum value of Wilson coefficient on y axis
        - `y_max`: maximum value of Wilson coefficient on y axis
        - `x_log`: boolean specifying whether logspace should be used for x
                   values (default: False)
        - `y_log`: boolean specifying whether logspace should be used for y
                   values (default: False)
        - `steps`: number of steps in each direction. The computing time scales
                   with the square of this number. (default: 20)
        - `threads`: number of threads for parallel computation (default: 1)
        - `pool`: either `None` or `pool` object for parallel computation. If
                  `pool` object is provided, `threads` is ignored.
                  (default: None)

        Returns:

        A dictionary of the  form
        `{'likelihood_A': dat_A, 'likelihood_B': dat_B, ...}`
        where `'likelihood_A'` etc. are the names of the sub- and custom
        likelihoods (as return by `GlobalLikelihoodPoint.log_likelihood_dict`)
        and `dat_A` etc. are dictionaries with the keys `x`, `y`, `z`, that
        can be directly fed to the `flavio.plots.contour` plot function.
        """
        if x_log:
            _x = np.logspace(x_min, x_max, steps)
        else:
            _x = np.linspace(x_min, x_max, steps)
        if y_log:
            _y = np.logspace(y_min, y_max, steps)
        else:
            _y = np.linspace(y_min, y_max, steps)
        x, y = np.meshgrid(_x, _y)
        xy = np.array([x, y]).reshape(2, steps**2).T
        xy_enumerated = list(enumerate(xy))
        if isinstance(scale,Number):
            scale_fct = partial(_scale_fct_fixed, scale=scale)
        else:
            scale_fct = scale
        ll = partial(_log_likelihood_2d, gl=self, wc_fct=wc_fct, scale_fct=scale_fct)
        ll_dict_list_enumerated = multithreading_map(ll, xy_enumerated,
            threads=threads, pool=pool)
        ll_dict_list = [
            ll_dict[1] for ll_dict in
            sorted(ll_dict_list_enumerated, key=itemgetter(0))
        ]
        plotdata = {}
        keys = ll_dict_list[0].keys()  # look at first dict to fix keys
        for k in keys:
            z = -2 * np.array([ll_dict[k] for ll_dict in ll_dict_list]).reshape((steps, steps))
            plotdata[k] = {'x': x, 'y': y, 'z': z}
        return plotdata

    def chi2_min(self,
                 wc_fct,
                 scale,
                 methods = ('SLSQP', 'MIGRAD', 'L-BFGS-B'),
                 include_likelihoods=None,
                 exclude_likelihoods=None,
                 plotdata=None,
                 initial_guesses=None,
                 n=None,
                 threads=1,
                 pool=None):
        """Find the minimum of -2*log-likelihood for a given function of Wilson
           coefficients and a scale.

        Parameters:

        - `wc_fct`: function with either n>=1 arguments or one argument being
          an array of length n>1 and which returns a dictionary with Wilson
          coefficients.
        - `scale`: either a function returning the renormalization scale in GeV,
          or a numerical value fixing the scale. If it is a function, it must
          have either n>=1 arguments or one argument being an array of length
          n>1.
        - methods: tuple of methods to try consecutively. (default:
          `('SLSQP', 'MIGRAD', 'L-BFGS-B')`)
        - include_likelihoods: a list of strings specifying the likelihoods
          to be included (default: all of them).
        - exclude_likelihoods: a list of strings specifying the likelihoods to
          be excluded (default: none of them).
        - `plotdata`: the result of `plot_data_2d` that will be used for
          extracting the initial guesses for the minimization in the 2D case
          (default: None)
        - `initial_guesses`: a dictionary with initial guesses for the
          minimization. The keys are strings with the names of the individual
          likelihoods, the values are arrays (or lists) of length n. This
          overrides initial guesses extracted from `plotdata` (default: None)
        - `n`: number of variables. Has to be provided only if `wc_fct` has a
           single argument (an array of length n) and neither `plotdata` nor
          `initial_guesses` is given. (default: None)
        - `threads`: number of threads for parallel computation (default: 1)
        - `pool`: either `None` or `pool` object for parallel computation. If
          `pool` object is provided, `threads` is ignored. (default: None)

        Returns:

        A dictionary of the  form
        `{'likelihood_A': dat_A, 'likelihood_B': dat_B, ...}`
        where `'likelihood_A'` etc. are the names of the sub- and custom
        likelihoods (as return by `GlobalLikelihoodPoint.log_likelihood_dict`)
        and `dat_A` etc. are dictionaries with the keys `z_min`, `coords_min`,
        where the values of `z_min` and `coords_min` are a number and an array
        of length n, respectively.
        """
        if include_likelihoods is not None and exclude_likelihoods is not None:
            raise ValueError("include_likelihoods and exclude_likelihoods "
                             "should not be specified simultaneously.")
        if isinstance(scale,Number):
            scale_fct = partial(_scale_fct_fixed, scale=scale)
        else:
            scale_fct = scale
        likelihoods = {
            k for k in self._all_likelihoods.keys()
            if ((include_likelihoods is None and exclude_likelihoods is None)
            or (include_likelihoods is not None and k in include_likelihoods)
            or (exclude_likelihoods is not None and k not in exclude_likelihoods))
        }
        n_fct = len(inspect.getfullargspec(wc_fct).args)
        if plotdata is not None:
            n_args = 2
            if n is not None:
                warnings.warn(f"Since `plotdata` is provided, `n={n}` "
                              " is ignored.")
            _initial_guesses = {k: np.zeros(n_args) for k in likelihoods}
            for k in _initial_guesses.keys():
                x,y,z = (plotdata[k][i] for i in 'xyz')
                minimum = (z == np.min(z))
                _initial_guesses[k] = [np.median(x[minimum]), np.median(y[minimum])]
            if initial_guesses is not None:
                _initial_guesses.update(initial_guesses)
        elif initial_guesses is not None:
            if n is not None:
                warnings.warn(f"Since `initial_guesses` is provided, `n={n}` "
                              " is ignored.")
            n_args = len(next(iter(initial_guesses.values())))
            _initial_guesses = {k: np.zeros(n_args) for k in likelihoods}
            _initial_guesses.update(initial_guesses)
        elif n_fct > 1:
            n_args = n_fct
            if n is not None:
                warnings.warn(f"Since `wc_fct` has {n_fct} arguments, `n={n}` "
                              " is ignored.")
            _initial_guesses = {k: np.zeros(n_args) for k in likelihoods}
        elif n is not None:
            n_args = n
            _initial_guesses = {k: np.zeros(n_args) for k in likelihoods}
        else:
            raise ValueError(
                'The number of variables `n` has to be provided as an argument '
                'if `wc_fct` has a single argument (which can be an array of '
                'length n) and neither `plotdata` nor `initial_guesses` is '
                'given.'
            )
        _initial_guesses = list(_initial_guesses.items())

        array_input = n_args != n_fct
        best_fit_point = partial(
            _best_fit_point,
            gl=self,
            wc_fct=wc_fct,
            scale_fct=scale_fct,
            array_input=array_input,
            methods=methods,
        )
        bf_list = multithreading_map(best_fit_point, _initial_guesses,
            threads=threads, pool=pool)
        bf_dict = dict(list(bf_list))
        return bf_dict

def _scale_fct_fixed(*args, scale=0):
    """
    This is a helper function that is necessary because multiprocessing requires
    a picklable (i.e. top-level) object for parallel computation.
    """
    return scale

def _log_likelihood_2d(xy_enumerated, gl, wc_fct, scale_fct):
    """Compute the likelihood on a 2D grid of 2 Wilson coefficients.

    This function is necessary because multiprocessing requires a picklable
    (i.e. top-level) object for parallel computation.
    """
    number, (x, y) = xy_enumerated
    pp = gl.parameter_point(wc_fct(x, y), scale_fct(x, y))
    ll_dict = pp.log_likelihood_dict()
    return (number, ll_dict)

def _best_fit_point(initial_guess, gl, wc_fct, scale_fct, array_input, methods):
    llh_name, x0 = initial_guess
    llh = (gl.fast_likelihoods.get(llh_name)
           or gl.likelihoods.get(llh_name)
           or gl.custom_likelihoods.get(llh_name)
           or (_global_llh(gl) if llh_name == 'global' else None))
    llh_sm = (0 if llh_name == 'global' else gl.log_likelihood_sm[llh_name])
    if array_input:
        def chi2(x):
            w = gl.get_wilson(wc_fct(x), scale_fct(x))
            gp = gl.parameter_point(w)
            try:
                res = -2*(llh.log_likelihood(gp.par_dict_np, w, delta=True)-llh_sm)
            except ValueError as e:
                if (
                    'The extraction of CKM elements failed.' in str(e)
                    or
                    'math domain error' in str(e)
                ):
                    res = np.inf
                else:
                    raise
            return res
    else:
        def chi2(x):
            w = gl.get_wilson(wc_fct(*x), scale_fct(*x))
            gp = gl.parameter_point(w)
            try:
                res = -2*(llh.log_likelihood(gp.par_dict_np, w, delta=True)-llh_sm)
            except ValueError as e:
                if (
                    'The extraction of CKM elements failed.' in str(e)
                    or
                    'math domain error' in str(e)
                ):
                    res = np.inf
                else:
                    raise
            return res
    try:
        res = minimize_robust(chi2, x0, methods=methods)
        if not res.success:
            x = [np.nan]*len(x0)
            z = np.nan
            warnings.warn("Optimization failed during computation of best-fit point of {}: {}"
            ''.format(llh_name, res.message))
        else:
            x = res.x
            z = res.fun
    except Exception as e:
        x = [np.nan]*len(x0)
        z = np.nan
        warnings.warn('\nERROR during computation of best-fit point of {}:\n{}'
                      ''.format(llh_name,e))
    return (llh_name, {'coords_min':np.array(x), 'z_min':z})

class _global_llh(object):
    def __init__(self, gl):
        self.gl = gl
    def log_likelihood(self, par_dict, w, delta):
        gp = self.gl.parameter_point(w)
        return gp.log_likelihood_global()

class CustomLikelihood(object):
    def __init__(self, likelihood, observables):
        if set(observables) - likelihood.observables:
            raise ValueError(
                'The following observables are not part of any included '
                '(fast)likelihood and thus cannot be used in a custom '
                'likelihood: {}.'.format(', '.join(
                    str(obs) for obs
                    in set(observables) - likelihood.observables
                ))
            )
        self.likelihood = likelihood
        self.observables = set(observables)
        self.exclude_obs = self._get_exclude_obs_dict()

    def _get_exclude_obs_dict(self):
        """Get a dictionary with observables to be excluded from each
        (Fast)Likelihood instance."""
        exclude_obs = {}
        for lhs_or_flhs in (self.likelihood.likelihoods,
                            self.likelihood.fast_likelihoods):
            for lh_name, lh in lhs_or_flhs.items():
                exclude_observables = set(lh.observables) - self.observables
                if set(lh.observables) != exclude_observables:
                    exclude_obs[lh_name] = exclude_observables
        return exclude_obs

    def log_likelihood(self, par_dict, wc_obj, delta=False):
        custom_log_likelihood = 0
        for lh_name, exclude_observables in self.exclude_obs.items():
            lh = (self.likelihood.fast_likelihoods.get(lh_name)
                  or self.likelihood.likelihoods.get(lh_name))
            custom_log_likelihood += lh.log_likelihood(
                par_dict, wc_obj, delta=delta,
                exclude_observables=exclude_observables
            )
        return custom_log_likelihood

    def get_number_observations(self):
        """Get the number of observations, defined as individual measurements
        of observables."""
        nobs = 0
        for llh_name, exclude_observables in self.exclude_obs.items():
            if llh_name in self.likelihood.fast_likelihoods:
                flh = self.likelihood.fast_likelihoods[llh_name]
                nobs += len(set(flh.observables) - set(exclude_observables or []))
            else:
                lh = self.likelihood.likelihoods[llh_name]
                ml =  lh.measurement_likelihood
                nobs += ml.get_number_observations(
                    exclude_observables=exclude_observables)
        return nobs

class GlobalLikelihoodPoint(object):
    """Class representing the properties of the likelihood function at a
    specific point in parameter space.

    Attributes:

    - `log_likelihood_dict`: dictionary with individual contributions
    to the log-likelihood
    - `value`: Return the numerical values of the global log-likelihood
    compared to the SM value (can also be acessed with `float(self)`)

    Methods:

    - `get_obstable`: return a pandas data frame with the values and pulls
    for each individual observable, given the Wilson coefficients
    """

    def __init__(self, likelihood, w,
                 fix_ckm=False):
        """Initialize the `GlobalLikelihoodPoint` instance.

        Parameters:
        - likelihood: an instance of `GlobalLikelihood`
        - w: an instance of `wilson.Wilson`
        - fix_ckm: If False (default), automatically determine the CKM elements
          in the presence of new physics in processes used to determine these
          elements in the SM. If set to True, the CKM elements are fixed to
          their SM values, which can lead to inconsistent results, but also
          to a significant speedup in specific cases.
        """
        self.likelihood = likelihood
        likelihood._check_sm_cov_loaded()
        self.w_input = w
        self.fix_ckm = fix_ckm
        self._w = None
        self._obstable_tree_cache = None
        self._log_likelihood_dict = None
        self._par_dict_np = None

    @property
    def w(self):
        if self._w is None:
            w = self.w_input
            opt = w.get_option('parameters')
            par = self.par_dict_np
            for p in ['Vus', 'Vcb', 'Vub', 'delta']:
                opt[p] = par[p]
            w.set_option('parameters', opt)
            self._w = w
        return self._w

    def get_ckm_np(self):
        """return the values of the four "true" CKM parameters
        `Vus`, `Vcb`, `Vub`, `delta`, extracted from the four input observables
        for this parameter point in Wilson coefficient space."""
        scheme = self.likelihood._ckm_scheme
        try:
            Vus, Vcb, Vub, delta = scheme.ckm_np(self.w_input)
        except ValueError:
            # this happens mostly when the formulas result in |cos(delta)| > 1
            raise ValueError("The extraction of CKM elements failed. Too large NP effects?")
        return {'Vus': Vus, 'Vcb': Vcb, 'Vub': Vub, 'delta': delta}

    @property
    def par_dict_np(self):
        """Return the dictionary of parameters where the four CKM parameters
        `Vus`, `Vcb`, `Vub`, `delta` have been replaced by their
        "true" values as extracted from the four input observables.

        Note that if `fix_ckm` is set to `True`, this method actually
        returns the SM values."""
        if self.fix_ckm:
            return self.likelihood.par_dict_sm
        if self._par_dict_np is None:
            par_dict_np = self.likelihood.par_dict_default.copy()
            par_dict_np.update(self.get_ckm_np())
            self._par_dict_np = par_dict_np
        return self._par_dict_np

    def _delta_log_likelihood(self):
        """Compute the delta log likelihood for the individual likelihoods"""
        ll = self.likelihood._log_likelihood(self.par_dict_np, self.w)
        for name in ll:
            ll[name] -= self.likelihood.log_likelihood_sm[name]
        ll['global'] = sum([v for k, v in ll.items() if 'custom_' not in k])
        return ll

    def log_likelihood_dict(self):
        """Return a dictionary with the delta log likelihood values
        for the individual contributions.

        Cached after the first call."""
        if self._log_likelihood_dict is None:
            self._log_likelihood_dict = self._delta_log_likelihood()
        return self._log_likelihood_dict

    def log_likelihood_global(self):
        """Return the value of the global delta log likelihood.

        Cached after the first call. Corresponds to the `global` key of
        the dictionary returned by `log_likelihood_dict`."""
        return self.log_likelihood_dict()['global']

    def pvalue_dict(self, n_par=0):
        r"""Dictionary of $p$ values of sublikelihoods given the number `n_par`
        of free parameters (default 0)."""
        nobs = self.likelihood.number_observations_dict()
        chi2 = self.chi2_dict()
        return {k: pvalue(chi2[k], dof=max(1, nobs[k] - n_par)) for k in chi2}

    def chi2_dict(self):
        r"""Dictionary of total $\chi^2$ values of each sublikelihood.

        $$\chi^2 = -2 (\ln L + \ln L_\text{SM})$$
        """
        ll = self.log_likelihood_dict()
        llsm = self.likelihood._log_likelihood_sm.copy()
        llsm['global'] = sum([v for k, v in llsm.items() if 'custom_' not in k])
        return {k: -2 * (ll[k] + llsm[k]) for k in ll}

    @property
    def _obstable_tree(self):
        if not self._obstable_tree_cache:
            llh = self.likelihood
            info = copy(llh.obstable_sm)
            for flh_name, flh in llh.fast_likelihoods.items():
                # loop over fast likelihoods: they only have a single "measurement"
                m = flh.pseudo_measurement
                ml = flh.full_measurement_likelihood
                pred = ml.get_predictions_par(self.par_dict_np, self.w)
                for i, obs in enumerate(flh.observables):
                    info[obs]['theory'] = pred[obs]
                    ll_central = info[obs]['ll_central']
                    ll_sm = info[obs]['ll_sm']
                    ll = m.get_logprobability_single(obs, pred[obs])
                    # DeltaChi2 is -2*DeltaLogLikelihood
                    info[obs]['pull exp.'] = pull(-2 * (ll - ll_central), dof=1)
                    s = -1 if ll > ll_sm else 1
                    info[obs]['pull SM'] = s * pull(-2 * (ll - ll_sm), dof=1)
            for lh_name, lh in llh.likelihoods.items():
                # loop over "normal" likelihoods
                ml = lh.measurement_likelihood
                pred = ml.get_predictions_par(self.par_dict_np, self.w)
                for i, obs in enumerate(lh.observables):
                    info[obs]['theory'] = pred[obs]
                    ll_central = info[obs]['ll_central']
                    ll_sm = info[obs]['ll_sm']
                    p_comb = info[obs]['exp. PDF']
                    ll = p_comb.logpdf([pred[obs]])
                    if ll == -np.inf:
                        ll = -1e100
                    info[obs]['pull exp.'] = pull(-2 * (ll - ll_central), dof=1)
                    s = -1 if ll > ll_sm else 1
                    info[obs]['pull SM'] = s * pull(-2 * (ll - ll_sm), dof=1)
            self._obstable_tree_cache = info
        return self._obstable_tree_cache

    def obstable(self, min_pull_exp=0, sort_by='pull exp.', ascending=None,
                 min_val=None, max_val=None):
        r"""Return a pandas data frame with the central values and uncertainties
        as well as the pulls with respect to the experimental and the SM values for each observable.

        The pull is defined is $\sqrt(|-2\ln L|)$. Note that the global
        likelihood is *not* simply proportional to the sum of squared pulls
        due to correlations.
        """
        sort_keys = ['name', 'exp. unc.', 'experiment', 'pull SM', 'pull exp.',
                     'th. unc.', 'theory']
        if sort_by not in sort_keys:
            raise ValueError(
                "'{}' is not an allowed value for sort_by. Allowed values are "
                "'{}', and '{}'.".format(sort_by, "', '".join(sort_keys[:-1]),
                                         sort_keys[-1])
            )
        info = self._obstable_tree
        subset = None
        if sort_by == 'pull exp.':
            # if sorted by pull exp., use descending order as default
            if ascending is None:
                ascending = False
            if min_val is not None:
                min_val = max(min_pull_exp, min_val)
            else:
                min_val = min_pull_exp
        elif min_pull_exp != 0:
            subset = lambda row: row['pull exp.'] >= min_pull_exp
        # if sorted not by pull exp., use ascending order as default
        if ascending is None:
            ascending = True
        info = self._obstable_filter_sort(info, sortkey=sort_by,
                                          ascending=ascending,
                                          min_val=min_val, max_val=max_val,
                                          subset=subset)
        # create DataFrame
        df = pd.DataFrame(info).T
        # if df has length 0 (e.g. if min_pull is very large) there are no
        # columns that could be removed
        if len(df) >0:
            # remove columns that are only used internal and should not be
            # included in obstable
            del(df['inspire'])
            del(df['lh_name'])
            del(df['name'])
            del(df['exp. PDF'])
            del(df['ll_central'])
            del(df['ll_sm'])
        return df

    @staticmethod
    def _obstable_filter_sort(info, sortkey='name', ascending=True,
                              min_val=None, max_val=None,
                              subset=None, max_rows=None):
        # impose min_val and max_val
        if min_val is not None:
            info = {obs:row for obs,row in info.items()
                    if row[sortkey] >= min_val}
        if max_val is not None:
            info = {obs:row for obs,row in info.items()
                    if row[sortkey] <= max_val}
        # get only subset:
        if subset is not None:
            info = {obs:row for obs,row in info.items() if subset(row)}
        # sort
        info = OrderedDict(sorted(info.items(), key=lambda x: x[1][sortkey],
                                  reverse=(not ascending)))
        # restrict number of rows per tabular to max_rows
        if max_rows is None or len(info)<=max_rows:
            return info
        else:
            info_list = []
            for n in range(ceil(len(info)/max_rows)):
                info_n = OrderedDict((obs,row)
                                    for i,(obs,row) in enumerate(info.items())
                                    if i>=n*max_rows and i<(n+1)*max_rows)
                info_list.append(info_n)
            return info_list