"""
defines:
  - split_line_elements(bdf_model, eids, neids=2,
                        eid_start=1, nid_start=1)

"""
import numpy as np
from pyNastran.bdf.bdf import read_bdf

def split_line_elements(bdf_model, eids, neids=2,
                        eid_start=1, nid_start=1):
    """
    Splits a set of element ids

    Parameters
    ----------
    eids : List[int]
        element ids to split
    neids : int; default=5
        how many elements should a single bar be split into
        min=2
    eid_start : int; default=1
        the starting element id
    nid_start : int; default=1
        the starting node id

    Returns
    -------
    eids_out : List[int]
        the list of elements that have been added
    eid_end : int; default=1
        the final element id
    nid_end : int; default=1
        the final node id

    A-----*-----B; neids=2
    A--*--*--*--B; neids=4

    """
    eids_out = []
    assert neids >= 2, neids
    dx = np.linspace(0., 1., num=neids+1)
    for eid in eids:
        elem = bdf_model.elements[eid]
        n1, n2 = elem.nodes
        node1 = bdf_model.nodes[n1]
        node2 = bdf_model.nodes[n2]
        cp = node1.cp
        assert node1.cp == node2.cp
        assert node1.cd == node2.cd
        xyz1 = node1.xyz
        xyz2 = node2.xyz
        dxyz = xyz2 - xyz1
        etype = elem.type

        if etype in ['CBAR', 'CBEAM']:
            pa = elem.pa
            pb = 0

        elem.comment = ''
        comment = str(elem) + '\n'
        for ieid in range(neids):
            dxi = dx[ieid + 1]
            new_xyz = xyz1 + dxyz * dxi
            if dxi < 1.:
                new_node = nid_start
                nid_start += 1
                bdf_model.add_grid(new_node, new_xyz, cp=cp)
            else:
                new_node = n2
                if etype in ['CBAR', 'CBEAM']:
                    pb = elem.pb

            if etype == 'CONROD':
                nids = [n1, new_node]
                bdf_model.add_conrod(eid_start, elem.mid, nids, elem.A, j=elem.j,
                                     c=elem.c, nsm=elem.nsm, comment=comment)
            elif etype == 'CROD':
                nids = [n1, new_node]
                bdf_model.add_crod(eid_start, elem.pid, nids, comment=comment)
            elif etype == 'CBAR':
                ga = n1
                gb = new_node
                bdf_model.add_cbar(eid_start, elem.pid, [ga, gb], elem.x, elem.g0, offt=elem.offt,
                                   pa=pa, pb=pb, wa=elem.wa, wb=elem.wb, comment=comment)
                pa = 0
            elif etype == 'CBEAM':
                ga = n1
                gb = new_node
                bdf_model.add_cbeam(eid_start, elem.pid, [ga, gb], elem.x, elem.g0,
                                    offt=elem.offt, bit=elem.bit,
                                    pa=pa, pb=pb,
                                    wa=elem.wa, wb=elem.wb, sa=elem.sa, sb=elem.sb,
                                    comment=comment)
                pa = 0
            else:
                raise NotImplementedError(elem)
            n1 = new_node
            eids_out.append(eid_start)
            eid_start += 1
            comment = str(eid)
        del bdf_model.elements[eid]
    return eids_out, eid_start, nid_start

def split_elements(bdf_filename):
    """unimplemented method for splitting elements"""
    model = read_bdf(bdf_filename, xref=True)
    for eid, elem in model.elements.items():
        if elem.type == 'CTRIA3':
            #
            #        3
            #       /|\
            #      / | \
            #     /  |  \
            #    /   4   \
            #   /  /   \  \
            #  / /       \ \
            # 1-------------2
            #
            p1, p2, p3 = elem.get_node_positions()
            #centroid = (p1 + p2 + p3) / 3.

            #
            #      3
            #     /|\
            #    / | \
            #   /  |  \
            #  /   |   \
            # 1----4----2
            #
        elif elem.type == 'CQUAD4':
            #
            #
            # 4---------3
            # | \     / |
            # |   \  /  |
            # |    5    |
            # |  /   \  |
            # |/       \|
            # 1---------2
            #
            # the same thing shown in a rotated view
            #           4
            #          /| \
            #       /   |   \
            #     /     |     \
            #   /       |       \
            # 1---------5---------3
            #   \       |       /
            #     \     |     /
            #       \   |   /
            #         \ | /
            #           2
            #
            # max_area, taper_ratio, area_ratio
            # 4----7----3
            # |    |    |
            # |    |    |
            # 8----9----6
            # |    |    |
            # |    |    |
            # 1----4----2
            #
            # max_interior_angle
            #      4---------3
            #     / \       /
            #    /   \     /
            #   /     \   /
            #  /       \ /
            # 1---------2
            #
            # taper_ratio
            #     4--6--3
            #    /   |   \
            #   /    |    \
            #  /     |     \
            # 1------5------2
            #
            # taper_ratio
            #     4------3
            #    / \    / \
            #   /   \  /   \
            #  /     \/     \
            # 1-------5------2
            #
            # taper_ratio
            #     4------3
            #    / \      \
            #   /   \      \
            #  /     \      \
            # 1-------5------2
            pass


import numbers
from contextlib import contextmanager
from functools import wraps, partial
from typing import List, Callable

import numpy as np
import tensorflow as tf
from tensorflow.python.client import device_lib

from ..math.backend._backend import combined_dim
from ..math.backend._dtype import DType, to_numpy_dtype, from_numpy_dtype
from phi.math.backend import Backend, ComputeDevice, NUMPY
from ._tf_cuda_resample import resample_cuda, use_cuda

class TFBackend(Backend):

    def __init__(self):
        Backend.__init__(self, "TensorFlow", default_device=None)

    def prefers_channels_last(self) -> bool:
        return True

    def list_devices(self, device_type: str or None = None) -> List[ComputeDevice]:
        tf_devices = device_lib.list_local_devices()
        devices = []
        for device in tf_devices:
            if device_type in (None, device.device_type):
                devices.append(ComputeDevice(self, device.name, device.device_type, device.memory_limit,
                                             processor_count=-1,
                                             description=str(device),
                                             ref=device))
        return devices

    def seed(self, seed: int):
        tf.random.set_seed(seed)

    def is_tensor(self, x, only_native=False):
        is_tf_tensor = tf.is_tensor(x) is True  # tf.is_tensor() can return non-bool values which indicates not a Tensor
        if only_native:
            return is_tf_tensor
        else:
            return is_tf_tensor or NUMPY.is_tensor(x, only_native=False)

    def as_tensor(self, x, convert_external=True):
        if self.is_tensor(x, only_native=convert_external):
            return x
        tensor = tf.convert_to_tensor(x)
        # --- Enforce Precision ---
        if not isinstance(tensor, numbers.Number):
            if isinstance(tensor, np.ndarray):
                tensor = NUMPY.as_tensor(tensor)
            elif tensor.dtype.is_floating:
                tensor = self.to_float(tensor)
        return tensor

    def is_available(self, tensor) -> bool:
        if self.is_tensor(tensor, only_native=True):
            return tf.executing_eagerly()
        else:
            return True

    def numpy(self, tensor):
        if tf.is_tensor(tensor):
            return tensor.numpy()
        return NUMPY.numpy(tensor)

    def to_dlpack(self, tensor):
        from tensorflow import experimental
        return experimental.dlpack.to_dlpack(tensor)

    def from_dlpack(self, capsule):
        from tensorflow import experimental
        return experimental.dlpack.from_dlpack(capsule)

    def copy(self, tensor, only_mutable=False):
        if not only_mutable or tf.executing_eagerly():
            return tf.identity(tensor)
        else:
            return tensor

    def jit_compile(self, f: Callable) -> Callable:
        compiled = tf.function(f)
        return lambda *args: self.as_registered.call(compiled, *args, name=f"run jit-compiled '{f.__name__}'")

    def custom_gradient(self, f: Callable, gradient: Callable = None) -> Callable:
        @tf.custom_gradient
        def tf_function(*args, **kwargs):
            def grad(*grad_args):
                return gradient(args, y, grad_args)
            y = f(*args, **kwargs)
            return y, grad
        return tf_function

    def transpose(self, tensor, axes):
        return tf.transpose(tensor, perm=axes)

    def equal(self, x, y):
        return tf.equal(x, y)

    def divide_no_nan(self, x, y):
        x, y = self.auto_cast(x, y)
        return tf.math.divide_no_nan(x, y)

    def random_uniform(self, shape):
        return tf.random.uniform(shape, dtype=to_numpy_dtype(self.float_type))

    def random_normal(self, shape):
        return tf.random.normal(shape, dtype=to_numpy_dtype(self.float_type))

    def rank(self, value):
        return len(value.shape)

    def range(self, start, limit=None, delta=1, dtype: DType = DType(int, 32)):
        return tf.range(start, limit, delta, to_numpy_dtype(dtype))

    def tile(self, value, multiples):
        if isinstance(multiples, (tuple, list)) and self.ndims(value) < len(multiples):
            value = self.expand_dims(value, axis=0, number=len(multiples) - self.ndims(value))
        return tf.tile(value, multiples)

    def stack(self, values, axis=0):
        return tf.stack(values, axis=axis)

    def concat(self, values, axis):
        return tf.concat(values, axis)

    def pad(self, value, pad_width, mode='constant', constant_values=0):
        if mode == 'boundary' and np.all(np.array(pad_width) <= 1):
            mode = 'symmetric'
        if mode in ('constant', 'symmetric', 'reflect'):
            return tf.pad(value, pad_width, mode.upper(), constant_values=constant_values)
        else:
            return NotImplemented

    def reshape(self, value, shape):
        return tf.reshape(value, shape)

    def sum(self, value, axis=None, keepdims=False):
        if axis is not None:
            if not isinstance(axis, int):
                axis = list(axis)
        if isinstance(value, tf.SparseTensor):
            return tf.sparse.reduce_sum(value, axis=axis, keepdims=keepdims, output_is_sparse=False)
        if isinstance(value, (tuple, list)) and any([isinstance(x, tf.SparseTensor) for x in value]):
            result = value[0]
            for v in value[1:]:
                result = tf.sparse.add(result, v, threshold=0)
            return result
        return tf.reduce_sum(value, axis=axis, keepdims=keepdims)

    def prod(self, value, axis=None):
        if axis is not None:
            if not isinstance(axis, int):
                axis = list(axis)
        if value.dtype == bool:
            return tf.reduce_all(value, axis=axis)
        return tf.reduce_prod(value, axis=axis)

    def where(self, condition, x=None, y=None):
        c = self.cast(condition, self.dtype(x))
        return c * x + (1 - c) * y
        # return tf.where(condition, x, y)  # TF1 has an inconsistent broadcasting rule for where

    def nonzero(self, values):
        return tf.where(tf.not_equal(values, 0))

    def mean(self, value, axis=None, keepdims=False):
        if axis is not None:
            if not isinstance(axis, int):
                axis = list(axis)
        return tf.reduce_mean(value, axis, keepdims=keepdims)

    def grid_sample(self, grid, spatial_dims: tuple, coordinates, extrapolation='constant'):
        if use_cuda(grid):
            # TODO reshape for spatial_dims
            return resample_cuda(grid, coordinates, extrapolation)
        else:
            return NotImplemented

    def zeros(self, shape, dtype: DType = None):
        return tf.zeros(shape, dtype=to_numpy_dtype(dtype or self.float_type))

    def zeros_like(self, tensor):
        return tf.zeros_like(tensor)

    def ones(self, shape, dtype: DType = None):
        return tf.ones(shape, dtype=to_numpy_dtype(dtype or self.float_type))

    def ones_like(self, tensor):
        return tf.ones_like(tensor)

    def meshgrid(self, *coordinates):
        result = tf.meshgrid(*coordinates, indexing='ij')
        return result

    def linspace(self, start, stop, number):
        return self.to_float(tf.linspace(start, stop, number))

    def tensordot(self, a, a_axes: tuple or list, b, b_axes: tuple or list):
        return tf.tensordot(a, b, (a_axes, b_axes))

    def matmul(self, A, b):
        if isinstance(A, tf.SparseTensor):
            result_T = tf.sparse.sparse_dense_matmul(A, tf.transpose(b))  # result shape contains unknown size
            result = tf.transpose(result_T)
            result.set_shape(tf.TensorShape([b.shape[0], A.shape[0]]))
            return result
        else:
            return tf.matmul(A, b)

    def einsum(self, equation, *tensors):
        return tf.einsum(equation, *tensors)

    def while_loop(self, loop: Callable, values: tuple):
        cond = lambda c, *vals: tf.reduce_any(c)
        return tf.nest.map_structure(tf.stop_gradient, tf.while_loop(cond, loop, values))

    def abs(self, x):
        return tf.abs(x)

    def sign(self, x):
        return tf.sign(x)

    def round(self, x):
        return tf.round(x)

    def ceil(self, x):
        return tf.math.ceil(x)

    def floor(self, x):
        return tf.floor(x)

    def max(self, x, axis=None, keepdims=False):
        if isinstance(x, (tuple, list)):
            x = tf.stack(x)
        if x.dtype == tf.bool:
            return tf.cast(tf.reduce_max(tf.cast(x, tf.uint8), axis=axis, keepdims=keepdims), tf.bool)  # reduce_max allows no bool
        return tf.reduce_max(x, axis=axis, keepdims=keepdims)

    def min(self, x, axis=None, keepdims=False):
        if isinstance(x, (tuple, list)):
            x = tf.stack(x)
        if x.dtype == tf.bool:
            return tf.cast(tf.reduce_min(tf.cast(x, tf.uint8), axis=axis, keepdims=keepdims), tf.bool)  # reduce_min allows no bool
        return tf.reduce_min(x, axis=axis, keepdims=keepdims)

    def maximum(self, a, b):
        a, b = self.auto_cast(a, b)
        return tf.maximum(a, b)

    def minimum(self, a, b):
        a, b = self.auto_cast(a, b)
        return tf.minimum(a, b)

    def clip(self, x, minimum, maximum):
        x, minimum, maximum = self.auto_cast(x, minimum, maximum)
        return tf.clip_by_value(x, minimum, maximum)

    def sqrt(self, x):
        return tf.sqrt(x)

    def exp(self, x):
        return tf.exp(x)

    def conv(self, value, kernel, zero_padding=True):
        value = self.to_float(value)
        kernel = self.to_float(kernel)  # should use auto_cast but TensorFlow only supports DT_HALF, DT_BFLOAT16, DT_FLOAT, DT_DOUBLE, DT_INT32
        if zero_padding:
            value_padding = [[0, 0]] * 2 + [[s // 2, (s - 1) // 2] for s in kernel.shape[3:]]
            value = tf.pad(value, value_padding)
        convf = {3: partial(tf.nn.conv1d, stride=1),
                 4: partial(tf.nn.conv2d, strides=[1, 1, 1, 1]),
                 5: partial(tf.nn.conv3d, strides=[1, 1, 1, 1, 1])}[len(value.shape)]
        value = tf.transpose(value, [0, *range(2, self.ndims(value)), 1])  # could use data_format='NC...' but it's supported neither on CPU and for int tensors
        kernel = tf.transpose(kernel, [0, *range(3, self.ndims(kernel)), 2, 1])
        if kernel.shape[0] == 1:
            result = convf(value, kernel[0, ...], padding='VALID')
        else:
            result = []
            for b in range(kernel.shape[0]):
                result.append(convf(value[b:b+1, ...], kernel[b], padding='VALID'))
            result = tf.concat(result, 0)
        result = tf.transpose(result, [0, self.ndims(result) - 1, *range(1, self.ndims(result) - 1)])
        return result

    def expand_dims(self, a, axis=0, number=1):
        if number == 0:
            return a
        for _i in range(number):
            a = tf.expand_dims(a, axis)
        return a

    def shape(self, tensor):
        return tf.shape(tensor)

    def staticshape(self, tensor):
        if self.is_tensor(tensor, only_native=True):
            return tuple(tensor.shape.as_list())
        else:
            return np.shape(tensor)

    def batched_gather_nd(self, values, indices):
        values_shape = self.staticshape(values)
        if values_shape[0] == 1 and self.staticshape(indices)[0] > 1:
            result = tf.gather_nd(values[0, ...], indices, batch_dims=0)
            return result
        if values_shape[0] > 1 and self.staticshape(indices)[0] == 1:
            indices = tf.tile(indices, [values_shape[0]] + [1] * (len(values_shape) - 1))
        return tf.gather_nd(values, indices, batch_dims=1)

    def unstack(self, tensor, axis=0, keepdims=False):
        unstacked = tf.unstack(tensor, axis=axis)
        if keepdims:
            unstacked = [self.expand_dims(c, axis=axis) for c in unstacked]
        return unstacked

    def std(self, x, axis=None, keepdims=False):
        _mean, var = tf.nn.moments(x, axis, keepdims=keepdims)
        return tf.sqrt(var)

    def boolean_mask(self, x, mask, axis=0):
        return tf.boolean_mask(x, mask, axis=axis)

    def isfinite(self, x):
        return tf.math.is_finite(x)

    def any(self, boolean_tensor, axis=None, keepdims=False):
        return tf.reduce_any(boolean_tensor, axis=axis, keepdims=keepdims)

    def all(self, boolean_tensor, axis=None, keepdims=False):
        return tf.reduce_all(boolean_tensor, axis=axis, keepdims=keepdims)

    def scatter(self, base_grid, indices, values, mode: str):
        base_grid, values = self.auto_cast(base_grid, values)
        indices = self.as_tensor(indices)
        batch_size = combined_dim(combined_dim(indices.shape[0], values.shape[0]), base_grid.shape[0])
        scatter = tf.tensor_scatter_nd_add if mode == 'add' else tf.tensor_scatter_nd_update
        result = []
        for b in range(batch_size):
            b_grid = base_grid[b, ...]
            b_indices = indices[min(b, indices.shape[0] - 1), ...]
            b_values = values[min(b, values.shape[0] - 1), ...]
            result.append(scatter(b_grid, b_indices, b_values))
        return self.stack(result, axis=0)

    def fft(self, x):
        rank = len(x.shape) - 2
        assert rank >= 1
        x = self.to_complex(x)
        if rank == 1:
            return tf.stack([tf.signal.fft(c) for c in tf.unstack(x, axis=-1)], axis=-1)
        elif rank == 2:
            return tf.stack([tf.signal.fft2d(c) for c in tf.unstack(x, axis=-1)], axis=-1)
        elif rank == 3:
            return tf.stack([tf.signal.fft3d(c) for c in tf.unstack(x, axis=-1)], axis=-1)
        else:
            raise NotImplementedError('n-dimensional FFT not implemented.')  # TODO perform multiple lower-dimensional FFTs

    def ifft(self, k):
        rank = len(k.shape) - 2
        assert rank >= 1
        if rank == 1:
            return tf.stack([tf.signal.ifft(c) for c in tf.unstack(k, axis=-1)], axis=-1)
        elif rank == 2:
            return tf.stack([tf.signal.ifft2d(c) for c in tf.unstack(k, axis=-1)], axis=-1)
        elif rank == 3:
            return tf.stack([tf.signal.ifft3d(c) for c in tf.unstack(k, axis=-1)], axis=-1)
        else:
            raise NotImplementedError('n-dimensional inverse FFT not implemented.')

    def imag(self, complex):
        return tf.math.imag(complex)

    def real(self, complex):
        return tf.math.real(complex)

    def cast(self, x, dtype: DType):
        if not self.is_tensor(x, only_native=True):
            x = self.as_tensor(x, convert_external=True)
        if self.dtype(x) == dtype:
            return x
        else:
            return tf.cast(x, to_numpy_dtype(dtype))

    def sin(self, x):
        return tf.math.sin(x)

    def cos(self, x):
        return tf.math.cos(x)

    def tan(self, x):
        return tf.math.tan(x)

    def log(self, x):
        return tf.math.log(x)

    def log2(self, x):
        return tf.math.log(x) / 0.6931471805599453094  # log(x) / log(2)

    def log10(self, x):
        return tf.math.log(x) / 2.3025850929940456840  # log(x) / log(10)

    def dtype(self, array) -> DType:
        if tf.is_tensor(array):
            dt = array.dtype.as_numpy_dtype
            return from_numpy_dtype(dt)
        else:
            return NUMPY.dtype(array)

    def sparse_tensor(self, indices, values, shape):
        indices = [tf.convert_to_tensor(i, tf.int64) for i in indices]
        indices = tf.cast(tf.stack(indices, axis=-1), tf.int64)
        return tf.SparseTensor(indices=indices, values=values, dense_shape=shape)

    def coordinates(self, tensor):
        assert isinstance(tensor, tf.SparseTensor)
        idx = tensor.indices
        idx = tuple(tf.unstack(idx, axis=-1))
        return idx, tensor.values

    def add(self, a, b):
        if isinstance(a, tf.SparseTensor) or isinstance(b, tf.SparseTensor):
            return tf.sparse.add(a, b, threshold=1e-5)
        else:
            return Backend.add(self, a, b)

    def functional_gradient(self, f, wrt: tuple or list, get_output: bool):
        @wraps(f)
        def eval_grad(*args):
            args = [self.as_tensor(arg, True) if i in wrt else arg for i, arg in enumerate(args)]
            wrt_args = [arg for i, arg in enumerate(args) if i in wrt]
            with tf.GradientTape(watch_accessed_variables=False) as tape:
                for arg in wrt_args:
                    assert arg.dtype in (tf.float16, tf.float32, tf.float64, tf.complex64, tf.complex128), f"Gradients can only be computed for float or complex tensors but got {arg.dtype} for argument with shape {arg.shape}"
                    tape.watch(arg)
                output = f(*args)
            loss, aux = (output[0], output[1:]) if isinstance(output, (tuple, list)) else (output, None)
            # if self.ndims(loss) > 0:
            #     loss = tf.reduce_sum(loss)  # this is not needed and will cause gradients to be None
            grads = list(self.as_registered.call(tape.gradient, loss, wrt_args, name=f"Backpropagation"))
            assert None not in grads, f"Gradient could not be computed for wrt argument {grads.index(None)} (argument {wrt[grads.index(None)]}) with shape {wrt_args[grads.index(None)].shape}. TensorFlow returned gradient=None."
            if get_output:
                if aux is not None:
                    return (loss, *aux, *grads)
                else:
                    return (loss, *grads)
            else:
                return grads
        return eval_grad

    # def variable(self, value):  # not supported, variables must record gradients outside a context
    #     return tf.Variable(value, trainable=True)

    def gradients(self, y, xs: tuple or list, grad_y):
        if _TAPES:
            tape = _TAPES[-1]
            return tape.gradient(y, xs, grad_y)
        return tf.gradients(y, xs, grad_y)

    @contextmanager
    def record_gradients(self, xs: tuple or list, persistent=False):
        tape = tf.GradientTape(persistent=persistent)
        tape.__enter__()
        for x in xs:
            tape.watch(x)
        _TAPES.append(tape)

        try:
            yield None
        finally:
            tape.__exit__(None, None, None)
            _TAPES.pop(-1)

    def stop_gradient(self, value):
        return tf.stop_gradient(value)

_TAPES = []


# Copyright (c) 2015, Michael Boyle
# See LICENSE file for details: <https://github.com/moble/scri/blob/master/LICENSE>

import os
import inspect
import functools
import warnings
import socket
import datetime
import pprint
import copy
import numpy as np
import quaternion
import scipy.constants as spc
from scipy.interpolate import CubicSpline
from . import *

@jit("void(c16[:,:], f8[:])")
def complex_array_norm(c, s):
    for i in range(len(s)):
        s[i] = 0.0
        for j in range(c.shape[1]):
            s[i] += c[i, j].real ** 2 + c[i, j].imag ** 2
    return

@jit("void(c16[:,:], f8[:])")
def complex_array_abs(c, s):
    for i in range(len(s)):
        s[i] = 0.0
        for j in range(c.shape[1]):
            s[i] += c[i, j].real ** 2 + c[i, j].imag ** 2
        s[i] = np.sqrt(s[i])
    return

def waveform_alterations(func):
    """Temporarily increment history depth safely

    This decorator stores the value of `self.__history_depth__`, then increments it by 1, calls the function,
    returns the history depth to its original value, and then returns the result of the function.  This should be
    used on any member function that could alter the waveform on which it is called, or which could return a new
    altered version of the original.

    Typically, within the function itself, you will want to decrement the depth manually just before appending to the
    history -- which will presumably take place at the end of the function.  You do not need to undo this,
    as the decorator will take care of that part.

    """

    @functools.wraps(func)
    def func_wrapper(self, *args, **kwargs):
        if self.__history_depth__ == 0:
            self._append_history("")
        stored_history_depth = self.__history_depth__
        self.__history_depth__ += 1
        result = func(self, *args, **kwargs)
        self.__history_depth__ = stored_history_depth
        return result

    return func_wrapper

def test_without_assertions(errs, val, msg=""):
    """Replacement for np.testing.assert_

    This function should be able to replace `assert_`, but rather than raising an exception, this just adds a
    description of the problem to the `errors` variable.

    """
    if not val:
        try:
            smsg = msg()
        except TypeError:
            smsg = msg
        errs += [smsg]

def test_with_assertions(errs, val, msg=""):
    np.testing.assert_(val, "Failed assertion:\n\t" + msg)

class _object:
    """Useless class to allow multiple inheritance"""

    def __init__(self, *args, **kwargs):
        super().__init__()

class WaveformBase(_object):
    """Object containing time, frame, and data, along with related information

    This object is just the base object from which these other classes are derived:
      * WaveformModes
      * WaveformGrid
      * WaveformInDetector
      * WaveformInDetectorFT
    For more specific information, see the documentation of those classes.

    Attributes
    ----------
    t : float array
        Time steps corresponding to other data
    frame : quaternion array
        Rotors taking static basis onto decomposition basis
    data : 2-d array of complex or real numbers
        The nature of this data depends on the derived type.  First index is time, second index depends on type.
    history : list of strings
        As far as possible, all functions applied to the object are recorded in the `history` variable.  In fact,
        the object should almost be able to be recreated using the commands in the history list. Commands taking
        large arrays, however, are shortened -- so the data will not be entirely reconstructable.
    version_hist : list of pairs of strings
        Records the git hash and description for any change in the way SpEC outputs waveform data.
    frameType : int
        Index corresponding to `scri.FrameType` appropriate for `data`.
    dataType : int
        Index corresponding to `scri.DataType` appropriate for `data`.
    r_is_scaled_out : bool
        True if the `data` have been multiplied by the appropriate power of radius so that the asymptotic value can
        be finite and nonzero.
    m_is_scaled_out : bool
        True if the `data` have been scaled by the appropriate value of the total mass so that they are dimensionless.
    num : int (read only)
        Automatically assigned number of this object.  The constructor of this type keeps count of the number of
        objects it has created, to assign each object a more-or-less unique ID for use in the history strings.  This
        counter is reset at the beginning of each python session.  Subclasses should automatically have a different
        counter.

    Indexing
    --------
    WaveformBase objects can be indexed much like a numpy array, where the first dimension gives the time indices,
    and the second gives the data-set indices. This will return another WaveformBase object containing slices of the
    original data.

    It is important to note, however, that as with numpy array slices, slicing a WaveformBase will not typically copy
    the original data; the result will simply be a view into the data.  This means that changing the data in the
    slice can change the data in the original.  If you want to make a copy, you should probably use the copy
    constructor: `W2 = WaveformBase(W1)`. It is also possible to use the standard copy.deepcopy method.

    Also note that the first slice dimension corresponds to the indices of the time, but the second dimension may NOT
    correspond to indices for derived types.  In particular, for `WaveformModes`, the second index corresponds to
    modes, because this type enforces completeness of each ell mode.  For the `WaveformBase` type, however,
    the second index does correspond to the second dimension of the data.

    For example,

    >>> W  = WaveformBase()
    >>> W[10:-20]

    will give all columns in the data, but only at times starting with the
    10th time step, and ending one before the -20th time step.  Meanwhile,

    >>> W[10:-20,2]

    will give the same range of times, but only the second column (unless the subclass overrides this behavior,
    as in `WaveformModes`).  Similarly,

    >>> W[10:-20,2:5]

    will return the same range of times, along with the 2,3,4 columns. Note the lack of 5 column, for consistency
    with python's usual slice syntax.

    >>> W[:,:0]

    will return all time steps, along with all `frame` data, but `data` will be empty (because the `:0` term selects
    everything before the 0th element).  Similarly,

    >>> W[:0,:0]

    is empty of all numerical data.

    """

    __num = 0  # Used to count number of Waveforms created

    def __init__(self, *args, **kwargs):
        """Initializer for WaveformBase object

        WaveformBase objects may be created in two ways.  First, by copying an existing WaveformBase object -- in
        which case the only parameter should be that object.  Second, by passing any of the (writable) attributes as
        keywords.

        In both cases, the last step in initialization is to check the validity of the result.  By default,
        this will raise an exception if the result is not valid.  An additional keyword parameter
        `override_exception_from_invalidity` may be set if this is not desired.  This may be necessary if only some
        of the data can be passed in to the initializer, for example.

        Keyword parameters
        ------------------
        t: float array, empty default
        frame : quaternion array, empty default
        data : 2-d complex array, empty default
        history : list of strings, empty default
            This is the list of strings prepended to the history, an additional line is appended, showing the call to
            this initializer.
        version_hist : list of pairs of strings, empty default
            Remains empty if waveform data is on version 0.
        frameType : int, defaults to 0 (UnknownFrameType)
            See scri.FrameNames for possible values
        dataType : int, defaults to 0 (UnknownDataType)
            See scri.DataNames for possible values
        r_is_scaled_out : bool, defaults to False
            Set to True if the data represented could approach a nonzero value at Scri
        m_is_scaled_out : bool, defaults to False
            Set to True if the data represented are dimensionless and in units where the total mass is 1
        override_exception_from_invalidity: bool, defaults to False
            If True, report any errors, but do not raise them.
        constructor_statement : str, optional
            If this is present, it will replace the default constructor statement added to the history.  It is
            prepended with a string of the form `'{0} = '.format(self)`, which prints the ID of the resulting object
            (unique to this session only).

        """
        original_kwargs = kwargs.copy()
        super().__init__(*args, **kwargs)  # to ensure proper calling in multiple inheritance
        override_exception_from_invalidity = kwargs.pop("override_exception_from_invalidity", False)
        self.__num = type(self).__num
        self.__history_depth__ = 0
        type(self).__num += 1  # Increment class's instance tracker
        if len(args) == 0:
            self.t = kwargs.pop("t", np.empty((0,), dtype=float))
            self.frame = kwargs.pop("frame", np.empty((0,), dtype=np.quaternion))
            self.data = kwargs.pop("data", np.empty((0, 0), dtype=complex))
            # Information about this object
            self.history = kwargs.pop("history", [])
            self.version_hist = kwargs.pop("version_hist", [])
            self.frameType = kwargs.pop("frameType", UnknownFrameType)
            self.dataType = kwargs.pop("dataType", UnknownDataType)
            self.r_is_scaled_out = kwargs.pop("r_is_scaled_out", False)
            self.m_is_scaled_out = kwargs.pop("m_is_scaled_out", False)
            if "constructor_statement" in kwargs:
                self._append_history("{} = {}".format(self, kwargs.pop("constructor_statement")))
            else:
                opts = np.get_printoptions()
                np.set_printoptions(threshold=6)
                self._append_history(
                    "{} = {}(**{})".format(self, type(self).__name__, pprint.pformat(original_kwargs, indent=4))
                )
                np.set_printoptions(**opts)
        elif len(args) == 1 and isinstance(args[0], type(self)):
            other = args[0]
            self.t = np.copy(other.t)
            self.frame = np.copy(other.frame)
            self.data = np.copy(other.data)
            # Information about this object
            self.history = other.history[:]
            self.version_hist = other.version_hist[:]
            self.frameType = other.frameType
            self.dataType = other.dataType
            self.r_is_scaled_out = other.r_is_scaled_out
            self.m_is_scaled_out = other.m_is_scaled_out
            self._append_history(["", "{} = {}({})".format(self, type(self).__name__, other)])
        else:
            raise ValueError(
                "Did not understand input arguments to `{}` constructor.\n".format(type(self).__name__)
                + "Note that explicit data values must be passed as keywords,\n"
                + "whereas objects to be copied must be passed as the sole argument."
            )
        hostname = socket.gethostname()
        cwd = os.getcwd()
        time = datetime.datetime.now().isoformat()
        self.__history_depth__ = 1
        self.ensure_validity(alter=True, assertions=(not override_exception_from_invalidity))
        self.__history_depth__ = 0
        self._append_history([f"hostname = {hostname}", f"cwd = {cwd}", f"datetime = {time}", version_info()], 1)
        if kwargs:
            warning = "\nIn `{}` initializer, unused keyword arguments:\n".format(type(self).__name__)
            warning += pprint.pformat(kwargs, indent=4)
            warnings.warn(warning)

    @waveform_alterations
    def ensure_validity(self, alter=True, assertions=False):
        """Try to ensure that the `WaveformBase` object is valid

        This tests various qualities of the WaveformBase's members that are frequently assumed throughout the code.
        If the optional argument `alter` is `True` (which is the default), this function tries to alter the
        WaveformBase in place to ensure validity.  Note that this is not always possible.  If that is the case,
        an exception may be raised.  For example, if the `t` member is not a one-dimensional array of floats,
        it is not clear what that data should be. Similarly, if the `t` and `data` members have mismatched
        dimensions, there is no way to resolve that automatically.

        Also note that this is almost certainly not be an exhaustive test of all assumptions made in the code.

        If the optional `assertions` argument is `True` (default is `False`), the first test that fails will raise an
        assertion error.

        """
        import numbers

        errors = []
        alterations = []

        if assertions:
            test = test_with_assertions
        else:
            test = test_without_assertions

        # Ensure that the various data are correct and compatible
        test(
            errors,
            isinstance(self.t, np.ndarray),
            "isinstance(self.t, np.ndarray) # type(self.t)={}".format(type(self.t)),
        )
        test(
            errors,
            self.t.dtype == np.dtype(np.float),
            f"self.t.dtype == np.dtype(np.float) # self.t.dtype={self.t.dtype}",
        )
        if alter and self.t.ndim == 2 and self.t.shape[1] == 1:
            self.t = self.t[:, 0]
            alterations += ["{0}.t = {0}.t[:,0]".format(self)]
        test(
            errors,
            not self.t.size or self.t.ndim == 1,
            f"not self.t.size or self.t.ndim==1 # self.t.size={self.t.size}; self.t.ndim={self.t.ndim}",
        )
        test(
            errors,
            self.t.size <= 1 or np.all(np.diff(self.t) > 0.0),
            "self.t.size<=1 or np.all(np.diff(self.t)>0.0) "
            "# self.t.size={}; max(np.diff(self.t))={}".format(
                self.t.size, (max(np.diff(self.t)) if self.t.size > 1 else np.nan)
            ),
        )
        test(errors, np.all(np.isfinite(self.t)), "np.all(np.isfinite(self.t))")

        if alter and self.frame is None:
            self.frame = np.empty((0,), dtype=np.quaternion)
            alterations += [f"{self}.frame = np.empty((0,), dtype=np.quaternion)"]
        test(
            errors,
            isinstance(self.frame, np.ndarray),
            "isinstance(self.frame, np.ndarray) # type(self.frame)={}".format(type(self.frame)),
        )
        if alter and self.frame.dtype == np.dtype(np.float):
            try:  # Might fail because of shape
                self.frame = quaternion.as_quat_array(self.frame)
                alterations += ["{0}.frame = quaternion.as_quat_array({0}.frame)".format(self)]
            except (AssertionError, ValueError):
                pass
        test(
            errors,
            self.frame.dtype == np.dtype(np.quaternion),
            f"self.frame.dtype == np.dtype(np.quaternion) # self.frame.dtype={self.frame.dtype}",
        )
        test(
            errors,
            self.frame.size <= 1 or self.frame.size == self.t.size,
            "self.frame.size<=1 or self.frame.size==self.t.size "
            "# self.frame.size={}; self.t.size={}".format(self.frame.size, self.t.size),
        )
        test(errors, np.all(np.isfinite(self.frame)), "np.all(np.isfinite(self.frame))")

        test(
            errors,
            isinstance(self.data, np.ndarray),
            "isinstance(self.data, np.ndarray) # type(self.data)={}".format(type(self.data)),
        )
        test(errors, self.data.ndim >= 1, f"self.data.ndim >= 1 # self.data.ndim={self.data.ndim}")
        test(
            errors,
            self.data.shape[0] == self.t.shape[0],
            "self.data.shape[0]==self.t.shape[0] "
            "# self.data.shape[0]={}; self.t.shape[0]={}".format(self.data.shape[0], self.t.shape[0]),
        )
        test(errors, np.all(np.isfinite(self.data)), "np.all(np.isfinite(self.data))")

        # Information about this object
        if alter and not self.history:
            self.history = [""]
            alterations += [f"{self}.history = ['']"]
        if alter and isinstance(self.history, str):
            self.history = self.history.split("\n")
            alterations += ["{0}.history = {0}.history.split('\n')".format(self)]
        test(
            errors,
            isinstance(self.history, list),
            "isinstance(self.history, list) # type(self.history)={}".format(type(self.history)),
        )
        test(
            errors,
            isinstance(self.history[0], str),
            "isinstance(self.history[0], str) # type(self.history[0])={}".format(type(self.history[0])),
        )
        test(
            errors,
            isinstance(self.frameType, numbers.Integral),
            "isinstance(self.frameType, numbers.Integral) # type(self.frameType)={}".format(type(self.frameType)),
        )
        test(errors, self.frameType in FrameType, f"self.frameType in FrameType # self.frameType={self.frameType}")
        test(
            errors,
            isinstance(self.dataType, numbers.Integral),
            "isinstance(self.dataType, numbers.Integral) # type(self.dataType)={}".format(type(self.dataType)),
        )
        test(errors, self.dataType in DataType, f"self.dataType in DataType # self.dataType={self.dataType}")
        test(
            errors,
            isinstance(self.r_is_scaled_out, bool),
            "isinstance(self.r_is_scaled_out, bool) # type(self.r_is_scaled_out)={}".format(type(self.r_is_scaled_out)),
        )
        test(
            errors,
            isinstance(self.m_is_scaled_out, bool),
            "isinstance(self.m_is_scaled_out, bool) # type(self.m_is_scaled_out)={}".format(type(self.m_is_scaled_out)),
        )
        test(
            errors,
            isinstance(self.num, numbers.Integral),
            "isinstance(self.num, numbers.Integral) # type(self.num)={}".format(type(self.num)),
        )

        if alterations:
            self._append_history(alterations)
            warnings.warn("The following alterations were made:\n\t" + "\n\t".join(alterations))
        if errors:
            warnings.warn("The following conditions were found to be incorrectly False:\n\t" + "\n\t".join(errors))
            return False

        self.__history_depth__ -= 1
        self._append_history("WaveformBase.ensure_validity" + f"({self}, alter={alter}, assertions={assertions})")

        return True

    @property
    def is_valid(self):
        return self.ensure_validity(alter=False, assertions=False)

    # Data sizes
    @property
    def n_data_sets(self):
        return int(np.prod(self.data.shape[1:]))

    @property
    def n_times(self):
        return self.t.shape[0]

    # Calculate weights
    @property
    def spin_weight(self):
        return SpinWeights[self.dataType]

    @property
    def conformal_weight(self):
        return ConformalWeights[self.dataType] + (-RScaling[self.dataType] if self.r_is_scaled_out else 0)

    @property
    def gamma_weight(self):
        """Non-conformal effect of a boost.

        This factor allows for mass-scaling, for example.  If the waveform describes `r*h/M`, for example,
        then `r` and `h` vary by the conformal weight, which depends on the direction; whereas `M` is a monopole,
        and thus cannot depend on the direction.  Instead, `M` simply obeys the standard formula, scaling with gamma.

        """
        return (MScaling[self.dataType] if self.m_is_scaled_out else 0) + (
            -RScaling[self.dataType] if (self.r_is_scaled_out and self.m_is_scaled_out) else 0
        )

    @property
    def r_scaling(self):
        return RScaling[self.dataType]

    @property
    def m_scaling(self):
        return MScaling[self.dataType]

    # Text descriptions
    @property
    def num(self):
        return self.__num

    @property
    def frame_type_string(self):
        return FrameNames[self.frameType]

    @property
    def data_type_string(self):
        return DataNames[self.dataType]

    @property
    def data_type_latex(self):
        return DataNamesLaTeX[self.dataType]

    @property
    def descriptor_string(self):
        """Create a simple string describing the content of the waveform

        This string will be suitable for file names.  For example, 'rMpsi4' or 'rhOverM'.  It uses the waveform's
        knowledge of itself, so if this is incorrect, the result will be incorrect.

        """
        if self.dataType == UnknownDataType:
            return self.data_type_string
        descriptor = ""
        if self.r_is_scaled_out:
            if RScaling[self.dataType] == 1:
                descriptor = "r"
            elif RScaling[self.dataType] > 1:
                descriptor = "r" + str(RScaling[self.dataType])
        if self.m_is_scaled_out:
            Mexponent = MScaling[self.dataType] - (RScaling[self.dataType] if self.r_is_scaled_out else 0)
            if Mexponent < -1:
                descriptor = descriptor + self.data_type_string + "OverM" + str(-Mexponent)
            elif Mexponent == -1:
                descriptor = descriptor + self.data_type_string + "OverM"
            elif Mexponent == 0:
                descriptor = descriptor + self.data_type_string
            elif Mexponent == 1:
                descriptor = descriptor + "M" + self.data_type_string
            elif Mexponent > 1:
                descriptor = descriptor + "M" + str(Mexponent) + self.data_type_string
        else:
            descriptor = descriptor + self.data_type_string
        return descriptor

    # Data simplifications
    @property
    def data_2d(self):
        return self.data.reshape((self.n_times, self.n_data_sets))

    @property
    def abs(self):
        return np.abs(self.data)

    @property
    def arg(self):
        return np.angle(self.data)

    @property
    def arg_unwrapped(self):
        return np.unwrap(np.angle(self.data), axis=0)

    def norm(self, take_sqrt=False, indices=slice(None, None, None)):
        """L2 norm of the waveform

        The optional arguments say whether to take the square-root of
        the norm at each time, and allow restriction to a slice of the
        data, respectively.

        """
        if indices == slice(None, None, None):
            n = np.empty((self.n_times,), dtype=float)
        else:
            n = np.empty((self.t[indices].shape[0],), dtype=float)
        if take_sqrt:
            complex_array_abs(self.data_2d[indices], n)
        else:
            complex_array_norm(self.data_2d[indices], n)
        return n

    def max_norm_index(self, skip_fraction_of_data=4):
        """Index of time step with largest norm

        The optional argument skips a fraction of the data.  The default is
        4, which means that it only searches the last three-fourths of the
        data for the max.  If 0 or 1 is input, this is ignored, and all the
        data is searched.

        """
        if skip_fraction_of_data == 0 or skip_fraction_of_data == 1:
            indices = slice(None, None, None)
            return np.argmax(self.norm(indices=indices))
        else:
            indices = slice(self.n_times // skip_fraction_of_data, None, None)
            return np.argmax(self.norm(indices=indices)) + (self.n_times // skip_fraction_of_data)

    def max_norm_time(self, skip_fraction_of_data=4):
        """Return time at which largest norm occurs in data

        See `help(max_norm_index)` for explanation of the optional argument.

        """
        return self.t[self.max_norm_index(skip_fraction_of_data=skip_fraction_of_data)]

    def compare(self, w_a, min_time_step=0.005, min_time=-3.0e300):
        """Return a waveform with differences between the two inputs

        This function simply subtracts the data in this waveform from the data
        in Waveform A, and finds the rotation needed to take this frame into frame A.
        Note that the waveform data are stored as complex numbers, rather than as
        modulus and phase.
        """
        from quaternion.means import mean_rotor_in_chordal_metric
        from scri.extrapolation import intersection
        import scri.waveform_modes

        if self.frameType != w_a.frameType:
            warning = (
                "\nWarning:"
                + "\n    This Waveform is in the "
                + self.frame_type_string
                + " frame,"
                + "\n    The Waveform in the argument is in the "
                + w_a.frame_type_string
                + " frame."
                + "\n    Comparing them probably does not make sense.\n"
            )
            warnings.warn(warning)

        if self.n_modes != w_a.n_modes:
            raise Exception(
                "Trying to compare waveforms with mismatched LM data."
                + "\nA.n_modes="
                + str(w_a.n_modes)
                + "\tB.n_modes()="
                + str(self.n_modes)
            )

        new_times = intersection(self.t, w_a.t)

        w_c = scri.waveform_modes.WaveformModes(
            t=new_times,
            data=np.zeros((new_times.shape[0], self.n_modes), dtype=self.data.dtype),
            history=[],
            version_hist=self.version_hist,
            frameType=self.frameType,
            dataType=self.dataType,
            r_is_scaled_out=self.r_is_scaled_out,
            m_is_scaled_out=self.m_is_scaled_out,
            ell_min=self.ell_min,
            ell_max=self.ell_max,
        )

        w_c.history += ["B.compare(A)\n"]
        w_c.history += ["### A.history.str():\n" + "".join(w_a.history)]
        w_c.history += ["### B.history.str():\n" + "".join(self.history)]
        w_c.history += ["### End of old histories from `compare`"]

        # Process the frame, depending on the sizes of the input frames
        if w_a.frame.shape[0] > 1 and self.frame.shape[0] > 1:
            # Find the frames interpolated to the appropriate times
            Aframe = quaternion.squad(w_a.frame, w_a.t, w_c.t)
            Bframe = quaternion.squad(self.frame, self.t, w_c.t)
            # Assign the data
            w_c.frame = Aframe * np.array([np.quaternion.inverse(v) for v in Bframe])
        elif w_a.frame.shape[0] == 1 and self.frame.shape[0] > 1:
            # Find the frames interpolated to the appropriate times
            Bframe = np.quaternion.squad(self.frame, self.t, w_c.t)
            # Assign the data
            w_c.frame.resize(w_c.n_times)
            w_c.frame = w_a.frame[0] * np.array([np.quaternion.inverse(v) for v in Bframe])
        elif w_a.frame.shape[0] > 1 and self.frame.shape[0] == 1:
            # Find the frames interpolated to the appropriate times
            Aframe = np.quaternion.squad(w_a.frame, w_a.t, w_c.t)
            # Assign the data
            w_c.frame.resize(w_c.n_times)
            w_c.frame = Aframe * np.quaternion.inverse(self.frame[0])
        elif w_a.frame.shape[0] == 1 and self.frame.shape[0] == 1:
            # Assign the data
            w_c.frame = np.array(w_a.frame[0] * np.quaternions.inverse(self.frame[0]))
        elif w_a.frame.shape[0] == 0 and self.frame.shape[0] == 1:
            # Assign the data
            w_c.frame = np.array(np.quaternions.inverse(self.frame[0]))
        elif w_a.frame.shape[0] == 1 and self.frame.shape[0] == 1:
            # Assign the data
            w_c.frame = np.array(w_a.frame[0])
        # else, leave the frame data empty

        # If the average frame rotor is closer to -1 than to 1, flip the sign
        if w_c.frame.shape[0] == w_c.n_times:
            R_m = mean_rotor_in_chordal_metric(w_c.frame, w_c.t)
            if quaternion.rotor_chordal_distance(R_m, -quaternion.one) < quaternion.rotor_chordal_distance(
                R_m, quaternion.one
            ):
                w_c.frame = -w_c.frame
        elif w_c.frame.shape[0] == 1:
            if quaternion.rotor_chordal_distance(w_c.frame[0], -quaternion.one) < quaternion.rotor_chordal_distance(
                w_c.frame[0], quaternion.one
            ):
                w_c.frame[0] = -w_c.frame[0]

        # Now loop over each mode filling in the waveform data
        for AMode in range(w_a.n_modes):
            # Assume that all the ell,m data are the same, but not necessarily in the same order
            BMode = self.index(w_a.LM[AMode][0], w_a.LM[AMode][1])
            # Initialize the interpolators for this data set
            # (Can't just re-view here because data are not contiguous)
            splineReA = CubicSpline(w_a.t, w_a.data[:, AMode].real)
            splineImA = CubicSpline(w_a.t, w_a.data[:, AMode].imag)
            splineReB = CubicSpline(self.t, self.data[:, BMode].real)
            splineImB = CubicSpline(self.t, self.data[:, BMode].imag)
            # Assign the data from the transition
            w_c.data[:, AMode] = (splineReA(w_c.t) - splineReB(w_c.t)) + 1j * (splineImA(w_c.t) - splineImB(w_c.t))

        return w_c

    @property
    def data_dot(self):
        return CubicSpline(self.t, self.data).derivative()(self.t)

    @property
    def data_ddot(self):
        return CubicSpline(self.t, self.data).derivative(2)(self.t)

    @property
    def data_int(self):
        return CubicSpline(self.t, self.data).antiderivative()(self.t)

    @property
    def data_iint(self):
        return CubicSpline(self.t, self.data).antiderivative(2)(self.t)

    # Data representations
    def _append_history(self, hist, additional_depth=0):
        """Add to the object's history log

        Input may be a single string or list of strings.  Any newlines will be split into separate strings.  Each
        such string is then prepended with a number of `#`s, indicating that the content of that line was called from
        within a member function, or is simply a piece of information relevant to the waveform.  The idea behind this
        is that the history should be -- as nearly as possible --  a script that could be run to reproduce the
        waveform, so the lines beginning with `#` would not be run.

        The number of `#`s is controlled by the object's `__history_depth__` field and the optional input to this
        function; their sum is the number prepended.  The user should never have to deal with this issue,
        but all member functions should increment the `__history_depth__` before calling another member function,
        and decrement it again as necessary before recording itself in the history.  Also, for any lines added just
        for informational purposes (e.g., the hostname, pwd, date, and versions added in `__init__`), this function
        should be called with `1` as the optional argument.

        """
        if not isinstance(hist, list):
            hist = [hist]
        self.history += [
            "# " * (self.__history_depth__ + additional_depth) + hist_line
            for hist_element in hist
            for hist_line in hist_element.split("\n")
        ]

    def __str__(self):
        # "The goal of __str__ is to be readable; the goal of __repr__ is to be unambiguous." --- stackoverflow
        return "{}_{}".format(type(self).__name__, self.num)

    def __repr__(self):
        # "The goal of __str__ is to be readable; the goal of __repr__ is to be unambiguous." --- stackoverflow
        from textwrap import dedent

        opts = np.get_printoptions()
        np.set_printoptions(threshold=6, linewidth=150, precision=6)
        rep = """
         {0}(
             t={1},
             frame={2},
             data={5},
             frameType={6}, dataType={7},
             r_is_scaled_out={8}, m_is_scaled_out={9})  # num = {10}"""
        rep = rep.format(
            type(self).__name__,
            str(self.t).replace("\n", "\n" + " " * 15),
            str(self.frame).replace("\n", "\n" + " " * 19),
            self.history,
            self.version_hist,
            str(self.data).replace("\n", "\n" + " " * 18),
            self.frameType,
            self.dataType,
            self.r_is_scaled_out,
            self.m_is_scaled_out,
            self.num,
        )
        np.set_printoptions(**opts)
        return dedent(rep)

    def __getstate__(self):
        """Get state of object for copying and pickling

        The only nontrivial operation is with quaternions, since they can't
        currently be pickled automatically.  We just view the frame array as
        a float array, and pickle as usual.

        Also, we remove the `num` value, because this will get reset
        properly on creation.

        """
        state = copy.deepcopy(self.__dict__)
        state["frame"] = quaternion.as_float_array(self.frame)
        return state

    def __setstate__(self, state):
        """Set state of object for copying and pickling

        The only nontrivial operation is with quaternions, since they can't
        currently be pickled automatically.  We just view the frame array as
        a float array, and unpickle as usual, then convert the float array
        back to a quaternion array.

        """
        new_num = self.__num
        old_num = state.get("_WaveformBase__num")
        self.__dict__.update(state)
        # Make sure to preserve auto-incremented num
        self.__num = new_num
        self.frame = quaternion.as_quat_array(self.frame)
        self._append_history(f"copied, deepcopied, or unpickled as {self}", 1)
        self._append_history("{} = {}".format(self, f"{self}".replace(str(self.num), str(old_num))))

    @waveform_alterations
    def deepcopy(self):
        """Return a deep copy of the object

        This is just an alias for `copy`, which is deep anyway.

        """
        W = self.copy()
        W.__history_depth__ -= 1
        W._append_history(f"{W} = {self}.deepcopy()")
        return W

    @waveform_alterations
    def copy(self):
        """Return a (deep) copy of the object

        Note that this also copies all members if the object is a subclass.  If you want a forgetful WaveformBase
        object, you can simply use the copy constructor.

        """
        W = type(self)()
        state = copy.deepcopy(self.__dict__)
        state.pop("_WaveformBase__num")
        W.__dict__.update(state)
        W.__history_depth__ -= 1
        W._append_history(f"{W} = {self}.copy()")
        return W

    @waveform_alterations
    def copy_without_data(self):
        """Return a copy of the object, with empty `t`, `frame`, and `data` fields

        Note that subclasses may override this to set some related data members.  For example,
        `WaveformModes.copy_without_data` sets the `ell_min` and `ell_max` fields appropriately.  If you wish to only
        skip `t`, `frame`, and `data`, you can simply use `WaveformBase.copy_without_data(W)`.  The latter is useful
        if, for example, you will only be making changes to those three fields, and want everything else preserved.

        Also note that some slicing operations can achieve similar -- but different -- goals.  For example,
        `w = w[:, :0]` will simply empty `data` and `ells`, without affecting the `time` and `frame`.

        """
        W = type(self)()
        state = copy.deepcopy(self.__dict__)
        state.pop("_WaveformBase__num")
        state.pop("t")
        state.pop("frame")
        state.pop("data")
        W.__dict__.update(state)
        W.__history_depth__ -= 1
        W._append_history(f"{W} = {self}.copy_without_data()")
        return W

    def _allclose(
        self, other, report_all=True, rtol=1e-10, atol=1e-10, compare_history_beginnings=False, exceptions=[]
    ):
        """Check that member data in two waveforms are the same

        For data sets (time, modes, etc.), the numpy function `np.allclose` is used, with the input tolerances.  See
        that function's documentation for more details.  The `*__num` datum is always ignored.  By default,
        the `history` is ignored, though this can be partially overridden -- in which case, the shortest subset of
        the histories is compared for exact equality.  This is probably only appropriate for the case where one
        waveform was created from the other.

        Parameters
        ----------
        other : object
            Another object subclassing WaveformBase to compare
        report_all: bool, optional
            Wait until all attributes have been checked (and reported on) before returning the verdict
        rtol : float, optional
            Relative tolerance to which to compare arrays (see np.allclose), defaults to 1e-10
        atol : float, optional
            Absolute tolerance to which to compare arrays (see np.allclose), defaults to 1e-10
        compare_history_beginnings: bool, optional
            Compare the shortest common part of the `history` fields for equality, defaults to False
        exceptions : list, optional
            Don't compare elements in this list, corresponding to keys in the object's `__dict__`, defaults to []

        """
        equality = True
        if not type(self) == type(other):  # not isinstance(other, self.__class__):
            warnings.warn("\n  (type(self)={}) != (type(other)={})".format(type(self), type(other)))
            equality = False
            if not report_all and not equality:
                return False
        for key, val in self.__dict__.items():
            if key.endswith("__num") or key in exceptions:
                continue
            elif key == "history":
                if compare_history_beginnings:
                    min_length = min(len(self.history), len(other.history))
                    if self.history[:min_length] != other.history[:min_length]:
                        warnings.warn("\n  `history` fields differ")
                        equality = False
            elif key == "version_hist":
                if self.version_hist != other.version_hist:
                    warnings.warn("\n  `version_hist` fields differ")
                    equality = False
            elif isinstance(val, np.ndarray):
                if val.dtype == np.quaternion:
                    if not np.allclose(
                        quaternion.as_float_array(val), quaternion.as_float_array(other.__dict__[key]), rtol, atol
                    ):
                        warnings.warn(f"\n  `{key}` fields differ")
                        equality = False
                elif not np.allclose(val, other.__dict__[key], rtol, atol):
                    warnings.warn(f"\n  `{key}` fields differ")
                    equality = False
            else:
                if not val == other.__dict__[key]:
                    warnings.warn(
                        "\n  (self.{0}={1}) != (other.{0}={2}) fields differ".format(key, val, other.__dict__[key])
                    )
                    equality = False
            if not report_all and not equality:
                return False
        return equality

    # Slicing
    @waveform_alterations
    def __getitem__(self, key):
        """Extract subsets of the data efficiently

        See the docstring of the WaveformBase class for examples.

        """
        W = WaveformBase.copy_without_data(self)

        # Remove trivial tuple structure first
        if isinstance(key, tuple) and len(key) == 1:
            key = key[0]

        # Now figure out which type of return is desired
        if isinstance(key, tuple) and 2 <= len(key) <= self.n_data_sets:
            # Return a subset of the data from a subset of times
            W.t = self.t[key[0]]
            W.frame = self.frame[key[0]]
            W.data = self.data[key]
        elif isinstance(key, slice) or isinstance(key, int):
            # Return complete data from a subset of times (key is slice), or
            # return complete data from a single instant in time (key is int)
            W.t = self.t[key]
            W.frame = self.frame[key]
            W.data = self.data[key]
        else:
            raise ValueError("Could not understand input `{}` (of type `{}`) ".format(key, type(key)))

        W.__history_depth__ -= 1
        W._append_history(f"{W} = {self}[{key}]")

        return W

    @waveform_alterations
    def interpolate(self, tprime):
        """Interpolate the frame and data onto the new set of time steps

        Note that only `t`, `frame`, and `data` are changed in this function.  If there is a corresponding data set
        in a subclass, for example, the subclass must override this function to set that data set -- though this
        function should probably be called to handle the ugly stuff.

        """
        # Copy the information fields, but not the data
        W = WaveformBase.copy_without_data(self)

        W.t = np.copy(tprime)
        W.frame = quaternion.squad(self.frame, self.t, W.t)
        W.data = np.empty((W.n_times,) + self.data.shape[1:], dtype=self.data.dtype)
        W.data_2d[:] = CubicSpline(self.t, self.data_2d.view(float))(W.t).view(complex)
        W.__history_depth__ -= 1
        W._append_history(f"{W} = {self}.interpolate({tprime})")
        return W

    @waveform_alterations
    def SI_units(self, current_unit_mass_in_solar_masses, distance_from_source_in_megaparsecs=100):
        """Assuming current quantities are in geometric units, convert to SI units

        This function assumes that the `dataType`, `r_is_scaled_out`, and `m_is_scaled_out` attributes are correct,
        then scales the amplitude and time data appropriately so that the data correspond to data that could be
        observed from a source with the given total mass at the given distance.

        Note that the curvature scalars will have units of s^-2, rather than the arguably more correct m^-2.  This
        seems to be more standard in numerical relativity.  The result can be divided by `scipy.constants.c**2`
        to give units of m^-2 if desired.

        Parameters
        ----------
        current_unit_mass_in_solar_masses : float
            Mass of the system in the data converted to solar masses
        distance_from_source_in_megaparsecs : float, optional
            Output will be waveform as observed from this distance, default=100 (Mpc)

        """
        if not self.r_is_scaled_out:
            warning = (
                "\nTrying to convert to SI units, the radius is supposedly not scaled out.\n"
                + "This seems to suggest that the data may already be in some units..."
            )
            warnings.warn(warning)
        if not self.m_is_scaled_out:
            warning = (
                "\nTrying to convert to SI units, the mass is supposedly not scaled out.\n"
                + "This seems to suggest that the data may already be in some units..."
            )
            warnings.warn(warning)

        M_in_meters = current_unit_mass_in_solar_masses * m_sun_in_meters  # m
        M_in_seconds = M_in_meters / speed_of_light  # s
        R_in_meters = distance_from_source_in_megaparsecs * (1e6 * parsec_in_meters)  # m
        R_over_M = R_in_meters / M_in_meters  # [dimensionless]

        # The radius scaling `r_scaling` is the number of factors of the dimensionless quantity `R_over_M` required
        # to keep the waveform asymptotically constant.  So, for example, h and Psi4 both have `r_scaling=1`.  The
        # mass scaling `m_scaling` is the number of factors of `M_in_meters` required to make the waveform
        # dimensionless, and does not account for the factors of mass in the radius scale.  The Newman-Penrose
        # quantities are curvature quantities, so they have dimensions 1/m^2, and thus have `m_scaling=2`.
        if self.r_is_scaled_out:
            if self.m_is_scaled_out:
                amplitude_scaling = (R_over_M ** -self.r_scaling) * (M_in_meters ** -self.m_scaling)
            else:
                amplitude_scaling = R_over_M ** -self.r_scaling
        else:
            if self.m_is_scaled_out:
                amplitude_scaling = M_in_meters ** -self.m_scaling
            else:
                amplitude_scaling = 1.0

        # Copy the information fields, but not the data
        W = WaveformBase.copy_without_data(self)

        if self.m_is_scaled_out:
            W.t = M_in_seconds * self.t  # s
        else:
            W.t = np.copy(self.t)  # supposedly already in the appropriate units...
        W.frame = np.copy(self.frame)
        W.data = amplitude_scaling * self.data

        W.m_is_scaled_out = False
        W.r_is_scaled_out = False

        W.__history_depth__ -= 1
        W._append_history(
            "{} = {}.SI_units(current_unit_mass_in_solar_masses={}, "
            "distance_from_source_in_megaparsecs={})".format(
                W, self, current_unit_mass_in_solar_masses, distance_from_source_in_megaparsecs
            )
        )

        return W


def selection_3():

    # Library import
    import numpy
    import matplotlib
    import matplotlib.pyplot   as plt
    import matplotlib.gridspec as gridspec

    # Library version
    matplotlib_version = matplotlib.__version__
    numpy_version      = numpy.__version__

    # Histo binning
    xBinning = numpy.linspace(0.0,1000.0,101,endpoint=True)

    # Creating data sequence: middle of each bin
    xData = numpy.array([5.0,15.0,25.0,35.0,45.0,55.0,65.0,75.0,85.0,95.0,105.0,115.0,125.0,135.0,145.0,155.0,165.0,175.0,185.0,195.0,205.0,215.0,225.0,235.0,245.0,255.0,265.0,275.0,285.0,295.0,305.0,315.0,325.0,335.0,345.0,355.0,365.0,375.0,385.0,395.0,405.0,415.0,425.0,435.0,445.0,455.0,465.0,475.0,485.0,495.0,505.0,515.0,525.0,535.0,545.0,555.0,565.0,575.0,585.0,595.0,605.0,615.0,625.0,635.0,645.0,655.0,665.0,675.0,685.0,695.0,705.0,715.0,725.0,735.0,745.0,755.0,765.0,775.0,785.0,795.0,805.0,815.0,825.0,835.0,845.0,855.0,865.0,875.0,885.0,895.0,905.0,915.0,925.0,935.0,945.0,955.0,965.0,975.0,985.0,995.0])

    # Creating weights for histo: y4_PT_0
    y4_PT_0_weights = numpy.array([0.0,0.0,5786.78210348,5281.0605314,4910.13784526,4512.19118199,4188.24845609,3792.452791,3452.23507887,3119.38536052,2778.24544917,2424.20944874,2150.00818076,1824.83545591,1649.19940452,1437.02338405,1233.75225605,1147.16242932,997.011956373,855.151676409,756.58675981,611.349082704,565.904821157,500.808776238,433.256333398,377.679280425,332.541918618,297.844607977,263.454317077,241.653305524,201.12186982,173.793872943,150.150532949,139.096502303,123.743685294,107.776768804,92.423951795,77.9923140064,74.9217266046,58.0336308945,58.3407106347,46.979600248,40.8384854443,39.6102564836,31.3197334986,27.6350676164,19.9586591119,17.5022101904,19.6516033717,13.510476568,9.51874594566,14.4316467886,11.3610833868,7.98346424475,4.60584510274,3.07056340183,4.29878936256,4.91290084292,4.29878936256,4.91290084292,4.29878936256,2.14939408128,3.07056340183,2.76350676164,1.53528140091,1.22822506073,1.22822506073,0.0,0.0,0.921169020548,0.614112680365,0.614112680365,0.921169020548,0.0,0.307056340183,0.0,0.307056340183,0.307056340183,0.307056340183,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_1
    y4_PT_1_weights = numpy.array([0.0,0.0,0.912787033803,1.81916001616,0.913150150035,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_2
    y4_PT_2_weights = numpy.array([0.0,0.0,9.03876615718,14.3000564189,12.8016704253,17.3227331768,16.5649496758,11.2956205368,5.27149226024,0.753445792208,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_3
    y4_PT_3_weights = numpy.array([0.0,0.0,14.8559620956,23.5128565693,33.4114494002,35.0706872792,41.6621785534,41.2548950084,49.9204336313,37.5395562166,36.3045123613,32.5797585463,32.5970041875,21.8638411509,21.0363520176,13.2018247826,9.90207547543,4.94335946635,2.06216552844,1.64844503259,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_4
    y4_PT_4_weights = numpy.array([0.0,0.0,8.06773951744,11.4694905207,15.7648365263,18.0599629707,19.6151602019,20.4277586991,19.985478039,19.5384174939,17.6129655561,16.5048709572,18.0592444849,16.2814248587,15.4683002735,16.2833668748,14.9508972563,15.6930059769,12.5830985217,12.7298289681,11.4718383762,8.06757718172,7.25323808485,5.77328345942,3.84893179711,3.035434441,1.92420736389,1.48061599318,0.518508403149,0.296202322267,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_5
    y4_PT_5_weights = numpy.array([0.0,0.0,1.98520664078,2.76083543994,3.47885497709,4.36822048457,4.57526056873,4.72688710688,4.70792778793,5.27467278568,4.7451202221,5.19959472287,4.85838399774,4.93370812958,4.63180543214,5.12282718608,4.650557693,4.55613920432,4.34802181793,4.23457499093,4.10239390812,4.12158129106,3.38377030151,3.64899670958,3.42176996214,3.30863222186,3.04361887356,3.02438047631,2.8168308492,2.79897253913,2.60929172504,2.06127168048,1.38007397353,1.30416587786,0.907631031796,0.718306417629,0.585807245583,0.264645535112,0.22687985012,0.0944662320757,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_6
    y4_PT_6_weights = numpy.array([0.0,0.0,0.880926044487,1.20383059022,1.20151975902,1.09497409521,1.31032601857,1.24538506363,1.69603313814,1.84686773395,1.48109284564,1.58894013274,1.43859064653,1.33080709099,1.1594854245,1.50232819976,1.28826365381,1.24532733034,1.33089706495,1.095309998,1.11736711428,1.09536248281,1.00963604355,0.773454398675,1.07269504316,1.05274631667,0.665614834344,0.881270194884,1.18044410868,0.793935471091,1.11647562229,1.00927614771,0.793924974129,0.880071291868,0.988191499975,0.966535517598,0.773048766072,0.600648461756,0.902253621911,0.580388350389,0.558027497014,0.708427668556,0.49457681076,0.407801125172,0.408065798571,0.343790800887,0.257956317898,0.214864039563,0.279192496774,0.17203800917,0.236497678407,0.0857799737648,0.0427792889203,0.0427693093086,0.0641474146084,0.107100053358,0.0,0.0641858334893,0.0212968213248,0.0215439423037,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_7
    y4_PT_7_weights = numpy.array([0.0,0.0,0.0939633949117,0.126374698649,0.139312395938,0.137681978341,0.110036261618,0.130970489133,0.106837410644,0.0971007960125,0.0988517245135,0.0939811540823,0.0938545139077,0.102055573202,0.0712685263583,0.0824750345434,0.0808946569453,0.0890838348826,0.0745356165535,0.0550748658589,0.0745386969052,0.0680569453587,0.0648158809925,0.0518185254627,0.0404785877563,0.0567118213458,0.0388850715151,0.0583254227375,0.043744232072,0.0421359112833,0.029187709366,0.0403724413503,0.0517026036547,0.0615929529829,0.0436066535055,0.0502176855281,0.0404902176557,0.0583105238935,0.0599251311143,0.0647839144853,0.0356373120987,0.0356562656915,0.0534745288389,0.0550742686478,0.0372410752242,0.0421144745498,0.0615596034606,0.0437515243332,0.0583280944712,0.0631650639227,0.0550754316378,0.0615762625057,0.043736594057,0.04858245881,0.0631755308322,0.0583313005516,0.0453435003985,0.043755201896,0.0453509812527,0.0485824273778,0.0372615689929,0.0453472408256,0.0291467186855,0.0340059829685,0.0291682214265,0.0388617174199,0.0113483458093,0.0162010376275,0.0113408869576,0.0129638299874,0.0097293192267,0.00324230279333,0.00161444628819,0.00324402841894,0.0016198774512,0.006482166451,0.0,0.0,0.00162193248585,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_8
    y4_PT_8_weights = numpy.array([0.0,0.0,0.019051396408,0.0255568357526,0.0170323594326,0.0127713462745,0.0169406513367,0.0127830172016,0.0105960224445,0.0191430599584,0.0149063018377,0.0106548047169,0.00638585020569,0.0106519348716,0.0149184961746,0.00424989905007,0.00635183190326,0.00426200095508,0.0042612147266,0.0105419653385,0.00637643216845,0.00426733639512,0.00635995589306,0.00632726281994,0.00638804852726,0.00213219485438,0.00212624023157,0.0042598315881,0.00213219485438,0.00841264032944,0.00850301317327,0.00213060680648,0.00425744172078,0.00213219485438,0.0,0.00213366819756,0.00426427500404,0.0,0.0,0.00426586305194,0.0,0.00213219485438,0.00212980498706,0.00213219485438,0.0,0.00212624023157,0.00425149489344,0.00639408110474,0.0,0.0042458554302,0.00213219485438,0.00426041290718,0.00426200095508,0.0,0.00426130493128,0.00212763673372,0.00851974001723,0.0,0.00426200095508,0.00425744172078,0.0,0.00213366819756,0.0,0.0084835634842,0.00213366819756,0.00637891001318,0.00639180594214,0.00212763673372,0.0,0.0,0.00425387585165,0.00212624023157,0.0106495450043,0.0,0.0,0.0042598315881,0.00212624023157,0.0,0.00423014979236,0.0,0.00851209043726,0.0,0.00212624023157,0.0,0.00425744172078,0.00212980498706,0.0,0.00212763673372,0.0,0.00213219485438,0.00213219485438,0.00213366819756,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_9
    y4_PT_9_weights = numpy.array([0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_10
    y4_PT_10_weights = numpy.array([0.0,0.0,79.0971291539,0.0,78.9085371523,0.0,78.97186254,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_11
    y4_PT_11_weights = numpy.array([0.0,0.0,86.4011892197,310.922590593,155.352700617,172.742662894,190.060574561,103.726017745,51.7445000294,51.7772398266,69.099589811,34.5700267167,103.78097795,17.2983917156,51.8014200112,17.3004869473,17.2596400156,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_12
    y4_PT_12_weights = numpy.array([0.0,0.0,176.541933258,95.5647890812,118.376517189,91.3858283688,64.4067086643,39.4564829255,33.2292406025,43.6029289416,29.0534246123,29.0616470511,35.2906492644,20.7724767543,18.7008953489,18.6877019411,16.6110745549,12.4623349111,18.6940433166,14.5383189276,10.3939732397,6.2198536684,4.15369618751,16.6027338285,2.0744012691,4.1530989788,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_13
    y4_PT_13_weights = numpy.array([0.0,0.0,55.9532928362,43.1132315618,26.4785176646,15.1222611138,11.3375538375,8.30775980306,9.83549701357,9.83379014633,5.29433803914,6.80093740577,9.07770713151,3.77614745088,5.29255379392,3.78273459292,5.28628617742,6.04602416396,4.53634287784,2.2632385926,3.02597565867,3.02454553151,3.02161836798,4.53661005946,6.80269889276,2.26997184248,2.27025495486,0.753999271304,2.27222854855,0.755502679967,2.26702510688,2.26997184248,0.0,0.0,0.757751193074,2.26503558243,0.757151741301,0.0,0.0,0.756398443893,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_14
    y4_PT_14_weights = numpy.array([0.0,0.0,23.7674343961,14.6405087644,8.91044047609,6.15365179535,3.81934144307,4.45546262709,3.18444951776,2.33413519734,2.54683244708,2.54656408778,1.69643848455,1.27313920749,1.69562878973,1.27251332436,1.90997688198,1.48488940862,1.48360041832,1.27332388486,2.12264325597,1.27222159184,1.06087854659,0.849298883466,0.848617885682,1.06072993902,1.48590080575,0.423367953954,0.84894482233,1.69853428409,1.2726247079,1.27345777595,0.849662755587,0.211760964354,0.42432337076,1.06204951651,1.06050803762,1.27395034511,1.27262413078,1.06099195003,0.848647318637,0.637179039051,0.637099108379,0.635627172068,0.0,0.0,0.0,0.0,0.212437806897,0.212281033127,0.849012056433,0.0,0.0,0.0,0.212060776514,0.0,0.0,0.0,0.211908013706,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_15
    y4_PT_15_weights = numpy.array([0.0,0.0,1.48145933214,1.02773175815,0.913812555037,0.230039122125,0.571482938632,0.341581832162,0.113137203039,0.0,0.343819689084,0.114071224206,0.114864836383,0.0,0.0,0.0,0.113137203039,0.0,0.0,0.0,0.0,0.0,0.0,0.343041057412,0.0,0.0,0.0,0.0,0.0,0.115905759849,0.0,0.114914830493,0.0,0.0,0.114337238966,0.0,0.34382775551,0.0,0.0,0.11422918432,0.0,0.114144619814,0.0,0.0,0.0,0.0,0.0,0.0,0.229372179422,0.114122193378,0.0,0.0,0.0,0.0,0.113795547464,0.228137395816,0.0,0.0,0.115905759849,0.113137203039,0.113449045733,0.0,0.115222152443,0.114071224206,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating weights for histo: y4_PT_16
    y4_PT_16_weights = numpy.array([0.0,0.0,0.162605602178,0.121873470418,0.0947348178819,0.06764164162,0.0270516297151,0.0135876322733,0.027095275389,0.0541827144219,0.0,0.0135400366601,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0135152253822,0.0135469606033,0.0,0.0,0.0,0.0,0.0135727680281,0.0,0.0,0.0,0.0,0.0,0.0135492849462,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0135550741481,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0135469606033,0.0,0.0,0.0,0.0,0.0,0.0,0.0135469606033,0.0,0.0,0.0,0.0,0.0135469606033,0.0,0.0,0.0135414774639,0.0135377527405,0.0135002716282,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0])

    # Creating a new Canvas
    fig   = plt.figure(figsize=(12,6),dpi=80)
    frame = gridspec.GridSpec(1,1,right=0.7)
    pad   = fig.add_subplot(frame[0])

    # Creating a new Stack
    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights+y4_PT_8_weights+y4_PT_9_weights+y4_PT_10_weights+y4_PT_11_weights+y4_PT_12_weights+y4_PT_13_weights+y4_PT_14_weights+y4_PT_15_weights+y4_PT_16_weights,\
             label="$bg\_dip\_1600\_inf$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#e5e5e5", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights+y4_PT_8_weights+y4_PT_9_weights+y4_PT_10_weights+y4_PT_11_weights+y4_PT_12_weights+y4_PT_13_weights+y4_PT_14_weights+y4_PT_15_weights,\
             label="$bg\_dip\_1200\_1600$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#f2f2f2", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights+y4_PT_8_weights+y4_PT_9_weights+y4_PT_10_weights+y4_PT_11_weights+y4_PT_12_weights+y4_PT_13_weights+y4_PT_14_weights,\
             label="$bg\_dip\_800\_1200$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#ccc6aa", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights+y4_PT_8_weights+y4_PT_9_weights+y4_PT_10_weights+y4_PT_11_weights+y4_PT_12_weights+y4_PT_13_weights,\
             label="$bg\_dip\_600\_800$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#ccc6aa", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights+y4_PT_8_weights+y4_PT_9_weights+y4_PT_10_weights+y4_PT_11_weights+y4_PT_12_weights,\
             label="$bg\_dip\_400\_600$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#c1bfa8", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights+y4_PT_8_weights+y4_PT_9_weights+y4_PT_10_weights+y4_PT_11_weights,\
             label="$bg\_dip\_200\_400$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#bab5a3", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights+y4_PT_8_weights+y4_PT_9_weights+y4_PT_10_weights,\
             label="$bg\_dip\_100\_200$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#b2a596", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights+y4_PT_8_weights+y4_PT_9_weights,\
             label="$bg\_dip\_0\_100$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#b7a39b", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights+y4_PT_8_weights,\
             label="$bg\_vbf\_1600\_inf$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#ad998c", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights,\
             label="$bg\_vbf\_1200\_1600$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#9b8e82", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights,\
             label="$bg\_vbf\_800\_1200$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#876656", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights,\
             label="$bg\_vbf\_600\_800$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#afcec6", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights,\
             label="$bg\_vbf\_400\_600$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#84c1a3", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights,\
             label="$bg\_vbf\_200\_400$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#89a8a0", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights,\
             label="$bg\_vbf\_100\_200$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#829e8c", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights+y4_PT_1_weights,\
             label="$bg\_vbf\_0\_100$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#adbcc6", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    pad.hist(x=xData, bins=xBinning, weights=y4_PT_0_weights,\
             label="$signal$", histtype="step", rwidth=1.0,\
             color=None, edgecolor="#7a8e99", linewidth=1, linestyle="solid",\
             bottom=None, cumulative=False, normed=False, align="mid", orientation="vertical")

    # Axis
    plt.rc('text',usetex=False)
    plt.xlabel(r"p_{T} [ j_{2} ]   ( GeV ) ",\
               fontsize=16,color="black")
    plt.ylabel(r"$\mathrm{Events}$ $(\mathcal{L}_{\mathrm{int}} = 3000.0\ \mathrm{fb}^{-1})$ ",\
               fontsize=16,color="black")

    # Boundary of y-axis
    ymax=(y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights+y4_PT_8_weights+y4_PT_9_weights+y4_PT_10_weights+y4_PT_11_weights+y4_PT_12_weights+y4_PT_13_weights+y4_PT_14_weights+y4_PT_15_weights+y4_PT_16_weights).max()*1.1
    #ymin=0 # linear scale
    ymin=min([x for x in (y4_PT_0_weights+y4_PT_1_weights+y4_PT_2_weights+y4_PT_3_weights+y4_PT_4_weights+y4_PT_5_weights+y4_PT_6_weights+y4_PT_7_weights+y4_PT_8_weights+y4_PT_9_weights+y4_PT_10_weights+y4_PT_11_weights+y4_PT_12_weights+y4_PT_13_weights+y4_PT_14_weights+y4_PT_15_weights+y4_PT_16_weights) if x])/100. # log scale
    plt.gca().set_ylim(ymin,ymax)

    # Log/Linear scale for X-axis
    plt.gca().set_xscale("linear")
    #plt.gca().set_xscale("log",nonposx="clip")

    # Log/Linear scale for Y-axis
    #plt.gca().set_yscale("linear")
    plt.gca().set_yscale("log",nonposy="clip")

    # Legend
    plt.legend(bbox_to_anchor=(1.05,1), loc=2, borderaxespad=0.)

    # Saving the image
    plt.savefig('../../HTML/MadAnalysis5job_0/selection_3.png')
    plt.savefig('../../PDF/MadAnalysis5job_0/selection_3.png')
    plt.savefig('../../DVI/MadAnalysis5job_0/selection_3.eps')

# Running!
if __name__ == '__main__':
    selection_3()