D47crunch API documentation

# D47crunch

Standardization and analytical error propagation of Δ47 and Δ48 clumped-isotope measurements

Process and standardize carbonate and/or CO2 clumped-isotope analyses, from low-level data out of a dual-inlet mass spectrometer to final, “absolute” Δ47 and Δ48 values with fully propagated analytical error estimates (Daëron, 2021).

The tutorial section takes you through a series of simple steps to import/process data and print out the results. The how-to section provides instructions applicable to various specific tasks.

## 1. Tutorial

### 1.1 Installation

The easy option is to use `pip`; open a shell terminal and simply type:

    python -m pip install D47crunch

For those wishing to experiment with the bleeding-edge development version, this can be done through the following steps:

1. Download the `dev` branch source code here and rename it to `D47crunch.py`.
2. Do any of the following:
   - copy `D47crunch.py` to somewhere in your Python path
   - copy `D47crunch.py` to a working directory (`import D47crunch` will only work if called within that directory)
   - copy `D47crunch.py` to any other location (e.g., `/foo/bar`) and then use the following code snippet in your own code to import `D47crunch`:

    import sys
    sys.path.append('/foo/bar')
    import D47crunch

Documentation for the development version can be downloaded here (save html file and open it locally).

### 1.2 Usage

Start by creating a file named `rawdata.csv` with the following contents:

    UID,  Sample,           d45,       d46,        d47,        d48,       d49
    A01,  ETH-1,        5.79502,  11.62767,   16.89351,   24.56708,   0.79486
    A02,  MYSAMPLE-1,   6.21907,  11.49107,   17.27749,   24.58270,   1.56318
    A03,  ETH-2,       -6.05868,  -4.81718,  -11.63506,  -10.32578,   0.61352
    A04,  MYSAMPLE-2,  -3.86184,   4.94184,    0.60612,   10.52732,   0.57118
    A05,  ETH-3,        5.54365,  12.05228,   17.40555,   25.96919,   0.74608
    A06,  ETH-2,       -6.06706,  -4.87710,  -11.69927,  -10.64421,   1.61234
    A07,  ETH-1,        5.78821,  11.55910,   16.80191,   24.56423,   1.47963
    A08,  MYSAMPLE-2,  -3.87692,   4.86889,    0.52185,   10.40390,   1.07032

Then instantiate a `D47data` object which will store and process this data:

    import D47crunch
    mydata = D47crunch.D47data()

For now, this object is empty:

    >>> print(mydata)
    []

To load the analyses saved in `rawdata.csv` into our `D47data` object and process the data:

    mydata.read('rawdata.csv')

    # compute δ13C, δ18O of working gas:
    mydata.wg()

    # compute δ13C, δ18O, raw Δ47 values for each analysis:
    mydata.crunch()

    # compute absolute Δ47 values for each analysis
    # as well as average Δ47 values for each sample:
    mydata.standardize()

We can now print a summary of the data processing:

    >>> mydata.summary(verbose = True, save_to_file = False)
    [summary]
    –––––––––––––––––––––––––––––––  –––––––––
    N samples (anchors + unknowns)   5 (3 + 2)
    N analyses (anchors + unknowns)  8 (5 + 3)
    Repeatability of δ13C_VPDB         4.2 ppm
    Repeatability of δ18O_VSMOW       47.5 ppm
    Repeatability of Δ47 (anchors)    13.4 ppm
    Repeatability of Δ47 (unknowns)    2.5 ppm
    Repeatability of Δ47 (all)         9.6 ppm
    Model degrees of freedom                 3
    Student's 95% t-factor                3.18
    Standardization method              pooled
    –––––––––––––––––––––––––––––––  –––––––––

This tells us that our data set contains 5 different samples: 3 anchors (ETH-1, ETH-2, ETH-3) and 2 unknowns (MYSAMPLE-1, MYSAMPLE-2). The total number of analyses is 8, with 5 anchor analyses and 3 unknown analyses. We get an estimate of the analytical repeatability (i.e. the overall, pooled standard deviation) for δ13C, δ18O and Δ47, as well as the number of degrees of freedom (here, 3) that these estimated standard deviations are based on, along with the corresponding Student's t-factor (here, 3.18) for 95 % confidence limits. Finally, the summary indicates that we used a “pooled” standardization approach (see [Daëron, 2021]).

To see the actual results:

    >>> mydata.table_of_samples(verbose = True, save_to_file = False)
    [table_of_samples]
    ––––––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    Sample      N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
    ––––––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    ETH-1       2       2.01       37.01  0.2052                    0.0131
    ETH-2       2     -10.17       19.88  0.2085                    0.0026
    ETH-3       1       1.73       37.49  0.6132
    MYSAMPLE-1  1       2.48       36.90  0.2996  0.0091  ± 0.0291
    MYSAMPLE-2  2      -8.17       30.05  0.6600  0.0115  ± 0.0366  0.0025
    ––––––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––

This table lists, for each sample, the number of analytical replicates, average δ13C and δ18O values (for the analyte CO2 , not for the carbonate itself), the average Δ47 value and the SD of Δ47 for all replicates of this sample. For unknown samples, the SE and 95 % confidence limits for mean Δ47 are also listed These 95 % CL take into account the number of degrees of freedom of the regression model, so that in large datasets the 95 % CL will tend to 1.96 times the SE, but in this case the applicable t-factor is much larger.

We can also generate a table of all analyses in the data set (again, note that `d18O_VSMOW` is the composition of the CO2 analyte):

    >>> mydata.table_of_analyses(verbose = True, save_to_file = False)
    [table_of_analyses]
    –––  –––––––––  ––––––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––
    UID    Session      Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48       d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw      D49raw       D47
    –––  –––––––––  ––––––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––
    A01  mySession       ETH-1       -3.807        24.921   5.795020  11.627670   16.893510   24.567080  0.794860    2.014086   37.041843  -0.574686   1.149684  -27.690250  0.214454
    A02  mySession  MYSAMPLE-1       -3.807        24.921   6.219070  11.491070   17.277490   24.582700  1.563180    2.476827   36.898281  -0.499264   1.435380  -27.122614  0.299589
    A03  mySession       ETH-2       -3.807        24.921  -6.058680  -4.817180  -11.635060  -10.325780  0.613520  -10.166796   19.907706  -0.685979  -0.721617   16.716901  0.206693
    A04  mySession  MYSAMPLE-2       -3.807        24.921  -3.861840   4.941840    0.606120   10.527320  0.571180   -8.159927   30.087230  -0.248531   0.613099   -4.979413  0.658270
    A05  mySession       ETH-3       -3.807        24.921   5.543650  12.052280   17.405550   25.969190  0.746080    1.727029   37.485567  -0.226150   1.678699  -28.280301  0.613200
    A06  mySession       ETH-2       -3.807        24.921  -6.067060  -4.877100  -11.699270  -10.644210  1.612340  -10.173599   19.845192  -0.683054  -0.922832   17.861363  0.210328
    A07  mySession       ETH-1       -3.807        24.921   5.788210  11.559100   16.801910   24.564230  1.479630    2.009281   36.970298  -0.591129   1.282632  -26.888335  0.195926
    A08  mySession  MYSAMPLE-2       -3.807        24.921  -3.876920   4.868890    0.521850   10.403900  1.070320   -8.173486   30.011134  -0.245768   0.636159   -4.324964  0.661803
    –––  –––––––––  ––––––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––

## 2. How-to

### 2.1 Simulate a virtual data set to play with

It is sometimes convenient to quickly build a virtual data set of analyses, for instance to assess the final analytical precision achievable for a given combination of anchor and unknown analyses (see also Fig. 6 of Daëron, 2021).

This can be achieved with `virtual_data()`. The example below creates a dataset with four sessions, each of which comprises four analyses of anchor ETH-1, five of ETH-2, six of ETH-3, and two analyses of an unknown sample named `FOO` with an arbitrarily defined isotopic composition. Analytical repeatabilities for Δ47 and Δ48 are also specified arbitrarily. See the `virtual_data()` documentation for additional configuration parameters.

    from D47crunch import *

    args = dict(
        samples = [
            dict(Sample = 'ETH-1', N = 4),
            dict(Sample = 'ETH-2', N = 5),
            dict(Sample = 'ETH-3', N = 6),
            dict(
                Sample = 'FOO',
                N = 2,
                d13C_VPDB = -5.,
                d18O_VPDB = -10.,
                D47 = 0.3,
                D48 = 0.15
                ),
            ],
        rD47 = 0.010,
        rD48 = 0.030,
        )

    session1 = virtual_data(session = 'Session_01', **args)
    session2 = virtual_data(session = 'Session_02', **args)
    session3 = virtual_data(session = 'Session_03', **args)
    session4 = virtual_data(session = 'Session_04', **args)

    D = D47data(session1 + session2 + session3 + session4)

    D.crunch()
    D.standardize()

    D.table_of_sessions(verbose = True, save_to_file = False)
    D.table_of_samples(verbose = True, save_to_file = False)
    D.table_of_analyses(verbose = True, save_to_file = False)

### 2.2 Control data quality

`D47crunch` offers several tools to visualize processed data. The examples below use the same virtual data set, generated with:

    from D47crunch import *
    from random import shuffle

    # generate virtual data:
    args = dict(
        samples = [
            dict(Sample = 'ETH-1', N = 8),
            dict(Sample = 'ETH-2', N = 8),
            dict(Sample = 'ETH-3', N = 8),
            dict(Sample = 'FOO', N = 4,
                d13C_VPDB = -5., d18O_VPDB = -10.,
                D47 = 0.3, D48 = 0.15),
            dict(Sample = 'BAR', N = 4,
                d13C_VPDB = -15., d18O_VPDB = -15.,
                D47 = 0.5, D48 = 0.2),
            ])

    sessions = [
        virtual_data(session = f'Session_{k+1:02.0f}', seed = int('1234567890'[:k+1]), **args)
        for k in range(10)]

    # shuffle the data:
    data = [r for s in sessions for r in s]
    shuffle(data)
    data = sorted(data, key = lambda r: r['Session'])

    # create D47data instance:
    data47 = D47data(data)

    # process D47data instance:
    data47.crunch()
    data47.standardize()

#### 2.1.1 Plotting the distribution of analyses through time

    data47.plot_distribution_of_analyses(filename = 'time_distribution.pdf')

The plot above shows the succession of analyses as if they were all distributed at regular time intervals. See `D4xdata.plot_distribution_of_analyses()` for how to plot analyses as a function of “true” time (based on the `TimeTag` for each analysis).

#### 2.1.2 Generating session plots

    data47.plot_sessions()

Below is one of the resulting sessions plots. Each cross marker is an analysis. Anchors are in red and unknowns in blue. Short horizontal lines show the nominal Δ47 value for anchors, in red, or the average Δ47 value for unknowns, in blue (overall average for all sessions). Curved grey contours correspond to Δ47 standardization errors in this session.

#### 2.1.3 Plotting Δ47 or Δ48 residuals

    data47.plot_residuals(filename = 'residuals.pdf')

Again, note that this plot only shows the succession of analyses as if they were all distributed at regular time intervals.

### 2.3 Use a different set of anchors, change anchor nominal values, and/or change 17O correction parameters

Nominal values for various carbonate standards are defined in four places:

- `D4xdata.Nominal_d13C_VPDB`
- `D4xdata.Nominal_d18O_VPDB`
- `D47data.Nominal_D4x` (also accessible through `D47data.Nominal_D47`)
- `D48data.Nominal_D4x` (also accessible through `D48data.Nominal_D48`)

17O correction parameters are defined by:

- `D4xdata.R13_VPDB`
- `D4xdata.R18_VSMOW`
- `D4xdata.R18_VPDB`
- `D4xdata.LAMBDA_17`
- `D4xdata.R17_VSMOW`
- `D4xdata.R17_VPDB`

When creating a new instance of `D47data` or `D48data`, the current values of these variables are copied as properties of the new object. Applying custom values for, e.g., `R17_VSMOW` and `Nominal_D47` can thus be done in several ways:

Option 1: by redefining `D4xdata.R17_VSMOW` and `D47data.Nominal_D47` _before_ creating a `D47data` object:

    from D47crunch import D4xdata, D47data

    # redefine R17_VSMOW:
    D4xdata.R17_VSMOW = 0.00037 # new value

    # redefine R17_VPDB for consistency:
    D4xdata.R17_VPDB = D4xdata.R17_VSMOW * (D4xdata.R18_VPDB/D4xdata.R18_VSMOW) ** D4xdata.LAMBDA_17

    # edit Nominal_D47 to only include ETH-1/2/3:
    D47data.Nominal_D4x = {
        a: D47data.Nominal_D4x[a]
        for a in ['ETH-1', 'ETH-2', 'ETH-3']
        }
    # redefine ETH-3:
    D47data.Nominal_D4x['ETH-3'] = 0.600

    # only now create D47data object:
    mydata = D47data()

    # check the results:
    print(mydata.R17_VSMOW, mydata.R17_VPDB)
    print(mydata.Nominal_D47)
    # NB: mydata.Nominal_D47 is just an alias for mydata.Nominal_D4x

    # should print out:
    # 0.00037 0.00037599710894149464
    # {'ETH-1': 0.2052, 'ETH-2': 0.2085, 'ETH-3': 0.6}

Option 2: by redefining `R17_VSMOW` and `Nominal_D47` _after_ creating a `D47data` object:

    from D47crunch import D47data

    # first create D47data object:
    mydata = D47data()

    # redefine R17_VSMOW:
    mydata.R17_VSMOW = 0.00037 # new value

    # redefine R17_VPDB for consistency:
    mydata.R17_VPDB = mydata.R17_VSMOW * (mydata.R18_VPDB/mydata.R18_VSMOW) ** mydata.LAMBDA_17

    # edit Nominal_D47 to only include ETH-1/2/3:
    mydata.Nominal_D47 = {
        a: mydata.Nominal_D47[a]
        for a in ['ETH-1', 'ETH-2', 'ETH-3']
        }
    # redefine ETH-3:
    mydata.Nominal_D47['ETH-3'] = 0.600

    # check the results:
    print(mydata.R17_VSMOW, mydata.R17_VPDB)
    print(mydata.Nominal_D47)

    # should print out:
    # 0.00037 0.00037599710894149464
    # {'ETH-1': 0.2052, 'ETH-2': 0.2085, 'ETH-3': 0.6}

The two options above are equivalent, but the latter provides a simple way to compare different data processing choices:

    from D47crunch import D47data

    # create two D47data objects:
    foo = D47data()
    bar = D47data()

    # modify foo in various ways:
    foo.LAMBDA_17 = 0.52
    foo.R17_VSMOW = 0.00037 # new value
    foo.R17_VPDB = foo.R17_VSMOW * (foo.R18_VPDB/foo.R18_VSMOW) ** foo.LAMBDA_17
    foo.Nominal_D47 = {
        'ETH-1': foo.Nominal_D47['ETH-1'],
        'ETH-2': foo.Nominal_D47['ETH-1'],
        'IAEA-C2': foo.Nominal_D47['IAEA-C2'],
        'INLAB_REF_MATERIAL': 0.666,
        }

    # now import the same raw data into foo and bar:
    foo.read('rawdata.csv')
    foo.wg()          # compute δ13C, δ18O of working gas
    foo.crunch()      # compute all δ13C, δ18O and raw Δ47 values
    foo.standardize() # compute absolute Δ47 values

    bar.read('rawdata.csv')
    bar.wg()          # compute δ13C, δ18O of working gas
    bar.crunch()      # compute all δ13C, δ18O and raw Δ47 values
    bar.standardize() # compute absolute Δ47 values

    # and compare the final results:
    foo.table_of_samples(verbose = True, save_to_file = False)
    bar.table_of_samples(verbose = True, save_to_file = False)

### 2.4 Process paired Δ47 and Δ48 values

Purely in terms of data processing, it is not obvious why Δ47 and Δ48 data should not be handled separately. For now, `D47crunch` uses two independent classes — `D47data` and `D48data` — which crunch numbers and deal with standardization in very similar ways. The following example demonstrates how to print out combined outputs for `D47data` and `D48data`.

    from D47crunch import *

    # generate virtual data:
    args = dict(
        samples = [
            dict(Sample = 'ETH-1', N = 3),
            dict(Sample = 'ETH-2', N = 3),
            dict(Sample = 'ETH-3', N = 3),
            dict(Sample = 'FOO', N = 3,
                d13C_VPDB = -5., d18O_VPDB = -10.,
                D47 = 0.3, D48 = 0.15),
            ], rD47 = 0.010, rD48 = 0.030)

    session1 = virtual_data(session = 'Session_01', **args)
    session2 = virtual_data(session = 'Session_02', **args)

    # create D47data instance:
    data47 = D47data(session1 + session2)

    # process D47data instance:
    data47.crunch()
    data47.standardize()

    # create D48data instance:
    data48 = D48data(data47) # alternatively: data48 = D48data(session1 + session2)

    # process D48data instance:
    data48.crunch()
    data48.standardize()

    # output combined results:
    table_of_sessions(data47, data48)
    table_of_samples(data47, data48)
    table_of_analyses(data47, data48)

Expected output:

    ––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––
    Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47      a_47 ± SE  1e3 x b_47 ± SE       c_47 ± SE   r_D48      a_48 ± SE  1e3 x b_48 ± SE       c_48 ± SE
    ––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––
    Session_01   9   3       -4.000        26.000  0.0000  0.0000  0.0098  1.021 ± 0.019   -0.398 ± 0.260  -0.903 ± 0.006  0.0486  0.540 ± 0.151    1.235 ± 0.607  -0.390 ± 0.025
    Session_02   9   3       -4.000        26.000  0.0000  0.0000  0.0090  1.015 ± 0.019    0.376 ± 0.260  -0.905 ± 0.006  0.0186  1.350 ± 0.156   -0.871 ± 0.608  -0.504 ± 0.027
    ––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––  ––––––  –––––––––––––  –––––––––––––––  ––––––––––––––

    ––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    Sample  N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene     D48      SE    95% CL      SD  p_Levene
    ––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    ETH-1   6       2.02       37.02  0.2052                    0.0078            0.1380                    0.0223
    ETH-2   6     -10.17       19.88  0.2085                    0.0036            0.1380                    0.0482
    ETH-3   6       1.71       37.45  0.6132                    0.0080            0.2700                    0.0176
    FOO     6      -5.00       28.91  0.3026  0.0044  ± 0.0093  0.0121     0.164  0.1397  0.0121  ± 0.0255  0.0267     0.127
    ––––––  –  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––

    –––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––  ––––––––
    UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47       D48
    –––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––  ––––––––
    1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.120787   21.286237   27.780042    2.020000   37.024281  -0.708176  -0.316435  -0.000013  0.197297  0.087763
    2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.132240   21.307795   27.780042    2.020000   37.024281  -0.696913  -0.295333  -0.000013  0.208328  0.126791
    3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.132438   21.313884   27.780042    2.020000   37.024281  -0.696718  -0.289374  -0.000013  0.208519  0.137813
    4    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700300  -12.210735  -18.023381  -10.170000   19.875825  -0.683938  -0.297902  -0.000002  0.209785  0.198705
    5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.707421  -12.270781  -18.023381  -10.170000   19.875825  -0.691145  -0.358673  -0.000002  0.202726  0.086308
    6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700061  -12.278310  -18.023381  -10.170000   19.875825  -0.683696  -0.366292  -0.000002  0.210022  0.072215
    7    Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684379   22.225827   28.306614    1.710000   37.450394  -0.273094  -0.216392  -0.000014  0.623472  0.270873
    8    Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.660163   22.233729   28.306614    1.710000   37.450394  -0.296906  -0.208664  -0.000014  0.600150  0.285167
    9    Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.675191   22.215632   28.306614    1.710000   37.450394  -0.282128  -0.226363  -0.000014  0.614623  0.252432
    10   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328380    5.374933    4.665655   -5.000000   28.907344  -0.582131  -0.288924  -0.000006  0.314928  0.175105
    11   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.302220    5.384454    4.665655   -5.000000   28.907344  -0.608241  -0.279457  -0.000006  0.289356  0.192614
    12   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.322530    5.372841    4.665655   -5.000000   28.907344  -0.587970  -0.291004  -0.000006  0.309209  0.171257
    13   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140853   21.267202   27.780042    2.020000   37.024281  -0.688442  -0.335067  -0.000013  0.207730  0.138730
    14   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.127087   21.256983   27.780042    2.020000   37.024281  -0.701980  -0.345071  -0.000013  0.194396  0.131311
    15   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.148253   21.287779   27.780042    2.020000   37.024281  -0.681165  -0.314926  -0.000013  0.214898  0.153668
    16   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715859  -12.204791  -18.023381  -10.170000   19.875825  -0.699685  -0.291887  -0.000002  0.207349  0.149128
    17   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709763  -12.188685  -18.023381  -10.170000   19.875825  -0.693516  -0.275587  -0.000002  0.213426  0.161217
    18   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715427  -12.253049  -18.023381  -10.170000   19.875825  -0.699249  -0.340727  -0.000002  0.207780  0.112907
    19   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.685994   22.249463   28.306614    1.710000   37.450394  -0.271506  -0.193275  -0.000014  0.618328  0.244431
    20   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.681351   22.298166   28.306614    1.710000   37.450394  -0.276071  -0.145641  -0.000014  0.613831  0.279758
    21   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.676169   22.306848   28.306614    1.710000   37.450394  -0.281167  -0.137150  -0.000014  0.608813  0.286056
    22   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.324359    5.339497    4.665655   -5.000000   28.907344  -0.586144  -0.324160  -0.000006  0.314015  0.136535
    23   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.297658    5.325854    4.665655   -5.000000   28.907344  -0.612794  -0.337727  -0.000006  0.287767  0.126473
    24   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.310185    5.339898    4.665655   -5.000000   28.907344  -0.600291  -0.323761  -0.000006  0.300082  0.136830
    –––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––  ––––––––

## D47crunch API

View Source

    '''
    Standardization and analytical error propagation of Δ47 and Δ48 clumped-isotope measurements

    Process and standardize carbonate and/or CO2 clumped-isotope analyses,
    from low-level data out of a dual-inlet mass spectrometer to final, “absolute”
    Δ47 and Δ48 values with fully propagated analytical error estimates
    ([Daëron, 2021](https://doi.org/10.1029/2020GC009592)).

    The **tutorial** section takes you through a series of simple steps to import/process data and print out the results.
    The **how-to** section provides instructions applicable to various specific tasks.

    .. include:: ../docs/tutorial.md
    .. include:: ../docs/howto.md

    ## D47crunch API
    '''

    __docformat__ = "restructuredtext"
    __author__    = 'Mathieu Daëron'
    __contact__   = 'daeron@lsce.ipsl.fr'
    __copyright__ = 'Copyright (c) 2022 Mathieu Daëron'
    __license__   = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause'
    __date__      = '2022-02-27'
    __version__   = '2.0.3'

    import os
    import numpy as np
    from statistics import stdev
    from scipy.stats import t as tstudent
    from scipy.stats import levene
    from scipy.interpolate import interp1d
    from numpy import linalg
    from lmfit import Minimizer, Parameters, report_fit
    from matplotlib import pyplot as ppl
    from datetime import datetime as dt
    from functools import wraps
    from colorsys import hls_to_rgb
    from matplotlib import rcParams

    rcParams['font.family'] = 'sans-serif'
    rcParams['font.sans-serif'] = 'Helvetica'
    rcParams['font.size'] = 10
    rcParams['mathtext.fontset'] = 'custom'
    rcParams['mathtext.rm'] = 'sans'
    rcParams['mathtext.bf'] = 'sans:bold'
    rcParams['mathtext.it'] = 'sans:italic'
    rcParams['mathtext.cal'] = 'sans:italic'
    rcParams['mathtext.default'] = 'rm'
    rcParams['xtick.major.size'] = 4
    rcParams['xtick.major.width'] = 1
    rcParams['ytick.major.size'] = 4
    rcParams['ytick.major.width'] = 1
    rcParams['axes.grid'] = False
    rcParams['axes.linewidth'] = 1
    rcParams['grid.linewidth'] = .75
    rcParams['grid.linestyle'] = '-'
    rcParams['grid.alpha'] = .15
    rcParams['savefig.dpi'] = 150

    Petersen_etal_CO2eqD47 = np.array([[-12, 1.147113572], [-11, 1.139961218], [-10, 1.132872856], [-9, 1.125847677], [-8, 1.118884889], [-7, 1.111983708], [-6, 1.105143366], [-5, 1.098363105], [-4, 1.091642182], [-3, 1.084979862], [-2, 1.078375423], [-1, 1.071828156], [0, 1.065337360], [1, 1.058902349], [2, 1.052522443], [3, 1.046196976], [4, 1.039925291], [5, 1.033706741], [6, 1.027540690], [7, 1.021426510], [8, 1.015363585], [9, 1.009351306], [10, 1.003389075], [11, 0.997476303], [12, 0.991612409], [13, 0.985796821], [14, 0.980028975], [15, 0.974308318], [16, 0.968634304], [17, 0.963006392], [18, 0.957424055], [19, 0.951886769], [20, 0.946394020], [21, 0.940945302], [22, 0.935540114], [23, 0.930177964], [24, 0.924858369], [25, 0.919580851], [26, 0.914344938], [27, 0.909150167], [28, 0.903996080], [29, 0.898882228], [30, 0.893808167], [31, 0.888773459], [32, 0.883777672], [33, 0.878820382], [34, 0.873901170], [35, 0.869019623], [36, 0.864175334], [37, 0.859367901], [38, 0.854596929], [39, 0.849862028], [40, 0.845162813], [41, 0.840498905], [42, 0.835869931], [43, 0.831275522], [44, 0.826715314], [45, 0.822188950], [46, 0.817696075], [47, 0.813236341], [48, 0.808809404], [49, 0.804414926], [50, 0.800052572], [51, 0.795722012], [52, 0.791422922], [53, 0.787154979], [54, 0.782917869], [55, 0.778711277], [56, 0.774534898], [57, 0.770388426], [58, 0.766271562], [59, 0.762184010], [60, 0.758125479], [61, 0.754095680], [62, 0.750094329], [63, 0.746121147], [64, 0.742175856], [65, 0.738258184], [66, 0.734367860], [67, 0.730504620], [68, 0.726668201], [69, 0.722858343], [70, 0.719074792], [71, 0.715317295], [72, 0.711585602], [73, 0.707879469], [74, 0.704198652], [75, 0.700542912], [76, 0.696912012], [77, 0.693305719], [78, 0.689723802], [79, 0.686166034], [80, 0.682632189], [81, 0.679122047], [82, 0.675635387], [83, 0.672171994], [84, 0.668731654], [85, 0.665314156], [86, 0.661919291], [87, 0.658546854], [88, 0.655196641], [89, 0.651868451], [90, 0.648562087], [91, 0.645277352], [92, 0.642014054], [93, 0.638771999], [94, 0.635551001], [95, 0.632350872], [96, 0.629171428], [97, 0.626012487], [98, 0.622873870], [99, 0.619755397], [100, 0.616656895], [102, 0.610519107], [104, 0.604459143], [106, 0.598475670], [108, 0.592567388], [110, 0.586733026], [112, 0.580971342], [114, 0.575281125], [116, 0.569661187], [118, 0.564110371], [120, 0.558627545], [122, 0.553211600], [124, 0.547861454], [126, 0.542576048], [128, 0.537354347], [130, 0.532195337], [132, 0.527098028], [134, 0.522061450], [136, 0.517084654], [138, 0.512166711], [140, 0.507306712], [142, 0.502503768], [144, 0.497757006], [146, 0.493065573], [148, 0.488428634], [150, 0.483845370], [152, 0.479314980], [154, 0.474836677], [156, 0.470409692], [158, 0.466033271], [160, 0.461706674], [162, 0.457429176], [164, 0.453200067], [166, 0.449018650], [168, 0.444884242], [170, 0.440796174], [172, 0.436753787], [174, 0.432756438], [176, 0.428803494], [178, 0.424894334], [180, 0.421028350], [182, 0.417204944], [184, 0.413423530], [186, 0.409683531], [188, 0.405984383], [190, 0.402325531], [192, 0.398706429], [194, 0.395126543], [196, 0.391585347], [198, 0.388082324], [200, 0.384616967], [202, 0.381188778], [204, 0.377797268], [206, 0.374441954], [208, 0.371122364], [210, 0.367838033], [212, 0.364588505], [214, 0.361373329], [216, 0.358192065], [218, 0.355044277], [220, 0.351929540], [222, 0.348847432], [224, 0.345797540], [226, 0.342779460], [228, 0.339792789], [230, 0.336837136], [232, 0.333912113], [234, 0.331017339], [236, 0.328152439], [238, 0.325317046], [240, 0.322510795], [242, 0.319733329], [244, 0.316984297], [246, 0.314263352], [248, 0.311570153], [250, 0.308904364], [252, 0.306265654], [254, 0.303653699], [256, 0.301068176], [258, 0.298508771], [260, 0.295975171], [262, 0.293467070], [264, 0.290984167], [266, 0.288526163], [268, 0.286092765], [270, 0.283683684], [272, 0.281298636], [274, 0.278937339], [276, 0.276599517], [278, 0.274284898], [280, 0.271993211], [282, 0.269724193], [284, 0.267477582], [286, 0.265253121], [288, 0.263050554], [290, 0.260869633], [292, 0.258710110], [294, 0.256571741], [296, 0.254454286], [298, 0.252357508], [300, 0.250281174], [302, 0.248225053], [304, 0.246188917], [306, 0.244172542], [308, 0.242175707], [310, 0.240198194], [312, 0.238239786], [314, 0.236300272], [316, 0.234379441], [318, 0.232477087], [320, 0.230593005], [322, 0.228726993], [324, 0.226878853], [326, 0.225048388], [328, 0.223235405], [330, 0.221439711], [332, 0.219661118], [334, 0.217899439], [336, 0.216154491], [338, 0.214426091], [340, 0.212714060], [342, 0.211018220], [344, 0.209338398], [346, 0.207674420], [348, 0.206026115], [350, 0.204393315], [355, 0.200378063], [360, 0.196456139], [365, 0.192625077], [370, 0.188882487], [375, 0.185226048], [380, 0.181653511], [385, 0.178162694], [390, 0.174751478], [395, 0.171417807], [400, 0.168159686], [405, 0.164975177], [410, 0.161862398], [415, 0.158819521], [420, 0.155844772], [425, 0.152936426], [430, 0.150092806], [435, 0.147312286], [440, 0.144593281], [445, 0.141934254], [450, 0.139333710], [455, 0.136790195], [460, 0.134302294], [465, 0.131868634], [470, 0.129487876], [475, 0.127158722], [480, 0.124879906], [485, 0.122650197], [490, 0.120468398], [495, 0.118333345], [500, 0.116243903], [505, 0.114198970], [510, 0.112197471], [515, 0.110238362], [520, 0.108320625], [525, 0.106443271], [530, 0.104605335], [535, 0.102805877], [540, 0.101043985], [545, 0.099318768], [550, 0.097629359], [555, 0.095974915], [560, 0.094354612], [565, 0.092767650], [570, 0.091213248], [575, 0.089690648], [580, 0.088199108], [585, 0.086737906], [590, 0.085306341], [595, 0.083903726], [600, 0.082529395], [605, 0.081182697], [610, 0.079862998], [615, 0.078569680], [620, 0.077302141], [625, 0.076059794], [630, 0.074842066], [635, 0.073648400], [640, 0.072478251], [645, 0.071331090], [650, 0.070206399], [655, 0.069103674], [660, 0.068022424], [665, 0.066962168], [670, 0.065922439], [675, 0.064902780], [680, 0.063902748], [685, 0.062921909], [690, 0.061959837], [695, 0.061016122], [700, 0.060090360], [705, 0.059182157], [710, 0.058291131], [715, 0.057416907], [720, 0.056559120], [725, 0.055717414], [730, 0.054891440], [735, 0.054080860], [740, 0.053285343], [745, 0.052504565], [750, 0.051738210], [755, 0.050985971], [760, 0.050247546], [765, 0.049522643], [770, 0.048810974], [775, 0.048112260], [780, 0.047426227], [785, 0.046752609], [790, 0.046091145], [795, 0.045441581], [800, 0.044803668], [805, 0.044177164], [810, 0.043561831], [815, 0.042957438], [820, 0.042363759], [825, 0.041780573], [830, 0.041207664], [835, 0.040644822], [840, 0.040091839], [845, 0.039548516], [850, 0.039014654], [855, 0.038490063], [860, 0.037974554], [865, 0.037467944], [870, 0.036970054], [875, 0.036480707], [880, 0.035999734], [885, 0.035526965], [890, 0.035062238], [895, 0.034605393], [900, 0.034156272], [905, 0.033714724], [910, 0.033280598], [915, 0.032853749], [920, 0.032434032], [925, 0.032021309], [930, 0.031615443], [935, 0.031216300], [940, 0.030823749], [945, 0.030437663], [950, 0.030057915], [955, 0.029684385], [960, 0.029316951], [965, 0.028955498], [970, 0.028599910], [975, 0.028250075], [980, 0.027905884], [985, 0.027567229], [990, 0.027234006], [995, 0.026906112], [1000, 0.026583445], [1005, 0.026265908], [1010, 0.025953405], [1015, 0.025645841], [1020, 0.025343124], [1025, 0.025045163], [1030, 0.024751871], [1035, 0.024463160], [1040, 0.024178947], [1045, 0.023899147], [1050, 0.023623680], [1055, 0.023352467], [1060, 0.023085429], [1065, 0.022822491], [1070, 0.022563577], [1075, 0.022308615], [1080, 0.022057533], [1085, 0.021810260], [1090, 0.021566729], [1095, 0.021326872], [1100, 0.021090622]])
    _fCO2eqD47_Petersen = interp1d(Petersen_etal_CO2eqD47[:,0], Petersen_etal_CO2eqD47[:,1])
    def fCO2eqD47_Petersen(T):
    	'''
    	CO2 equilibrium Δ47 value as a function of T (in degrees C)
    	according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).

    	'''
    	return float(_fCO2eqD47_Petersen(T))

    Wang_etal_CO2eqD47 = np.array([[-83., 1.8954], [-73., 1.7530], [-63., 1.6261], [-53., 1.5126], [-43., 1.4104], [-33., 1.3182], [-23., 1.2345], [-13., 1.1584], [-3., 1.0888], [7., 1.0251], [17., 0.9665], [27., 0.9125], [37., 0.8626], [47., 0.8164], [57., 0.7734], [67., 0.7334], [87., 0.6612], [97., 0.6286], [107., 0.5980], [117., 0.5693], [127., 0.5423], [137., 0.5169], [147., 0.4930], [157., 0.4704], [167., 0.4491], [177., 0.4289], [187., 0.4098], [197., 0.3918], [207., 0.3747], [217., 0.3585], [227., 0.3431], [237., 0.3285], [247., 0.3147], [257., 0.3015], [267., 0.2890], [277., 0.2771], [287., 0.2657], [297., 0.2550], [307., 0.2447], [317., 0.2349], [327., 0.2256], [337., 0.2167], [347., 0.2083], [357., 0.2002], [367., 0.1925], [377., 0.1851], [387., 0.1781], [397., 0.1714], [407., 0.1650], [417., 0.1589], [427., 0.1530], [437., 0.1474], [447., 0.1421], [457., 0.1370], [467., 0.1321], [477., 0.1274], [487., 0.1229], [497., 0.1186], [507., 0.1145], [517., 0.1105], [527., 0.1068], [537., 0.1031], [547., 0.0997], [557., 0.0963], [567., 0.0931], [577., 0.0901], [587., 0.0871], [597., 0.0843], [607., 0.0816], [617., 0.0790], [627., 0.0765], [637., 0.0741], [647., 0.0718], [657., 0.0695], [667., 0.0674], [677., 0.0654], [687., 0.0634], [697., 0.0615], [707., 0.0597], [717., 0.0579], [727., 0.0562], [737., 0.0546], [747., 0.0530], [757., 0.0515], [767., 0.0500], [777., 0.0486], [787., 0.0472], [797., 0.0459], [807., 0.0447], [817., 0.0435], [827., 0.0423], [837., 0.0411], [847., 0.0400], [857., 0.0390], [867., 0.0380], [877., 0.0370], [887., 0.0360], [897., 0.0351], [907., 0.0342], [917., 0.0333], [927., 0.0325], [937., 0.0317], [947., 0.0309], [957., 0.0302], [967., 0.0294], [977., 0.0287], [987., 0.0281], [997., 0.0274], [1007., 0.0268], [1017., 0.0261], [1027., 0.0255], [1037., 0.0249], [1047., 0.0244], [1057., 0.0238], [1067., 0.0233], [1077., 0.0228], [1087., 0.0223], [1097., 0.0218]])
    _fCO2eqD47_Wang = interp1d(Wang_etal_CO2eqD47[:,0] - 0.15, Wang_etal_CO2eqD47[:,1])
    def fCO2eqD47_Wang(T):
    	'''
    	CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
    	according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
    	(supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
    	'''
    	return float(_fCO2eqD47_Wang(T))

    def correlated_sum(X, C, w = None):
    	'''
    	Compute covariance-aware linear combinations

    	**Parameters**

    	+ `X`: list or 1-D array of values to sum
    	+ `C`: covariance matrix for the elements of `X`
    	+ `w`: list or 1-D array of weights to apply to the elements of `X`
    	       (all equal to 1 by default)

    	Return the sum (and its SE) of the elements of `X`, with optional weights equal
    	to the elements of `w`, accounting for covariances between the elements of `X`.
    	'''
    	if w is None:
    		w = [1 for x in X]
    	return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5

    def make_csv(x, hsep = ',', vsep = '\n'):
    	'''
    	Formats a list of lists of strings as a CSV

    	**Parameters**

    	+ `x`: the list of lists of strings to format
    	+ `hsep`: the field separator (`,` by default)
    	+ `vsep`: the line-ending convention to use (`\\n` by default)

    	**Example**

    	```py
    	print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))
    	```

    	outputs:

    	```py
    	a,b,c
    	d,e,f
    	```
    	'''
    	return vsep.join([hsep.join(l) for l in x])

    def pf(txt):
    	'''
    	Modify string `txt` to follow `lmfit.Parameter()` naming rules.
    	'''
    	return txt.replace('-','_').replace('.','_').replace(' ','_')

    def smart_type(x):
    	'''
    	Tries to convert string `x` to a float if it includes a decimal point, or
    	to an integer if it does not. If both attempts fail, return the original
    	string unchanged.
    	'''
    	try:
    		y = float(x)
    	except ValueError:
    		return x
    	if '.' not in x:
    		return int(y)
    	return y

    def pretty_table(x, header = 1, hsep = '  ', vsep = '–', align = '<'):
    	'''
    	Reads a list of lists of strings and outputs an ascii table

    	**Parameters**

    	+ `x`: a list of lists of strings
    	+ `header`: the number of lines to treat as header lines
    	+ `hsep`: the horizontal separator between columns
    	+ `vsep`: the character to use as vertical separator
    	+ `align`: string of left (`<`) or right (`>`) alignment characters.

    	**Example**

    	```py
    	x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']]
    	print(pretty_table(x))
    	```
    	yields:
    	```
    	--  ------  ---
    	A        B    C
    	--  ------  ---
    	1   1.9999  foo
    	10       x  bar
    	--  ------  ---
    	```

    	'''
    	txt = []
    	widths = [np.max([len(e) for e in c]) for c in zip(*x)]

    	if len(widths) > len(align):
    		align += '>' * (len(widths)-len(align))
    	sepline = hsep.join([vsep*w for w in widths])
    	txt += [sepline]
    	for k,l in enumerate(x):
    		if k and k == header:
    			txt += [sepline]
    		txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])]
    	txt += [sepline]
    	txt += ['']
    	return '\n'.join(txt)

    def transpose_table(x):
    	'''
    	Transpose a list if lists

    	**Parameters**

    	+ `x`: a list of lists

    	**Example**

    	```py
    	x = [[1, 2], [3, 4]]
    	print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
    	```
    	'''
    	return [[e for e in c] for c in zip(*x)]

    def w_avg(X, sX) :
    	'''
    	Compute variance-weighted average

    	Returns the value and SE of the weighted average of the elements of `X`,
    	with relative weights equal to their inverse variances (`1/sX**2`).

    	**Parameters**

    	+ `X`: array-like of elements to average
    	+ `sX`: array-like of the corresponding SE values

    	**Tip**

    	If `X` and `sX` are initially arranged as a list of `(x, sx)` doublets,
    	they may be rearranged using `zip()`:

    	```python
    	foo = [(0, 1), (1, 0.5), (2, 0.5)]
    	print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)
    	```
    	'''
    	X = [ x for x in X ]
    	sX = [ sx for sx in sX ]
    	W = [ sx**-2 for sx in sX ]
    	W = [ w/sum(W) for w in W ]
    	Xavg = sum([ w*x for w,x in zip(W,X) ])
    	sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5
    	return Xavg, sXavg

    def read_csv(filename, sep = ''):
    	'''
    	Read contents of `filename` in csv format and return a list of dictionaries.

    	In the csv string, spaces before and after field separators (`','` by default)
    	are optional.

    	**Parameters**

    	+ `filename`: the csv file to read
    	+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    	whichever appers most often in the contents of `filename`.
    	'''
    	with open(filename) as fid:
    		txt = fid.read()

    	if sep == '':
    		sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    	txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    	return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]]

    def simulate_single_analysis(
    	sample = 'MYSAMPLE',
    	d13Cwg_VPDB = -4., d18Owg_VSMOW = 26.,
    	d13C_VPDB = None, d18O_VPDB = None,
    	D47 = None, D48 = None, D49 = 0., D17O = 0.,
    	a47 = 1., b47 = 0., c47 = -0.9,
    	a48 = 1., b48 = 0., c48 = -0.45,
    	Nominal_D47 = None,
    	Nominal_D48 = None,
    	Nominal_d13C_VPDB = None,
    	Nominal_d18O_VPDB = None,
    	ALPHA_18O_ACID_REACTION = None,
    	R13_VPDB = None,
    	R17_VSMOW = None,
    	R18_VSMOW = None,
    	LAMBDA_17 = None,
    	R18_VPDB = None,
    	):
    	'''
    	Compute working-gas delta values for a single analysis, assuming a stochastic working
    	gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values).

    	**Parameters**

    	+ `sample`: sample name
    	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
    		(respectively –4 and +26 ‰ by default)
    	+ `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
    	+ `D47`, `D48`, `D49`, `D17O`: clumped-isotope and oxygen-17 anomalies
    		of the carbonate sample
    	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and
    		Δ48 values if `D47` or `D48` are not specified
    	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
    		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
    	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
    	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
    		correction parameters (by default equal to the `D4xdata` default values)

    	Returns a dictionary with fields
    	`['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49']`.
    	'''

    	if Nominal_d13C_VPDB is None:
    		Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB

    	if Nominal_d18O_VPDB is None:
    		Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB

    	if ALPHA_18O_ACID_REACTION is None:
    		ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION

    	if R13_VPDB is None:
    		R13_VPDB = D4xdata().R13_VPDB

    	if R17_VSMOW is None:
    		R17_VSMOW = D4xdata().R17_VSMOW

    	if R18_VSMOW is None:
    		R18_VSMOW = D4xdata().R18_VSMOW

    	if LAMBDA_17 is None:
    		LAMBDA_17 = D4xdata().LAMBDA_17

    	if R18_VPDB is None:
    		R18_VPDB = D4xdata().R18_VPDB

    	R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17

    	if Nominal_D47 is None:
    		Nominal_D47 = D47data().Nominal_D47

    	if Nominal_D48 is None:
    		Nominal_D48 = D48data().Nominal_D48

    	if d13C_VPDB is None:
    		if sample in Nominal_d13C_VPDB:
    			d13C_VPDB = Nominal_d13C_VPDB[sample]
    		else:
    			raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.")

    	if d18O_VPDB is None:
    		if sample in Nominal_d18O_VPDB:
    			d18O_VPDB = Nominal_d18O_VPDB[sample]
    		else:
    			raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.")

    	if D47 is None:
    		if sample in Nominal_D47:
    			D47 = Nominal_D47[sample]
    		else:
    			raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.")

    	if D48 is None:
    		if sample in Nominal_D48:
    			D48 = Nominal_D48[sample]
    		else:
    			raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.")

    	X = D4xdata()
    	X.R13_VPDB = R13_VPDB
    	X.R17_VSMOW = R17_VSMOW
    	X.R18_VSMOW = R18_VSMOW
    	X.LAMBDA_17 = LAMBDA_17
    	X.R18_VPDB = R18_VPDB
    	X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17

    	R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios(
    		R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000),
    		R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000),
    		)
    	R45, R46, R47, R48, R49 = X.compute_isobar_ratios(
    		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
    		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
    		D17O=D17O, D47=D47, D48=D48, D49=D49,
    		)
    	R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios(
    		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
    		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
    		D17O=D17O,
    		)

    	d45 = 1000 * (R45/R45wg - 1)
    	d46 = 1000 * (R46/R46wg - 1)
    	d47 = 1000 * (R47/R47wg - 1)
    	d48 = 1000 * (R48/R48wg - 1)
    	d49 = 1000 * (R49/R49wg - 1)

    	for k in range(3): # dumb iteration to adjust for small changes in d47
    		R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch
    		R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch
    		d47 = 1000 * (R47raw/R47wg - 1)
    		d48 = 1000 * (R48raw/R48wg - 1)

    	return dict(
    		Sample = sample,
    		D17O = D17O,
    		d13Cwg_VPDB = d13Cwg_VPDB,
    		d18Owg_VSMOW = d18Owg_VSMOW,
    		d45 = d45,
    		d46 = d46,
    		d47 = d47,
    		d48 = d48,
    		d49 = d49,
    		)

    def virtual_data(
    	samples = [],
    	a47 = 1., b47 = 0., c47 = -0.9,
    	a48 = 1., b48 = 0., c48 = -0.45,
    	rD47 = 0.015, rD48 = 0.045,
    	d13Cwg_VPDB = None, d18Owg_VSMOW = None,
    	session = None,
    	Nominal_D47 = None, Nominal_D48 = None,
    	Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
    	ALPHA_18O_ACID_REACTION = None,
    	R13_VPDB = None,
    	R17_VSMOW = None,
    	R18_VSMOW = None,
    	LAMBDA_17 = None,
    	R18_VPDB = None,
    	seed = 0,
    	):
    	'''
    	Return list with simulated analyses from a single session.

    	**Parameters**

    	+ `samples`: a list of entries; each entry is a dictionary with the following fields:
    	    * `Sample`: the name of the sample
    	    * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
    	    * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
    	    * `N`: how many analyses to generate for this sample
    	+ `a47`: scrambling factor for Δ47
    	+ `b47`: compositional nonlinearity for Δ47
    	+ `c47`: working gas offset for Δ47
    	+ `a48`: scrambling factor for Δ48
    	+ `b48`: compositional nonlinearity for Δ48
    	+ `c48`: working gas offset for Δ48
    	+ `rD47`: analytical repeatability of Δ47
    	+ `rD48`: analytical repeatability of Δ48
    	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
    		(by default equal to the `simulate_single_analysis` default values)
    	+ `session`: name of the session (no name by default)
    	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
    		if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
    	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
    		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
    		(by default equal to the `simulate_single_analysis` defaults)
    	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
    		(by default equal to the `simulate_single_analysis` defaults)
    	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
    		correction parameters (by default equal to the `simulate_single_analysis` default)
    	+ `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations

    	Here is an example of using this method to generate an arbitrary combination of
    	anchors and unknowns for a bunch of sessions:

    	```py
    	args = dict(
    		samples = [
    			dict(Sample = 'ETH-1', N = 4),
    			dict(Sample = 'ETH-2', N = 5),
    			dict(Sample = 'ETH-3', N = 6),
    			dict(Sample = 'FOO', N = 2,
    				d13C_VPDB = -5., d18O_VPDB = -10.,
    				D47 = 0.3, D48 = 0.15),
    			], rD47 = 0.010, rD48 = 0.030)

    	session1 = virtual_data(session = 'Session_01', **args, seed = 123)
    	session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
    	session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
    	session4 = virtual_data(session = 'Session_04', **args, seed = 123456)

    	D = D47data(session1 + session2 + session3 + session4)

    	D.crunch()
    	D.standardize()

    	D.table_of_sessions(verbose = True, save_to_file = False)
    	D.table_of_samples(verbose = True, save_to_file = False)
    	D.table_of_analyses(verbose = True, save_to_file = False)
    	```

    	This should output something like:

    	```
    	[table_of_sessions]
    	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    	Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
    	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    	Session_01  15   2       -4.000        26.000  0.0000  0.0000  0.0110  0.997 ± 0.017  -0.097 ± 0.244  -0.896 ± 0.006
    	Session_02  15   2       -4.000        26.000  0.0000  0.0000  0.0109  1.002 ± 0.017  -0.110 ± 0.244  -0.901 ± 0.006
    	Session_03  15   2       -4.000        26.000  0.0000  0.0000  0.0107  1.010 ± 0.017  -0.037 ± 0.244  -0.904 ± 0.006
    	Session_04  15   2       -4.000        26.000  0.0000  0.0000  0.0106  1.001 ± 0.017  -0.181 ± 0.244  -0.894 ± 0.006
    	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––

    	[table_of_samples]
    	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    	Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
    	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    	ETH-1   16       2.02       37.02  0.2052                    0.0079
    	ETH-2   20     -10.17       19.88  0.2085                    0.0100
    	ETH-3   24       1.71       37.45  0.6132                    0.0105
    	FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
    	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––

    	[table_of_analyses]
    	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    	UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
    	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    	1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.122986   21.273526   27.780042    2.020000   37.024281  -0.706013  -0.328878  -0.000013  0.192554
    	2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.130144   21.282615   27.780042    2.020000   37.024281  -0.698974  -0.319981  -0.000013  0.199615
    	3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.149219   21.299572   27.780042    2.020000   37.024281  -0.680215  -0.303383  -0.000013  0.218429
    	4    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.136616   21.233128   27.780042    2.020000   37.024281  -0.692609  -0.368421  -0.000013  0.205998
    	5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.697171  -12.203054  -18.023381  -10.170000   19.875825  -0.680771  -0.290128  -0.000002  0.215054
    	6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701124  -12.184422  -18.023381  -10.170000   19.875825  -0.684772  -0.271272  -0.000002  0.211041
    	7    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715105  -12.195251  -18.023381  -10.170000   19.875825  -0.698923  -0.282232  -0.000002  0.196848
    	8    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701529  -12.204963  -18.023381  -10.170000   19.875825  -0.685182  -0.292061  -0.000002  0.210630
    	9    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.711420  -12.228478  -18.023381  -10.170000   19.875825  -0.695193  -0.315859  -0.000002  0.200589
    	10   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.666719   22.296486   28.306614    1.710000   37.450394  -0.290459  -0.147284  -0.000014  0.609363
    	11   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.671553   22.291060   28.306614    1.710000   37.450394  -0.285706  -0.152592  -0.000014  0.614130
    	12   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.652854   22.273271   28.306614    1.710000   37.450394  -0.304093  -0.169990  -0.000014  0.595689
    	13   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684168   22.263156   28.306614    1.710000   37.450394  -0.273302  -0.179883  -0.000014  0.626572
    	14   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.662702   22.253578   28.306614    1.710000   37.450394  -0.294409  -0.189251  -0.000014  0.605401
    	15   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.681957   22.230907   28.306614    1.710000   37.450394  -0.275476  -0.211424  -0.000014  0.624391
    	16   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.312044    5.395798    4.665655   -5.000000   28.907344  -0.598436  -0.268176  -0.000006  0.298996
    	17   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328123    5.307086    4.665655   -5.000000   28.907344  -0.582387  -0.356389  -0.000006  0.315092
    	18   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.122201   21.340606   27.780042    2.020000   37.024281  -0.706785  -0.263217  -0.000013  0.195135
    	19   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.134868   21.305714   27.780042    2.020000   37.024281  -0.694328  -0.297370  -0.000013  0.207564
    	20   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140008   21.261931   27.780042    2.020000   37.024281  -0.689273  -0.340227  -0.000013  0.212607
    	21   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.135540   21.298472   27.780042    2.020000   37.024281  -0.693667  -0.304459  -0.000013  0.208224
    	22   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701213  -12.202602  -18.023381  -10.170000   19.875825  -0.684862  -0.289671  -0.000002  0.213842
    	23   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.685649  -12.190405  -18.023381  -10.170000   19.875825  -0.669108  -0.277327  -0.000002  0.229559
    	24   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.719003  -12.257955  -18.023381  -10.170000   19.875825  -0.702869  -0.345692  -0.000002  0.195876
    	25   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700592  -12.204641  -18.023381  -10.170000   19.875825  -0.684233  -0.291735  -0.000002  0.214469
    	26   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720426  -12.214561  -18.023381  -10.170000   19.875825  -0.704308  -0.301774  -0.000002  0.194439
    	27   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.673044   22.262090   28.306614    1.710000   37.450394  -0.284240  -0.180926  -0.000014  0.616730
    	28   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.666542   22.263401   28.306614    1.710000   37.450394  -0.290634  -0.179643  -0.000014  0.610350
    	29   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.680487   22.243486   28.306614    1.710000   37.450394  -0.276921  -0.199121  -0.000014  0.624031
    	30   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.663900   22.245175   28.306614    1.710000   37.450394  -0.293231  -0.197469  -0.000014  0.607759
    	31   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.674379   22.301309   28.306614    1.710000   37.450394  -0.282927  -0.142568  -0.000014  0.618039
    	32   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.660825   22.270466   28.306614    1.710000   37.450394  -0.296255  -0.172733  -0.000014  0.604742
    	33   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.294076    5.349940    4.665655   -5.000000   28.907344  -0.616369  -0.313776  -0.000006  0.283707
    	34   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.313775    5.292121    4.665655   -5.000000   28.907344  -0.596708  -0.371269  -0.000006  0.303323
    	35   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.121613   21.259909   27.780042    2.020000   37.024281  -0.707364  -0.342207  -0.000013  0.194934
    	36   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.145714   21.304889   27.780042    2.020000   37.024281  -0.683661  -0.298178  -0.000013  0.218401
    	37   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.126573   21.325093   27.780042    2.020000   37.024281  -0.702485  -0.278401  -0.000013  0.199764
    	38   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.132057   21.323211   27.780042    2.020000   37.024281  -0.697092  -0.280244  -0.000013  0.205104
    	39   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.708448  -12.232023  -18.023381  -10.170000   19.875825  -0.692185  -0.319447  -0.000002  0.208915
    	40   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.714417  -12.202504  -18.023381  -10.170000   19.875825  -0.698226  -0.289572  -0.000002  0.202934
    	41   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720039  -12.264469  -18.023381  -10.170000   19.875825  -0.703917  -0.352285  -0.000002  0.197300
    	42   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701953  -12.228550  -18.023381  -10.170000   19.875825  -0.685611  -0.315932  -0.000002  0.215423
    	43   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.704535  -12.213634  -18.023381  -10.170000   19.875825  -0.688224  -0.300836  -0.000002  0.212837
    	44   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.652920   22.230043   28.306614    1.710000   37.450394  -0.304028  -0.212269  -0.000014  0.594265
    	45   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.691485   22.261017   28.306614    1.710000   37.450394  -0.266106  -0.181975  -0.000014  0.631810
    	46   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.679119   22.305357   28.306614    1.710000   37.450394  -0.278266  -0.138609  -0.000014  0.619771
    	47   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.663623   22.327286   28.306614    1.710000   37.450394  -0.293503  -0.117161  -0.000014  0.604685
    	48   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.678524   22.282103   28.306614    1.710000   37.450394  -0.278851  -0.161352  -0.000014  0.619192
    	49   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.666246   22.283361   28.306614    1.710000   37.450394  -0.290925  -0.160121  -0.000014  0.607238
    	50   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.309929    5.340249    4.665655   -5.000000   28.907344  -0.600546  -0.323413  -0.000006  0.300148
    	51   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.317548    5.334102    4.665655   -5.000000   28.907344  -0.592942  -0.329524  -0.000006  0.307676
    	52   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.136865   21.300298   27.780042    2.020000   37.024281  -0.692364  -0.302672  -0.000013  0.204033
    	53   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.133538   21.291260   27.780042    2.020000   37.024281  -0.695637  -0.311519  -0.000013  0.200762
    	54   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.139991   21.319865   27.780042    2.020000   37.024281  -0.689290  -0.283519  -0.000013  0.207107
    	55   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.145748   21.330075   27.780042    2.020000   37.024281  -0.683629  -0.273524  -0.000013  0.212766
    	56   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702989  -12.202762  -18.023381  -10.170000   19.875825  -0.686660  -0.289833  -0.000002  0.204507
    	57   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.692830  -12.240287  -18.023381  -10.170000   19.875825  -0.676377  -0.327811  -0.000002  0.214786
    	58   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702899  -12.180291  -18.023381  -10.170000   19.875825  -0.686568  -0.267091  -0.000002  0.204598
    	59   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709282  -12.282257  -18.023381  -10.170000   19.875825  -0.693029  -0.370287  -0.000002  0.198140
    	60   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.679330  -12.235994  -18.023381  -10.170000   19.875825  -0.662712  -0.323466  -0.000002  0.228446
    	61   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.695594   22.238663   28.306614    1.710000   37.450394  -0.262066  -0.203838  -0.000014  0.634200
    	62   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.663504   22.286354   28.306614    1.710000   37.450394  -0.293620  -0.157194  -0.000014  0.602656
    	63   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666457   22.254290   28.306614    1.710000   37.450394  -0.290717  -0.188555  -0.000014  0.605558
    	64   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666910   22.223232   28.306614    1.710000   37.450394  -0.290271  -0.218930  -0.000014  0.606004
    	65   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.679662   22.257256   28.306614    1.710000   37.450394  -0.277732  -0.185653  -0.000014  0.618539
    	66   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.676768   22.267680   28.306614    1.710000   37.450394  -0.280578  -0.175459  -0.000014  0.615693
    	67   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.307663    5.317330    4.665655   -5.000000   28.907344  -0.602808  -0.346202  -0.000006  0.290853
    	68   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.308562    5.331400    4.665655   -5.000000   28.907344  -0.601911  -0.332212  -0.000006  0.291749
    	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    	```
    	'''

    	kwargs = locals().copy()

    	from numpy import random as nprandom
    	if seed:
    		rng = nprandom.default_rng(seed)
    	else:
    		rng = nprandom.default_rng()

    	N = sum([s['N'] for s in samples])
    	errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    	errors47 *= rD47 / stdev(errors47) # scale errors to rD47
    	errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    	errors48 *= rD48 / stdev(errors48) # scale errors to rD48

    	k = 0
    	out = []
    	for s in samples:
    		kw = {}
    		kw['sample'] = s['Sample']
    		kw = {
    			**kw,
    			**{var: kwargs[var]
    				for var in [
    					'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
    					'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
    					'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
    					'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
    					]
    				if kwargs[var] is not None},
    			**{var: s[var]
    				for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
    				if var in s},
    			}

    		sN = s['N']
    		while sN:
    			out.append(simulate_single_analysis(**kw))
    			out[-1]['d47'] += errors47[k] * a47
    			out[-1]['d48'] += errors48[k] * a48
    			sN -= 1
    			k += 1

    		if session is not None:
    			for r in out:
    				r['Session'] = session
    	return out

    def table_of_samples(
    	data47 = None,
    	data48 = None,
    	dir = 'output',
    	filename = None,
    	save_to_file = True,
    	print_out = True,
    	output = None,
    	):
    	'''
    	Print out, save to disk and/or return a combined table of samples
    	for a pair of `D47data` and `D48data` objects.

    	**Parameters**

    	+ `data47`: `D47data` instance
    	+ `data48`: `D48data` instance
    	+ `dir`: the directory in which to save the table
    	+ `filename`: the name to the csv file to write to
    	+ `save_to_file`: whether to save the table to disk
    	+ `print_out`: whether to print out the table
    	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		if set to `'raw'`: return a list of list of strings
    		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    	'''
    	if data47 is None:
    		if data48 is None:
    			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
    		else:
    			return data48.table_of_samples(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    	else:
    		if data48 is None:
    			return data47.table_of_samples(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    		else:
    			out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
    			out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
    			out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:])

    			if save_to_file:
    				if not os.path.exists(dir):
    					os.makedirs(dir)
    				if filename is None:
    					filename = f'D47D48_samples.csv'
    				with open(f'{dir}/{filename}', 'w') as fid:
    					fid.write(make_csv(out))
    			if print_out:
    				print('\n'+pretty_table(out))
    			if output == 'raw':
    				return out
    			elif output == 'pretty':
    				return pretty_table(out)

    def table_of_sessions(
    	data47 = None,
    	data48 = None,
    	dir = 'output',
    	filename = None,
    	save_to_file = True,
    	print_out = True,
    	output = None,
    	):
    	'''
    	Print out, save to disk and/or return a combined table of sessions
    	for a pair of `D47data` and `D48data` objects.
    	***Only applicable if the sessions in `data47` and those in `data48`
    	consist of the exact same sets of analyses.***

    	**Parameters**

    	+ `data47`: `D47data` instance
    	+ `data48`: `D48data` instance
    	+ `dir`: the directory in which to save the table
    	+ `filename`: the name to the csv file to write to
    	+ `save_to_file`: whether to save the table to disk
    	+ `print_out`: whether to print out the table
    	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		if set to `'raw'`: return a list of list of strings
    		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    	'''
    	if data47 is None:
    		if data48 is None:
    			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
    		else:
    			return data48.table_of_sessions(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    	else:
    		if data48 is None:
    			return data47.table_of_sessions(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    		else:
    			out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
    			out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
    			for k,x in enumerate(out47[0]):
    				if k>7:
    					out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47')
    					out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48')
    			out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:])

    			if save_to_file:
    				if not os.path.exists(dir):
    					os.makedirs(dir)
    				if filename is None:
    					filename = f'D47D48_sessions.csv'
    				with open(f'{dir}/{filename}', 'w') as fid:
    					fid.write(make_csv(out))
    			if print_out:
    				print('\n'+pretty_table(out))
    			if output == 'raw':
    				return out
    			elif output == 'pretty':
    				return pretty_table(out)

    def table_of_analyses(
    	data47 = None,
    	data48 = None,
    	dir = 'output',
    	filename = None,
    	save_to_file = True,
    	print_out = True,
    	output = None,
    	):
    	'''
    	Print out, save to disk and/or return a combined table of analyses
    	for a pair of `D47data` and `D48data` objects.

    	If the sessions in `data47` and those in `data48` do not consist of
    	the exact same sets of analyses, the table will have two columns
    	`Session_47` and `Session_48` instead of a single `Session` column.

    	**Parameters**

    	+ `data47`: `D47data` instance
    	+ `data48`: `D48data` instance
    	+ `dir`: the directory in which to save the table
    	+ `filename`: the name to the csv file to write to
    	+ `save_to_file`: whether to save the table to disk
    	+ `print_out`: whether to print out the table
    	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		if set to `'raw'`: return a list of list of strings
    		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    	'''
    	if data47 is None:
    		if data48 is None:
    			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
    		else:
    			return data48.table_of_analyses(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    	else:
    		if data48 is None:
    			return data47.table_of_analyses(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    		else:
    			out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
    			out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')

    			if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical
    				out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:])
    			else:
    				out47[0][1] = 'Session_47'
    				out48[0][1] = 'Session_48'
    				out47 = transpose_table(out47)
    				out48 = transpose_table(out48)
    				out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:])

    			if save_to_file:
    				if not os.path.exists(dir):
    					os.makedirs(dir)
    				if filename is None:
    					filename = f'D47D48_sessions.csv'
    				with open(f'{dir}/{filename}', 'w') as fid:
    					fid.write(make_csv(out))
    			if print_out:
    				print('\n'+pretty_table(out))
    			if output == 'raw':
    				return out
    			elif output == 'pretty':
    				return pretty_table(out)

    class D4xdata(list):
    	'''
    	Store and process data for a large set of Δ47 and/or Δ48
    	analyses, usually comprising more than one analytical session.
    	'''

    	### 17O CORRECTION PARAMETERS
    	R13_VPDB = 0.01118  # (Chang & Li, 1990)
    	'''
    	Absolute (13C/12C) ratio of VPDB.
    	By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm))
    	'''

    	R18_VSMOW = 0.0020052  # (Baertschi, 1976)
    	'''
    	Absolute (18O/16C) ratio of VSMOW.
    	By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1))
    	'''

    	LAMBDA_17 = 0.528  # (Barkan & Luz, 2005)
    	'''
    	Mass-dependent exponent for triple oxygen isotopes.
    	By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250))
    	'''

    	R17_VSMOW = 0.00038475  # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB)
    	'''
    	Absolute (17O/16C) ratio of VSMOW.
    	By default equal to 0.00038475
    	([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011),
    	rescaled to `R13_VPDB`)
    	'''

    	R18_VPDB = R18_VSMOW * 1.03092
    	'''
    	Absolute (18O/16C) ratio of VPDB.
    	By definition equal to `R18_VSMOW * 1.03092`.
    	'''

    	R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17
    	'''
    	Absolute (17O/16C) ratio of VPDB.
    	By definition equal to `R17_VSMOW * 1.03092 ** LAMBDA_17`.
    	'''

    	LEVENE_REF_SAMPLE = 'ETH-3'
    	'''
    	After the Δ4x standardization step, each sample is tested to
    	assess whether the Δ4x variance within all analyses for that
    	sample differs significantly from that observed for a given reference
    	sample (using [Levene's test](https://en.wikipedia.org/wiki/Levene%27s_test),
    	which yields a p-value corresponding to the null hypothesis that the
    	underlying variances are equal).

    	`LEVENE_REF_SAMPLE` (by default equal to `'ETH-3'`) specifies which
    	sample should be used as a reference for this test.
    	'''

    	ALPHA_18O_ACID_REACTION = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6)  # (Kim et al., 2007, calcite)
    	'''
    	Specifies the 18O/16O fractionation factor generally applicable
    	to acid reactions in the dataset. Currently used by `D4xdata.wg()`,
    	`D4xdata.standardize_d13C`, and `D4xdata.standardize_d18O`.

    	By default equal to 1.008129 (calcite reacted at 90 °C,
    	[Kim et al., 2007](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)).
    	'''

    	Nominal_d13C_VPDB = {
    		'ETH-1': 2.02,
    		'ETH-2': -10.17,
    		'ETH-3': 1.71,
    		}	# (Bernasconi et al., 2018)
    	'''
    	Nominal δ13C_VPDB values assigned to carbonate standards, used by
    	`D4xdata.standardize_d13C()`.

    	By default equal to `{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}` after
    	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
    	'''

    	Nominal_d18O_VPDB = {
    		'ETH-1': -2.19,
    		'ETH-2': -18.69,
    		'ETH-3': -1.78,
    		}	# (Bernasconi et al., 2018)
    	'''
    	Nominal δ18O_VPDB values assigned to carbonate standards, used by
    	`D4xdata.standardize_d18O()`.

    	By default equal to `{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}` after
    	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
    	'''

    	d13C_STANDARDIZATION_METHOD = '2pt'
    	'''
    	Method by which to standardize δ13C values:

    	+ `none`: do not apply any δ13C standardization.
    	+ `'1pt'`: within each session, offset all initial δ13C values so as to
    	minimize the difference between final δ13C_VPDB values and
    	`Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined).
    	+ `'2pt'`: within each session, apply a affine trasformation to all δ13C
    	values so as to minimize the difference between final δ13C_VPDB
    	values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB`
    	is defined).
    	'''

    	d18O_STANDARDIZATION_METHOD = '2pt'
    	'''
    	Method by which to standardize δ18O values:

    	+ `none`: do not apply any δ18O standardization.
    	+ `'1pt'`: within each session, offset all initial δ18O values so as to
    	minimize the difference between final δ18O_VPDB values and
    	`Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined).
    	+ `'2pt'`: within each session, apply a affine trasformation to all δ18O
    	values so as to minimize the difference between final δ18O_VPDB
    	values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB`
    	is defined).
    	'''

    	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
    		'''
    		**Parameters**

    		+ `l`: a list of dictionaries, with each dictionary including at least the keys
    		`Sample`, `d45`, `d46`, and `d47` or `d48`.
    		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
    		+ `session`: define session name for analyses without a `Session` key
    		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.

    		Returns a `D4xdata` object derived from `list`.
    		'''
    		self._4x = mass
    		self.verbose = verbose
    		self.prefix = 'D4xdata'
    		self.logfile = logfile
    		list.__init__(self, l)
    		self.Nf = None
    		self.repeatability = {}
    		self.refresh(session = session)

    	def make_verbal(oldfun):
    		'''
    		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
    		'''
    		@wraps(oldfun)
    		def newfun(*args, verbose = '', **kwargs):
    			myself = args[0]
    			oldprefix = myself.prefix
    			myself.prefix = oldfun.__name__
    			if verbose != '':
    				oldverbose = myself.verbose
    				myself.verbose = verbose
    			out = oldfun(*args, **kwargs)
    			myself.prefix = oldprefix
    			if verbose != '':
    				myself.verbose = oldverbose
    			return out
    		return newfun

    	def msg(self, txt):
    		'''
    		Log a message to `self.logfile`, and print it out if `verbose = True`
    		'''
    		self.log(txt)
    		if self.verbose:
    			print(f'{f"[{self.prefix}]":<16} {txt}')

    	def vmsg(self, txt):
    		'''
    		Log a message to `self.logfile` and print it out
    		'''
    		self.log(txt)
    		print(txt)

    	def log(self, *txts):
    		'''
    		Log a message to `self.logfile`
    		'''
    		if self.logfile:
    			with open(self.logfile, 'a') as fid:
    				for txt in txts:
    					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')

    	def refresh(self, session = 'mySession'):
    		'''
    		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    		'''
    		self.fill_in_missing_info(session = session)
    		self.refresh_sessions()
    		self.refresh_samples()

    	def refresh_sessions(self):
    		'''
    		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    		to `False` for all sessions.
    		'''
    		self.sessions = {
    			s: {'data': [r for r in self if r['Session'] == s]}
    			for s in sorted({r['Session'] for r in self})
    			}
    		for s in self.sessions:
    			self.sessions[s]['scrambling_drift'] = False
    			self.sessions[s]['slope_drift'] = False
    			self.sessions[s]['wg_drift'] = False
    			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD

    	def refresh_samples(self):
    		'''
    		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    		'''
    		self.samples = {
    			s: {'data': [r for r in self if r['Sample'] == s]}
    			for s in sorted({r['Sample'] for r in self})
    			}
    		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}

    	def read(self, filename, sep = '', session = ''):
    		'''
    		Read file in csv format to load data into a `D47data` object.

    		In the csv file, spaces before and after field separators (`','` by default)
    		are optional. Each line corresponds to a single analysis.

    		The required fields are:

    		+ `UID`: a unique identifier
    		+ `Session`: an identifier for the analytical session
    		+ `Sample`: a sample identifier
    		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values

    		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    		and `d49` are optional, and set to NaN by default.

    		**Parameters**

    		+ `fileneme`: the path of the file to read
    		+ `sep`: csv separator delimiting the fields
    		+ `session`: set `Session` field to this string for all analyses
    		'''
    		with open(filename) as fid:
    			self.input(fid.read(), sep = sep, session = session)

    	def input(self, txt, sep = '', session = ''):
    		'''
    		Read `txt` string in csv format to load analysis data into a `D47data` object.

    		In the csv string, spaces before and after field separators (`','` by default)
    		are optional. Each line corresponds to a single analysis.

    		The required fields are:

    		+ `UID`: a unique identifier
    		+ `Session`: an identifier for the analytical session
    		+ `Sample`: a sample identifier
    		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values

    		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    		and `d49` are optional, and set to NaN by default.

    		**Parameters**

    		+ `txt`: the csv string to read
    		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    		whichever appers most often in `txt`.
    		+ `session`: set `Session` field to this string for all analyses
    		'''
    		if sep == '':
    			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    		data = [{k: v if k in ['UID', 'Session', 'Sample'] else smart_type(v) for k,v in zip(txt[0], l) if v != ''} for l in txt[1:]]

    		if session != '':
    			for r in data:
    				r['Session'] = session

    		self += data
    		self.refresh()

    	@make_verbal
    	def wg(self, samples = None, a18_acid = None):
    		'''
    		Compute bulk composition of the working gas for each session based on
    		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
    		`self.Nominal_d18O_VPDB`.
    		'''

    		self.msg('Computing WG composition:')

    		if a18_acid is None:
    			a18_acid = self.ALPHA_18O_ACID_REACTION
    		if samples is None:
    			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]

    		assert a18_acid, f'Acid fractionation factor should not be zero.'

    		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
    		R45R46_standards = {}
    		for sample in samples:
    			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
    			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
    			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
    			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
    			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid

    			C12_s = 1 / (1 + R13_s)
    			C13_s = R13_s / (1 + R13_s)
    			C16_s = 1 / (1 + R17_s + R18_s)
    			C17_s = R17_s / (1 + R17_s + R18_s)
    			C18_s = R18_s / (1 + R17_s + R18_s)

    			C626_s = C12_s * C16_s ** 2
    			C627_s = 2 * C12_s * C16_s * C17_s
    			C628_s = 2 * C12_s * C16_s * C18_s
    			C636_s = C13_s * C16_s ** 2
    			C637_s = 2 * C13_s * C16_s * C17_s
    			C727_s = C12_s * C17_s ** 2

    			R45_s = (C627_s + C636_s) / C626_s
    			R46_s = (C628_s + C637_s + C727_s) / C626_s
    			R45R46_standards[sample] = (R45_s, R46_s)

    		for s in self.sessions:
    			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
    			assert db, f'No sample from {samples} found in session "{s}".'
    # 			dbsamples = sorted({r['Sample'] for r in db})

    			X = [r['d45'] for r in db]
    			Y = [R45R46_standards[r['Sample']][0] for r in db]
    			x1, x2 = np.min(X), np.max(X)

    			if x1 < x2:
    				wgcoord = x1/(x1-x2)
    			else:
    				wgcoord = 999

    			if wgcoord < -.5 or wgcoord > 1.5:
    				# unreasonable to extrapolate to d45 = 0
    				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    			else :
    				# d45 = 0 is reasonably well bracketed
    				R45_wg = np.polyfit(X, Y, 1)[1]

    			X = [r['d46'] for r in db]
    			Y = [R45R46_standards[r['Sample']][1] for r in db]
    			x1, x2 = np.min(X), np.max(X)

    			if x1 < x2:
    				wgcoord = x1/(x1-x2)
    			else:
    				wgcoord = 999

    			if wgcoord < -.5 or wgcoord > 1.5:
    				# unreasonable to extrapolate to d46 = 0
    				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    			else :
    				# d46 = 0 is reasonably well bracketed
    				R46_wg = np.polyfit(X, Y, 1)[1]

    			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)

    			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')

    			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
    			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
    			for r in self.sessions[s]['data']:
    				r['d13Cwg_VPDB'] = d13Cwg_VPDB
    				r['d18Owg_VSMOW'] = d18Owg_VSMOW

    	def compute_bulk_delta(self, R45, R46, D17O = 0):
    		'''
    		Compute δ13C_VPDB and δ18O_VSMOW,
    		by solving the generalized form of equation (17) from
    		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
    		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
    		solving the corresponding second-order Taylor polynomial.
    		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
    		'''

    		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17

    		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
    		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
    		C = 2 * self.R18_VSMOW
    		D = -R46

    		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
    		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
    		cc = A + B + C + D

    		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)

    		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
    		R17 = K * R18 ** self.LAMBDA_17
    		R13 = R45 - 2 * R17

    		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)

    		return d13C_VPDB, d18O_VSMOW

    	@make_verbal
    	def crunch(self, verbose = ''):
    		'''
    		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    		'''
    		for r in self:
    			self.compute_bulk_and_clumping_deltas(r)
    		self.standardize_d13C()
    		self.standardize_d18O()
    		self.msg(f"Crunched {len(self)} analyses.")

    	def fill_in_missing_info(self, session = 'mySession'):
    		'''
    		Fill in optional fields with default values
    		'''
    		for i,r in enumerate(self):
    			if 'D17O' not in r:
    				r['D17O'] = 0.
    			if 'UID' not in r:
    				r['UID'] = f'{i+1}'
    			if 'Session' not in r:
    				r['Session'] = session
    			for k in ['d47', 'd48', 'd49']:
    				if k not in r:
    					r[k] = np.nan

    	def standardize_d13C(self):
    		'''
    		Perform δ13C standadization within each session `s` according to
    		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
    		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
    		may be redefined abitrarily at a later stage.
    		'''
    		for s in self.sessions:
    			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
    				XY = [(r['d13C_VPDB'], self.Nominal_d13C_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d13C_VPDB]
    				X,Y = zip(*XY)
    				if self.sessions[s]['d13C_standardization_method'] == '1pt':
    					offset = np.mean(Y) - np.mean(X)
    					for r in self.sessions[s]['data']:
    						r['d13C_VPDB'] += offset
    				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
    					a,b = np.polyfit(X,Y,1)
    					for r in self.sessions[s]['data']:
    						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b

    	def standardize_d18O(self):
    		'''
    		Perform δ18O standadization within each session `s` according to
    		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
    		which is defined by default by `D47data.refresh_sessions()`as equal to
    		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
    		'''
    		for s in self.sessions:
    			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
    				XY = [(r['d18O_VSMOW'], self.Nominal_d18O_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d18O_VPDB]
    				X,Y = zip(*XY)
    				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
    				if self.sessions[s]['d18O_standardization_method'] == '1pt':
    					offset = np.mean(Y) - np.mean(X)
    					for r in self.sessions[s]['data']:
    						r['d18O_VSMOW'] += offset
    				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
    					a,b = np.polyfit(X,Y,1)
    					for r in self.sessions[s]['data']:
    						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b

    	def compute_bulk_and_clumping_deltas(self, r):
    		'''
    		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
    		'''

    		# Compute working gas R13, R18, and isobar ratios
    		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
    		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
    		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)

    		# Compute analyte isobar ratios
    		R45 = (1 + r['d45'] / 1000) * R45_wg
    		R46 = (1 + r['d46'] / 1000) * R46_wg
    		R47 = (1 + r['d47'] / 1000) * R47_wg
    		R48 = (1 + r['d48'] / 1000) * R48_wg
    		R49 = (1 + r['d49'] / 1000) * R49_wg

    		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
    		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
    		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW

    		# Compute stochastic isobar ratios of the analyte
    		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
    			R13, R18, D17O = r['D17O']
    		)

    		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
    		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
    		if (R45 / R45stoch - 1) > 5e-8:
    			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
    		if (R46 / R46stoch - 1) > 5e-8:
    			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')

    		# Compute raw clumped isotope anomalies
    		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
    		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
    		r['D49raw'] = 1000 * (R49 / R49stoch - 1)

    	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
    		'''
    		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
    		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
    		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
    		'''

    		# Compute R17
    		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17

    		# Compute isotope concentrations
    		C12 = (1 + R13) ** -1
    		C13 = C12 * R13
    		C16 = (1 + R17 + R18) ** -1
    		C17 = C16 * R17
    		C18 = C16 * R18

    		# Compute stochastic isotopologue concentrations
    		C626 = C16 * C12 * C16
    		C627 = C16 * C12 * C17 * 2
    		C628 = C16 * C12 * C18 * 2
    		C636 = C16 * C13 * C16
    		C637 = C16 * C13 * C17 * 2
    		C638 = C16 * C13 * C18 * 2
    		C727 = C17 * C12 * C17
    		C728 = C17 * C12 * C18 * 2
    		C737 = C17 * C13 * C17
    		C738 = C17 * C13 * C18 * 2
    		C828 = C18 * C12 * C18
    		C838 = C18 * C13 * C18

    		# Compute stochastic isobar ratios
    		R45 = (C636 + C627) / C626
    		R46 = (C628 + C637 + C727) / C626
    		R47 = (C638 + C728 + C737) / C626
    		R48 = (C738 + C828) / C626
    		R49 = C838 / C626

    		# Account for stochastic anomalies
    		R47 *= 1 + D47 / 1000
    		R48 *= 1 + D48 / 1000
    		R49 *= 1 + D49 / 1000

    		# Return isobar ratios
    		return R45, R46, R47, R48, R49

    	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
    		'''
    		Split unknown samples by UID (treat all analyses as different samples)
    		or by session (treat analyses of a given sample in different sessions as
    		different samples).

    		**Parameters**

    		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
    		+ `grouping`: `by_uid` | `by_session`
    		'''
    		if samples_to_split == 'all':
    			samples_to_split = [s for s in self.unknowns]
    		gkeys = {'by_uid':'UID', 'by_session':'Session'}
    		self.grouping = grouping.lower()
    		if self.grouping in gkeys:
    			gkey = gkeys[self.grouping]
    		for r in self:
    			if r['Sample'] in samples_to_split:
    				r['Sample_original'] = r['Sample']
    				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
    			elif r['Sample'] in self.unknowns:
    				r['Sample_original'] = r['Sample']
    		self.refresh_samples()

    	def unsplit_samples(self, tables = False):
    		'''
    		Reverse the effects of `D47data.split_samples()`.

    		This should only be used after `D4xdata.standardize()` with `method='pooled'`.

    		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
    		probably use `D4xdata.combine_samples()` instead to reverse the effects of
    		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
    		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
    		that case session-averaged Δ4x values are statistically independent).
    		'''
    		unknowns_old = sorted({s for s in self.unknowns})
    		CM_old = self.standardization.covar[:,:]
    		VD_old = self.standardization.params.valuesdict().copy()
    		vars_old = self.standardization.var_names

    		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})

    		Ns = len(vars_old) - len(unknowns_old)
    		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
    		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}

    		W = np.zeros((len(vars_new), len(vars_old)))
    		W[:Ns,:Ns] = np.eye(Ns)
    		for u in unknowns_new:
    			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
    			if self.grouping == 'by_session':
    				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
    			elif self.grouping == 'by_uid':
    				weights = [1 for s in splits]
    			sw = sum(weights)
    			weights = [w/sw for w in weights]
    			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]

    		CM_new = W @ CM_old @ W.T
    		V = W @ np.array([[VD_old[k]] for k in vars_old])
    		VD_new = {k:v[0] for k,v in zip(vars_new, V)}

    		self.standardization.covar = CM_new
    		self.standardization.params.valuesdict = lambda : VD_new
    		self.standardization.var_names = vars_new

    		for r in self:
    			if r['Sample'] in self.unknowns:
    				r['Sample_split'] = r['Sample']
    				r['Sample'] = r['Sample_original']

    		self.refresh_samples()
    		self.consolidate_samples()
    		self.repeatabilities()

    		if tables:
    			self.table_of_analyses()
    			self.table_of_samples()

    	def assign_timestamps(self):
    		'''
    		Assign a time field `t` of type `float` to each analysis.

    		If `TimeTag` is one of the data fields, `t` is equal within a given session
    		to `TimeTag` minus the mean value of `TimeTag` for that session.
    		Otherwise, `TimeTag` is by default equal to the index of each analysis
    		in the dataset and `t` is defined as above.
    		'''
    		for session in self.sessions:
    			sdata = self.sessions[session]['data']
    			try:
    				t0 = np.mean([r['TimeTag'] for r in sdata])
    				for r in sdata:
    					r['t'] = r['TimeTag'] - t0
    			except KeyError:
    				t0 = (len(sdata)-1)/2
    				for t,r in enumerate(sdata):
    					r['t'] = t - t0

    	def report(self):
    		'''
    		Prints a report on the standardization fit.
    		Only applicable after `D4xdata.standardize(method='pooled')`.
    		'''
    		report_fit(self.standardization)

    	def combine_samples(self, sample_groups):
    		'''
    		Combine analyses of different samples to compute weighted average Δ4x
    		and new error (co)variances corresponding to the groups defined by the `sample_groups`
    		dictionary.

    		Caution: samples are weighted by number of replicate analyses, which is a
    		reasonable default behavior but is not always optimal (e.g., in the case of strongly
    		correlated analytical errors for one or more samples).

    		Returns a tuplet of:

    		+ the list of group names
    		+ an array of the corresponding Δ4x values
    		+ the corresponding (co)variance matrix

    		**Parameters**

    		+ `sample_groups`: a dictionary of the form:
    		```py
    		{'group1': ['sample_1', 'sample_2'],
    		 'group2': ['sample_3', 'sample_4', 'sample_5']}
    		```
    		'''

    		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
    		groups = sorted(sample_groups.keys())
    		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
    		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
    		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
    		W = np.array([
    			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
    			for j in groups])
    		D4x_new = W @ D4x_old
    		CM_new = W @ CM_old @ W.T

    		return groups, D4x_new[:,0], CM_new

    	@make_verbal
    	def standardize(self,
    		method = 'pooled',
    		weighted_sessions = [],
    		consolidate = True,
    		consolidate_tables = False,
    		consolidate_plots = False,
    		constraints = {},
    		):
    		'''
    		Compute absolute Δ4x values for all replicate analyses and for sample averages.
    		If `method` argument is set to `'pooled'`, the standardization processes all sessions
    		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
    		i.e. that their true Δ4x value does not change between sessions,
    		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
    		`'indep_sessions'`, the standardization processes each session independently, based only
    		on anchors analyses.
    		'''

    		self.standardization_method = method
    		self.assign_timestamps()

    		if method == 'pooled':
    			if weighted_sessions:
    				for session_group in weighted_sessions:
    					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
    					X.Nominal_D4x = self.Nominal_D4x.copy()
    					X.refresh()
    					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
    					w = np.sqrt(result.redchi)
    					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
    					for r in X:
    						r[f'wD{self._4x}raw'] *= w
    			else:
    				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
    				for r in self:
    					r[f'wD{self._4x}raw'] = 1.

    			params = Parameters()
    			for k,session in enumerate(self.sessions):
    				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
    				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
    				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
    				s = pf(session)
    				params.add(f'a_{s}', value = 0.9)
    				params.add(f'b_{s}', value = 0.)
    				params.add(f'c_{s}', value = -0.9)
    				params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
    				params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
    				params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
    			for sample in self.unknowns:
    				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)

    			for k in constraints:
    				params[k].expr = constraints[k]

    			def residuals(p):
    				R = []
    				for r in self:
    					session = pf(r['Session'])
    					sample = pf(r['Sample'])
    					if r['Sample'] in self.Nominal_D4x:
    						R += [ (
    							r[f'D{self._4x}raw'] - (
    								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
    								+ p[f'b_{session}'] * r[f'd{self._4x}']
    								+	p[f'c_{session}']
    								+ r['t'] * (
    									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
    									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    									+	p[f'c2_{session}']
    									)
    								)
    							) / r[f'wD{self._4x}raw'] ]
    					else:
    						R += [ (
    							r[f'D{self._4x}raw'] - (
    								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
    								+ p[f'b_{session}'] * r[f'd{self._4x}']
    								+	p[f'c_{session}']
    								+ r['t'] * (
    									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
    									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    									+	p[f'c2_{session}']
    									)
    								)
    							) / r[f'wD{self._4x}raw'] ]
    				return R

    			M = Minimizer(residuals, params)
    			result = M.least_squares()
    			self.Nf = result.nfree
    			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
    # 			if self.verbose:
    # 				report_fit(result)

    			for r in self:
    				s = pf(r["Session"])
    				a = result.params.valuesdict()[f'a_{s}']
    				b = result.params.valuesdict()[f'b_{s}']
    				c = result.params.valuesdict()[f'c_{s}']
    				a2 = result.params.valuesdict()[f'a2_{s}']
    				b2 = result.params.valuesdict()[f'b2_{s}']
    				c2 = result.params.valuesdict()[f'c2_{s}']
    				r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])

    			self.standardization = result

    			for session in self.sessions:
    				self.sessions[session]['Np'] = 3
    				for k in ['scrambling', 'slope', 'wg']:
    					if self.sessions[session][f'{k}_drift']:
    						self.sessions[session]['Np'] += 1

    			if consolidate:
    				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
    			return result

    		elif method == 'indep_sessions':

    			if weighted_sessions:
    				for session_group in weighted_sessions:
    					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
    					X.Nominal_D4x = self.Nominal_D4x.copy()
    					X.refresh()
    					# This is only done to assign r['wD47raw'] for r in X:
    					X.standardize(method = method, weighted_sessions = [], consolidate = False)
    					self.msg(f'D{self._4x}raw weights set to {1000*X[0][f"wD{self._4x}raw"]:.1f} ppm for sessions in {session_group}')
    			else:
    				self.msg('All weights set to 1 ‰')
    				for r in self:
    					r[f'wD{self._4x}raw'] = 1

    			for session in self.sessions:
    				s = self.sessions[session]
    				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
    				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
    				s['Np'] = sum(p_active)
    				sdata = s['data']

    				A = np.array([
    					[
    						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
    						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
    						1 / r[f'wD{self._4x}raw'],
    						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
    						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
    						r['t'] / r[f'wD{self._4x}raw']
    						]
    					for r in sdata if r['Sample'] in self.anchors
    					])[:,p_active] # only keep columns for the active parameters
    				Y = np.array([[r[f'D{self._4x}raw'] / r[f'wD{self._4x}raw']] for r in sdata if r['Sample'] in self.anchors])
    				s['Na'] = Y.size
    				CM = linalg.inv(A.T @ A)
    				bf = (CM @ A.T @ Y).T[0,:]
    				k = 0
    				for n,a in zip(p_names, p_active):
    					if a:
    						s[n] = bf[k]
    # 						self.msg(f'{n} = {bf[k]}')
    						k += 1
    					else:
    						s[n] = 0.
    # 						self.msg(f'{n} = 0.0')

    				for r in sdata :
    					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
    					r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
    					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])

    				s['CM'] = np.zeros((6,6))
    				i = 0
    				k_active = [j for j,a in enumerate(p_active) if a]
    				for j,a in enumerate(p_active):
    					if a:
    						s['CM'][j,k_active] = CM[i,:]
    						i += 1

    			if not weighted_sessions:
    				w = self.rmswd()['rmswd']
    				for r in self:
    						r[f'wD{self._4x}'] *= w
    						r[f'wD{self._4x}raw'] *= w
    				for session in self.sessions:
    					self.sessions[session]['CM'] *= w**2

    			for session in self.sessions:
    				s = self.sessions[session]
    				s['SE_a'] = s['CM'][0,0]**.5
    				s['SE_b'] = s['CM'][1,1]**.5
    				s['SE_c'] = s['CM'][2,2]**.5
    				s['SE_a2'] = s['CM'][3,3]**.5
    				s['SE_b2'] = s['CM'][4,4]**.5
    				s['SE_c2'] = s['CM'][5,5]**.5

    			if not weighted_sessions:
    				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
    			else:
    				self.Nf = 0
    				for sg in weighted_sessions:
    					self.Nf += self.rmswd(sessions = sg)['Nf']

    			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)

    			avgD4x = {
    				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
    				for sample in self.samples
    				}
    			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
    			rD4x = (chi2/self.Nf)**.5
    			self.repeatability[f'sigma_{self._4x}'] = rD4x

    			if consolidate:
    				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)

    	def standardization_error(self, session, d4x, D4x, t = 0):
    		'''
    		Compute standardization error for a given session and
    		(δ47, Δ47) composition.
    		'''
    		a = self.sessions[session]['a']
    		b = self.sessions[session]['b']
    		c = self.sessions[session]['c']
    		a2 = self.sessions[session]['a2']
    		b2 = self.sessions[session]['b2']
    		c2 = self.sessions[session]['c2']
    		CM = self.sessions[session]['CM']

    		x, y = D4x, d4x
    		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
    # 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
    		dxdy = -(b+b2*t) / (a+a2*t)
    		dxdz = 1. / (a+a2*t)
    		dxda = -x / (a+a2*t)
    		dxdb = -y / (a+a2*t)
    		dxdc = -1. / (a+a2*t)
    		dxda2 = -x * a2 / (a+a2*t)
    		dxdb2 = -y * t / (a+a2*t)
    		dxdc2 = -t / (a+a2*t)
    		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
    		sx = (V @ CM @ V.T) ** .5
    		return sx

    	@make_verbal
    	def summary(self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		):
    		'''
    		Print out an/or save to disk a summary of the standardization results.

    		**Parameters**

    		+ `dir`: the directory in which to save the table
    		+ `filename`: the name to the csv file to write to
    		+ `save_to_file`: whether to save the table to disk
    		+ `print_out`: whether to print out the table
    		'''

    		out = []
    		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
    		out += [['N analyses (anchors + unknowns)', f"{len(self)} ({len([r for r in self if r['Sample'] in self.anchors])} + {len([r for r in self if r['Sample'] in self.unknowns])})"]]
    		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
    		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
    		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
    		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
    		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
    		out += [['Model degrees of freedom', f"{self.Nf}"]]
    		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
    		out += [['Standardization method', self.standardization_method]]

    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_summary.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n' + pretty_table(out, header = 0))

    	@make_verbal
    	def table_of_sessions(self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out an/or save to disk a table of sessions.

    		**Parameters**

    		+ `dir`: the directory in which to save the table
    		+ `filename`: the name to the csv file to write to
    		+ `save_to_file`: whether to save the table to disk
    		+ `print_out`: whether to print out the table
    		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''
    		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
    		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
    		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])

    		out = [['Session','Na','Nu','d13Cwg_VPDB','d18Owg_VSMOW','r_d13C','r_d18O',f'r_D{self._4x}','a ± SE','1e3 x b ± SE','c ± SE']]
    		if include_a2:
    			out[-1] += ['a2 ± SE']
    		if include_b2:
    			out[-1] += ['b2 ± SE']
    		if include_c2:
    			out[-1] += ['c2 ± SE']
    		for session in self.sessions:
    			out += [[
    				session,
    				f"{self.sessions[session]['Na']}",
    				f"{self.sessions[session]['Nu']}",
    				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
    				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
    				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
    				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
    				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
    				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
    				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
    				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
    				]]
    			if include_a2:
    				if self.sessions[session]['scrambling_drift']:
    					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
    				else:
    					out[-1] += ['']
    			if include_b2:
    				if self.sessions[session]['slope_drift']:
    					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
    				else:
    					out[-1] += ['']
    			if include_c2:
    				if self.sessions[session]['wg_drift']:
    					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
    				else:
    					out[-1] += ['']

    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_sessions.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n' + pretty_table(out))
    		if output == 'raw':
    			return out
    		elif output == 'pretty':
    			return pretty_table(out)

    	@make_verbal
    	def table_of_analyses(
    		self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out an/or save to disk a table of analyses.

    		**Parameters**

    		+ `dir`: the directory in which to save the table
    		+ `filename`: the name to the csv file to write to
    		+ `save_to_file`: whether to save the table to disk
    		+ `print_out`: whether to print out the table
    		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''

    		out = [['UID','Session','Sample']]
    		extra_fields = [f for f in [('SampleMass','.2f'),('ColdFingerPressure','.1f'),('AcidReactionYield','.3f')] if f[0] in {k for r in self for k in r}]
    		for f in extra_fields:
    			out[-1] += [f[0]]
    		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
    		for r in self:
    			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
    			for f in extra_fields:
    				out[-1] += [f"{r[f[0]]:{f[1]}}"]
    			out[-1] += [
    				f"{r['d13Cwg_VPDB']:.3f}",
    				f"{r['d18Owg_VSMOW']:.3f}",
    				f"{r['d45']:.6f}",
    				f"{r['d46']:.6f}",
    				f"{r['d47']:.6f}",
    				f"{r['d48']:.6f}",
    				f"{r['d49']:.6f}",
    				f"{r['d13C_VPDB']:.6f}",
    				f"{r['d18O_VSMOW']:.6f}",
    				f"{r['D47raw']:.6f}",
    				f"{r['D48raw']:.6f}",
    				f"{r['D49raw']:.6f}",
    				f"{r[f'D{self._4x}']:.6f}"
    				]
    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_analyses.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n' + pretty_table(out))
    		return out

    	@make_verbal
    	def covar_table(
    		self,
    		correl = False,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out, save to disk and/or return the variance-covariance matrix of D4x
    		for all unknown samples.

    		**Parameters**

    		+ `dir`: the directory in which to save the csv
    		+ `filename`: the name of the csv file to write to
    		+ `save_to_file`: whether to save the csv
    		+ `print_out`: whether to print out the matrix
    		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''
    		samples = sorted([u for u in self.unknowns])
    		out = [[''] + samples]
    		for s1 in samples:
    			out.append([s1])
    			for s2 in samples:
    				if correl:
    					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
    				else:
    					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')

    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				if correl:
    					filename = f'D{self._4x}_correl.csv'
    				else:
    					filename = f'D{self._4x}_covar.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n'+pretty_table(out))
    		if output == 'raw':
    			return out
    		elif output == 'pretty':
    			return pretty_table(out)

    	@make_verbal
    	def table_of_samples(
    		self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out, save to disk and/or return a table of samples.

    		**Parameters**

    		+ `dir`: the directory in which to save the csv
    		+ `filename`: the name of the csv file to write to
    		+ `save_to_file`: whether to save the csv
    		+ `print_out`: whether to print out the table
    		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''

    		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
    		for sample in self.anchors:
    			out += [[
    				f"{sample}",
    				f"{self.samples[sample]['N']}",
    				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
    				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
    				]]
    		for sample in self.unknowns:
    			out += [[
    				f"{sample}",
    				f"{self.samples[sample]['N']}",
    				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    				f"{self.samples[sample][f'D{self._4x}']:.4f}",
    				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
    				f"± {self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
    				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
    				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
    				]]
    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_samples.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n'+pretty_table(out))
    		if output == 'raw':
    			return out
    		elif output == 'pretty':
    			return pretty_table(out)

    	def plot_sessions(self, dir = 'output', figsize = (8,8)):
    		'''
    		Generate session plots and save them to disk.

    		**Parameters**

    		+ `dir`: the directory in which to save the plots
    		+ `figsize`: the width and height (in inches) of each plot
    		'''
    		if not os.path.exists(dir):
    			os.makedirs(dir)

    		for session in self.sessions:
    			sp = self.plot_single_session(session, xylimits = 'constant')
    			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
    			ppl.close(sp.fig)

    	@make_verbal
    	def consolidate_samples(self):
    		'''
    		Compile various statistics for each sample.

    		For each anchor sample:

    		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
    		+ `SE_D47` or `SE_D48`: set to zero by definition

    		For each unknown sample:

    		+ `D47` or `D48`: the standardized Δ4x value for this unknown
    		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown

    		For each anchor and unknown:

    		+ `N`: the total number of analyses of this sample
    		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
    		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
    		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
    		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
    		variance, indicating whether the Δ4x repeatability this sample differs significantly from
    		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
    		'''
    		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
    		for sample in self.samples:
    			self.samples[sample]['N'] = len(self.samples[sample]['data'])
    			if self.samples[sample]['N'] > 1:
    				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])

    			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
    			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])

    			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
    			if len(D4x_pop) > 2:
    				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]

    		if self.standardization_method == 'pooled':
    			for sample in self.anchors:
    				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    				self.samples[sample][f'SE_D{self._4x}'] = 0.
    			for sample in self.unknowns:
    				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
    				try:
    					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
    				except ValueError:
    					# when `sample` is constrained by self.standardize(constraints = {...}),
    					# it is no longer listed in self.standardization.var_names.
    					# Temporary fix: define SE as zero for now
    					self.samples[sample][f'SE_D4{self._4x}'] = 0.

    		elif self.standardization_method == 'indep_sessions':
    			for sample in self.anchors:
    				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    				self.samples[sample][f'SE_D{self._4x}'] = 0.
    			for sample in self.unknowns:
    				self.msg(f'Consolidating sample {sample}')
    				self.unknowns[sample][f'session_D{self._4x}'] = {}
    				session_avg = []
    				for session in self.sessions:
    					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
    					if sdata:
    						self.msg(f'{sample} found in session {session}')
    						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
    						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
    						# !! TODO: sigma_s below does not account for temporal changes in standardization error
    						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
    						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
    						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
    						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
    				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
    				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
    				wsum = sum([weights[s] for s in weights])
    				for s in weights:
    					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]

    	def consolidate_sessions(self):
    		'''
    		Compute various statistics for each session.

    		+ `Na`: Number of anchor analyses in the session
    		+ `Nu`: Number of unknown analyses in the session
    		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
    		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
    		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
    		+ `a`: scrambling factor
    		+ `b`: compositional slope
    		+ `c`: WG offset
    		+ `SE_a`: Model stadard erorr of `a`
    		+ `SE_b`: Model stadard erorr of `b`
    		+ `SE_c`: Model stadard erorr of `c`
    		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
    		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
    		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
    		+ `a2`: scrambling factor drift
    		+ `b2`: compositional slope drift
    		+ `c2`: WG offset drift
    		+ `Np`: Number of standardization parameters to fit
    		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
    		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
    		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
    		'''
    		for session in self.sessions:
    			if 'd13Cwg_VPDB' not in self.sessions[session]:
    				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
    			if 'd18Owg_VSMOW' not in self.sessions[session]:
    				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
    			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
    			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])

    			self.msg(f'Computing repeatabilities for session {session}')
    			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
    			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
    			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])

    		if self.standardization_method == 'pooled':
    			for session in self.sessions:

    				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
    				i = self.standardization.var_names.index(f'a_{pf(session)}')
    				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5

    				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
    				i = self.standardization.var_names.index(f'b_{pf(session)}')
    				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5

    				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
    				i = self.standardization.var_names.index(f'c_{pf(session)}')
    				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5

    				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
    				if self.sessions[session]['scrambling_drift']:
    					i = self.standardization.var_names.index(f'a2_{pf(session)}')
    					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
    				else:
    					self.sessions[session]['SE_a2'] = 0.

    				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
    				if self.sessions[session]['slope_drift']:
    					i = self.standardization.var_names.index(f'b2_{pf(session)}')
    					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
    				else:
    					self.sessions[session]['SE_b2'] = 0.

    				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
    				if self.sessions[session]['wg_drift']:
    					i = self.standardization.var_names.index(f'c2_{pf(session)}')
    					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
    				else:
    					self.sessions[session]['SE_c2'] = 0.

    				i = self.standardization.var_names.index(f'a_{pf(session)}')
    				j = self.standardization.var_names.index(f'b_{pf(session)}')
    				k = self.standardization.var_names.index(f'c_{pf(session)}')
    				CM = np.zeros((6,6))
    				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
    				try:
    					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
    					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
    					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
    					try:
    						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    						CM[3,4] = self.standardization.covar[i2,j2]
    						CM[4,3] = self.standardization.covar[j2,i2]
    					except ValueError:
    						pass
    					try:
    						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    						CM[3,5] = self.standardization.covar[i2,k2]
    						CM[5,3] = self.standardization.covar[k2,i2]
    					except ValueError:
    						pass
    				except ValueError:
    					pass
    				try:
    					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
    					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
    					try:
    						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    						CM[4,5] = self.standardization.covar[j2,k2]
    						CM[5,4] = self.standardization.covar[k2,j2]
    					except ValueError:
    						pass
    				except ValueError:
    					pass
    				try:
    					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
    					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
    				except ValueError:
    					pass

    				self.sessions[session]['CM'] = CM

    		elif self.standardization_method == 'indep_sessions':
    			pass # Not implemented yet

    	@make_verbal
    	def repeatabilities(self):
    		'''
    		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    		(for all samples, for anchors, and for unknowns).
    		'''
    		self.msg('Computing reproducibilities for all sessions')

    		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')

    	@make_verbal
    	def consolidate(self, tables = True, plots = True):
    		'''
    		Collect information about samples, sessions and repeatabilities.
    		'''
    		self.consolidate_samples()
    		self.consolidate_sessions()
    		self.repeatabilities()

    		if tables:
    			self.summary()
    			self.table_of_sessions()
    			self.table_of_analyses()
    			self.table_of_samples()

    		if plots:
    			self.plot_sessions()

    	@make_verbal
    	def rmswd(self,
    		samples = 'all samples',
    		sessions = 'all sessions',
    		):
    		'''
    		Compute the χ2, root mean squared weighted deviation
    		(i.e. reduced χ2), and corresponding degrees of freedom of the
    		Δ4x values for samples in `samples` and sessions in `sessions`.

    		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
    		'''
    		if samples == 'all samples':
    			mysamples = [k for k in self.samples]
    		elif samples == 'anchors':
    			mysamples = [k for k in self.anchors]
    		elif samples == 'unknowns':
    			mysamples = [k for k in self.unknowns]
    		else:
    			mysamples = samples

    		if sessions == 'all sessions':
    			sessions = [k for k in self.sessions]

    		chisq, Nf = 0, 0
    		for sample in mysamples :
    			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    			if len(G) > 1 :
    				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
    				Nf += (len(G) - 1)
    				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
    		r = (chisq / Nf)**.5 if Nf > 0 else 0
    		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
    		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}

    	@make_verbal
    	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
    		'''
    		Compute the repeatability of `[r[key] for r in self]`
    		'''
    		# NB: it's debatable whether rD47 should be computed
    		# with Nf = len(self)-len(self.samples) instead of
    		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)

    		if samples == 'all samples':
    			mysamples = [k for k in self.samples]
    		elif samples == 'anchors':
    			mysamples = [k for k in self.anchors]
    		elif samples == 'unknowns':
    			mysamples = [k for k in self.unknowns]
    		else:
    			mysamples = samples

    		if sessions == 'all sessions':
    			sessions = [k for k in self.sessions]

    		if key in ['D47', 'D48']:
    			chisq, Nf = 0, 0
    			for sample in mysamples :
    				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    				if len(X) > 1 :
    					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
    					if sample in self.unknowns:
    						Nf += len(X) - 1
    					else:
    						Nf += len(X)
    			if samples in ['anchors', 'all samples']:
    				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
    			r = (chisq / Nf)**.5 if Nf > 0 else 0

    		else: # if key not in ['D47', 'D48']
    			chisq, Nf = 0, 0
    			for sample in mysamples :
    				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    				if len(X) > 1 :
    					Nf += len(X) - 1
    					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
    			r = (chisq / Nf)**.5 if Nf > 0 else 0

    		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
    		return r

    	def sample_average(self, samples, weights = 'equal', normalize = True):
    		'''
    		Weighted average Δ4x value of a group of samples, accounting for covariance.

    		Returns the weighed average Δ4x value and associated SE
    		of a group of samples. Weights are equal by default. If `normalize` is
    		true, `weights` will be rescaled so that their sum equals 1.

    		**Examples**

    		```python
    		self.sample_average(['X','Y'], [1, 2])
    		```

    		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
    		where Δ4x(X) and Δ4x(Y) are the average Δ4x
    		values of samples X and Y, respectively.

    		```python
    		self.sample_average(['X','Y'], [1, -1], normalize = False)
    		```

    		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
    		'''
    		if weights == 'equal':
    			weights = [1/len(samples)] * len(samples)

    		if normalize:
    			s = sum(weights)
    			if s:
    				weights = [w/s for w in weights]

    		try:
    # 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
    # 			C = self.standardization.covar[indices,:][:,indices]
    			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
    			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
    			return correlated_sum(X, C, weights)
    		except ValueError:
    			return (0., 0.)

    	def sample_D4x_covar(self, sample1, sample2 = None):
    		'''
    		Covariance between Δ4x values of samples

    		Returns the error covariance between the average Δ4x values of two
    		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
    		returns the Δ4x variance for that sample.
    		'''
    		if sample2 is None:
    			sample2 = sample1
    		if self.standardization_method == 'pooled':
    			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
    			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
    			return self.standardization.covar[i, j]
    		elif self.standardization_method == 'indep_sessions':
    			if sample1 == sample2:
    				return self.samples[sample1][f'SE_D{self._4x}']**2
    			else:
    				c = 0
    				for session in self.sessions:
    					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
    					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
    					if sdata1 and sdata2:
    						a = self.sessions[session]['a']
    						# !! TODO: CM below does not account for temporal changes in standardization parameters
    						CM = self.sessions[session]['CM'][:3,:3]
    						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
    						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
    						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
    						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
    						c += (
    							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
    							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
    							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
    							@ CM
    							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
    							) / a**2
    				return float(c)

    	def sample_D4x_correl(self, sample1, sample2 = None):
    		'''
    		Correlation between Δ4x errors of samples

    		Returns the error correlation between the average Δ4x values of two samples.
    		'''
    		if sample2 is None or sample2 == sample1:
    			return 1.
    		return (
    			self.sample_D4x_covar(sample1, sample2)
    			/ self.unknowns[sample1][f'SE_D{self._4x}']
    			/ self.unknowns[sample2][f'SE_D{self._4x}']
    			)

    	def plot_single_session(self,
    		session,
    		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
    		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
    		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
    		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
    		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
    		xylimits = 'free', # | 'constant'
    		x_label = None,
    		y_label = None,
    		error_contour_interval = 'auto',
    		fig = 'new',
    		):
    		'''
    		Generate plot for a single session
    		'''
    		if x_label is None:
    			x_label = f'δ$_{{{self._4x}}}$ (‰)'
    		if y_label is None:
    			y_label = f'Δ$_{{{self._4x}}}$ (‰)'

    		out = _SessionPlot()
    		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
    		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]

    		if fig == 'new':
    			out.fig = ppl.figure(figsize = (6,6))
    			ppl.subplots_adjust(.1,.1,.9,.9)

    		out.anchor_analyses, = ppl.plot(
    			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    			**kw_plot_anchors)
    		out.unknown_analyses, = ppl.plot(
    			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    			**kw_plot_unknowns)
    		out.anchor_avg = ppl.plot(
    			np.array([ np.array([
    				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    				]) for sample in anchors]).T,
    			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
    			**kw_plot_anchor_avg)
    		out.unknown_avg = ppl.plot(
    			np.array([ np.array([
    				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    				]) for sample in unknowns]).T,
    			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
    			**kw_plot_unknown_avg)
    		if xylimits == 'constant':
    			x = [r[f'd{self._4x}'] for r in self]
    			y = [r[f'D{self._4x}'] for r in self]
    			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
    			w, h = x2-x1, y2-y1
    			x1 -= w/20
    			x2 += w/20
    			y1 -= h/20
    			y2 += h/20
    			ppl.axis([x1, x2, y1, y2])
    		elif xylimits == 'free':
    			x1, x2, y1, y2 = ppl.axis()
    		else:
    			x1, x2, y1, y2 = ppl.axis(xylimits)

    		if error_contour_interval != 'none':
    			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
    			XI,YI = np.meshgrid(xi, yi)
    			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
    			if error_contour_interval == 'auto':
    				rng = np.max(SI) - np.min(SI)
    				if rng <= 0.01:
    					cinterval = 0.001
    				elif rng <= 0.03:
    					cinterval = 0.004
    				elif rng <= 0.1:
    					cinterval = 0.01
    				elif rng <= 0.3:
    					cinterval = 0.03
    				elif rng <= 1.:
    					cinterval = 0.1
    				else:
    					cinterval = 0.5
    			else:
    				cinterval = error_contour_interval

    			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
    			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
    			out.clabel = ppl.clabel(out.contour)

    		ppl.xlabel(x_label)
    		ppl.ylabel(y_label)
    		ppl.title(session, weight = 'bold')
    		ppl.grid(alpha = .2)
    		out.ax = ppl.gca()

    		return out

    	def plot_residuals(
    		self,
    		hist = False,
    		binwidth = 2/3,
    		dir = 'output',
    		filename = None,
    		highlight = [],
    		colors = None,
    		figsize = None,
    		):
    		'''
    		Plot residuals of each analysis as a function of time (actually, as a function of
    		the order of analyses in the `D4xdata` object)

    		+ `hist`: whether to add a histogram of residuals
    		+ `histbins`: specify bin edges for the histogram
    		+ `dir`: the directory in which to save the plot
    		+ `highlight`: a list of samples to highlight
    		+ `colors`: a dict of `{<sample>: <color>}` for all samples
    		+ `figsize`: (width, height) of figure
    		'''
    		# Layout
    		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
    		if hist:
    			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
    			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
    		else:
    			ppl.subplots_adjust(.08,.05,.78,.8)
    			ax1 = ppl.subplot(111)

    		# Colors
    		N = len(self.anchors)
    		if colors is None:
    			if len(highlight) > 0:
    				Nh = len(highlight)
    				if Nh == 1:
    					colors = {highlight[0]: (0,0,0)}
    				elif Nh == 3:
    					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
    				elif Nh == 4:
    					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    				else:
    					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
    			else:
    				if N == 3:
    					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
    				elif N == 4:
    					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    				else:
    					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}

    		ppl.sca(ax1)

    		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)

    		session = self[0]['Session']
    		x1 = 0
    # 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
    		x_sessions = {}
    		one_or_more_singlets = False
    		one_or_more_multiplets = False
    		for k,r in enumerate(self):
    			if r['Session'] != session:
    				x2 = k-1
    				x_sessions[session] = (x1+x2)/2
    				ppl.axvline(k - 0.5, color = 'k', lw = .5)
    				session = r['Session']
    				x1 = k
    			singlet = len(self.samples[r['Sample']]['data']) == 1
    			if r['Sample'] in self.unknowns:
    				if singlet:
    					one_or_more_singlets = True
    				else:
    					one_or_more_multiplets = True
    			kw = dict(
    				marker = 'x' if singlet else '+',
    				ms = 4 if singlet else 5,
    				ls = 'None',
    				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
    				mew = 1,
    				alpha = 0.2 if singlet else 1,
    				)
    			if highlight and r['Sample'] not in highlight:
    				kw['alpha'] = 0.2
    			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
    		x2 = k
    		x_sessions[session] = (x1+x2)/2

    		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
    		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
    		if not hist:
    			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
    			ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f"   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')

    		xmin, xmax, ymin, ymax = ppl.axis()
    		for s in x_sessions:
    			ppl.text(
    				x_sessions[s],
    				ymax +1,
    				s,
    				va = 'bottom',
    				**(
    					dict(ha = 'center')
    					if len(self.sessions[s]['data']) > (0.15 * len(self))
    					else dict(ha = 'left', rotation = 45)
    					)
    				)

    		if hist:
    			ppl.sca(ax2)

    		for s in colors:
    			kw['marker'] = '+'
    			kw['ms'] = 5
    			kw['mec'] = colors[s]
    			kw['label'] = s
    			kw['alpha'] = 1
    			ppl.plot([], [], **kw)

    		kw['mec'] = (0,0,0)

    		if one_or_more_singlets:
    			kw['marker'] = 'x'
    			kw['ms'] = 4
    			kw['alpha'] = .2
    			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
    			ppl.plot([], [], **kw)

    		if one_or_more_multiplets:
    			kw['marker'] = '+'
    			kw['ms'] = 4
    			kw['alpha'] = 1
    			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
    			ppl.plot([], [], **kw)

    		if hist:
    			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
    		else:
    			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
    		leg.set_zorder(-1000)

    		ppl.sca(ax1)

    		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
    		ppl.xticks([])
    		ppl.axis([-1, len(self), None, None])

    		if hist:
    			ppl.sca(ax2)
    			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self]
    			ppl.hist(
    				X,
    				orientation = 'horizontal',
    				histtype = 'stepfilled',
    				ec = [.4]*3,
    				fc = [.25]*3,
    				alpha = .25,
    				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
    				)
    			ppl.axis([None, None, ymin, ymax])
    			ppl.text(0, 0,
    				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
    				size = 8,
    				alpha = 1,
    				va = 'center',
    				ha = 'left',
    				)

    			ppl.xticks([])
    			ppl.yticks([])
    # 			ax2.spines['left'].set_visible(False)
    			ax2.spines['right'].set_visible(False)
    			ax2.spines['top'].set_visible(False)
    			ax2.spines['bottom'].set_visible(False)

    		if not os.path.exists(dir):
    			os.makedirs(dir)
    		if filename is None:
    			return fig
    		elif filename == '':
    			filename = f'D{self._4x}_residuals.pdf'
    		ppl.savefig(f'{dir}/{filename}')
    		ppl.close(fig)

    	def simulate(self, *args, **kwargs):
    		'''
    		Legacy function with warning message pointing to `virtual_data()`
    		'''
    		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')

    	def plot_distribution_of_analyses(self, dir = 'output', filename = None, vs_time = False, output = None):
    		'''
    		Plot temporal distribution of all analyses in the data set.

    		**Parameters**

    		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    		'''

    		asamples = [s for s in self.anchors]
    		usamples = [s for s in self.unknowns]
    		if output is None or output == 'fig':
    			fig = ppl.figure(figsize = (6,4))
    			ppl.subplots_adjust(0.02, 0.03, 0.9, 0.8)
    		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    		for k, s in enumerate(asamples + usamples):
    			if vs_time:
    				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    			else:
    				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    			Y = [k for x in X]
    			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .5)
    			ppl.axhline(k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    			ppl.text(Xmax, k, f'  {s}', va = 'center', ha = 'left', size = 7)
    		if vs_time:
    			t = [r['TimeTag'] for r in self]
    			t1, t2 = min(t), max(t)
    			tspan = t2 - t1
    			t1 -= tspan / len(self)
    			t2 += tspan / len(self)
    			ppl.axis([t1, t2, -1, k+1])
    		else:
    			ppl.axis([-1, len(self), -1, k+1])

    		x2 = 0
    		for session in self.sessions:
    			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    			if vs_time:
    				ppl.axvline(x1, color = 'k', lw = .75)
    			if k:
    				if vs_time:
    					ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .2)
    				else:
    					ppl.axvline((x1+x2)/2, color = 'k', lw = .75)
    			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    # 			from xlrd import xldate_as_datetime
    # 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    			if vs_time:
    				ppl.axvline(x2, color = 'k', lw = .75)
    			ppl.text((2*x1+x2)/3, k+1, session, ha = 'left', va = 'bottom', rotation = 45, size = 8)

    		ppl.xticks([])
    		ppl.yticks([])

    		if output is None:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename == None:
    				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    			ppl.savefig(f'{dir}/{filename}')
    			ppl.close(fig)
    		elif output == 'ax':
    			return ppl.gca()
    		elif output == 'fig':
    			return fig

    class D47data(D4xdata):
    	'''
    	Store and process data for a large set of Δ47 analyses,
    	usually comprising more than one analytical session.
    	'''

    	Nominal_D4x = {
    		'ETH-1':   0.2052,
    		'ETH-2':   0.2085,
    		'ETH-3':   0.6132,
    		'ETH-4':   0.4511,
    		'IAEA-C1': 0.3018,
    		'IAEA-C2': 0.6409,
    		'MERCK':   0.5135,
    		} # I-CDES (Bernasconi et al., 2021)
    	'''
    	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
    	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
    	reference frame.

    	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
    	```py
    	{
    		'ETH-1'   : 0.2052,
    		'ETH-2'   : 0.2085,
    		'ETH-3'   : 0.6132,
    		'ETH-4'   : 0.4511,
    		'IAEA-C1' : 0.3018,
    		'IAEA-C2' : 0.6409,
    		'MERCK'   : 0.5135,
    	}
    	```
    	'''

    	@property
    	def Nominal_D47(self):
    		return self.Nominal_D4x

    	@Nominal_D47.setter
    	def Nominal_D47(self, new):
    		self.Nominal_D4x = dict(**new)
    		self.refresh()

    	def __init__(self, l = [], **kwargs):
    		'''
    		**Parameters:** same as `D4xdata.__init__()`
    		'''
    		D4xdata.__init__(self, l = l, mass = '47', **kwargs)

    	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    		'''
    		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    		value for that temperature, and add treat these samples as additional anchors.

    		**Parameters**

    		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    		if `new`: keep pre-existing anchors but update them in case of conflict
    		between old and new Δ47 values;
    		if `old`: keep pre-existing anchors but preserve their original Δ47
    		values in case of conflict.
    		'''
    		f = {
    			'petersen': fCO2eqD47_Petersen,
    			'wang': fCO2eqD47_Wang,
    			}[fCo2eqD47]
    		foo = {}
    		for r in self:
    			if 'Teq' in r:
    				if r['Sample'] in foo:
    					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    				else:
    					foo[r['Sample']] = f(r['Teq'])
    			else:
    					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'

    		if priority == 'replace':
    			self.Nominal_D47 = {}
    		for s in foo:
    			if priority != 'old' or s not in self.Nominal_D47:
    				self.Nominal_D47[s] = foo[s]

    class D48data(D4xdata):
    	'''
    	Store and process data for a large set of Δ48 analyses,
    	usually comprising more than one analytical session.
    	'''

    	Nominal_D4x = {
    		'ETH-1':  0.138,
    		'ETH-2':  0.138,
    		'ETH-3':  0.270,
    		'ETH-4':  0.223,
    		'GU-1':  -0.419,
    		} # (Fiebig et al., 2019, 2021)
    	'''
    	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
    	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
    	reference frame.

    	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
    	Fiebig et al. (in press)):

    	```py
    	{
    		'ETH-1' :  0.138,
    		'ETH-2' :  0.138,
    		'ETH-3' :  0.270,
    		'ETH-4' :  0.223,
    		'GU-1'  : -0.419,
    	}
    	```
    	'''

    	@property
    	def Nominal_D48(self):
    		return self.Nominal_D4x

    	@Nominal_D48.setter
    	def Nominal_D48(self, new):
    		self.Nominal_D4x = dict(**new)
    		self.refresh()

    	def __init__(self, l = [], **kwargs):
    		'''
    		**Parameters:** same as `D4xdata.__init__()`
    		'''
    		D4xdata.__init__(self, l = l, mass = '48', **kwargs)

    class _SessionPlot():
    	'''
    	Simple placeholder class
    	'''
    	def __init__(self):
    		pass

def fCO2eqD47_Petersen(T):
View Source

    def fCO2eqD47_Petersen(T):
    	'''
    	CO2 equilibrium Δ47 value as a function of T (in degrees C)
    	according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).

    	'''
    	return float(_fCO2eqD47_Petersen(T))

CO2 equilibrium Δ47 value as a function of T (in degrees C) according to Petersen et al. (2019).

def fCO2eqD47_Wang(T):
View Source

    def fCO2eqD47_Wang(T):
    	'''
    	CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
    	according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
    	(supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
    	'''
    	return float(_fCO2eqD47_Wang(T))

CO2 equilibrium Δ47 value as a function of `T` (in degrees C) according to Wang et al. (2004) (supplementary data of Dennis et al., 2011).

def correlated_sum(X, C, w=None):
View Source

    def correlated_sum(X, C, w = None):
    	'''
    	Compute covariance-aware linear combinations

    	**Parameters**

    	+ `X`: list or 1-D array of values to sum
    	+ `C`: covariance matrix for the elements of `X`
    	+ `w`: list or 1-D array of weights to apply to the elements of `X`
    	       (all equal to 1 by default)

    	Return the sum (and its SE) of the elements of `X`, with optional weights equal
    	to the elements of `w`, accounting for covariances between the elements of `X`.
    	'''
    	if w is None:
    		w = [1 for x in X]
    	return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5

Compute covariance-aware linear combinations

Parameters

- `X`: list or 1-D array of values to sum
- `C`: covariance matrix for the elements of `X`
- `w`: list or 1-D array of weights to apply to the elements of `X` (all equal to 1 by default)

Return the sum (and its SE) of the elements of `X`, with optional weights equal to the elements of `w`, accounting for covariances between the elements of `X`.

def make_csv(x, hsep=',', vsep='\n'):
View Source

    def make_csv(x, hsep = ',', vsep = '\n'):
    	'''
    	Formats a list of lists of strings as a CSV

    	**Parameters**

    	+ `x`: the list of lists of strings to format
    	+ `hsep`: the field separator (`,` by default)
    	+ `vsep`: the line-ending convention to use (`\\n` by default)

    	**Example**

    	```py
    	print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))
    	```

    	outputs:

    	```py
    	a,b,c
    	d,e,f
    	```
    	'''
    	return vsep.join([hsep.join(l) for l in x])

Formats a list of lists of strings as a CSV

Parameters

- `x`: the list of lists of strings to format
- `hsep`: the field separator (`,` by default)
- `vsep`: the line-ending convention to use (`\n` by default)

Example

    print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))

outputs:

    a,b,c
    d,e,f

def pf(txt):
View Source

    def pf(txt):
    	'''
    	Modify string `txt` to follow `lmfit.Parameter()` naming rules.
    	'''
    	return txt.replace('-','_').replace('.','_').replace(' ','_')

Modify string `txt` to follow `lmfit.Parameter()` naming rules.

def smart_type(x):
View Source

    def smart_type(x):
    	'''
    	Tries to convert string `x` to a float if it includes a decimal point, or
    	to an integer if it does not. If both attempts fail, return the original
    	string unchanged.
    	'''
    	try:
    		y = float(x)
    	except ValueError:
    		return x
    	if '.' not in x:
    		return int(y)
    	return y

Tries to convert string `x` to a float if it includes a decimal point, or to an integer if it does not. If both attempts fail, return the original string unchanged.

def pretty_table(x, header=1, hsep=' ', vsep='–', align='<'):
View Source

    def pretty_table(x, header = 1, hsep = '  ', vsep = '–', align = '<'):
    	'''
    	Reads a list of lists of strings and outputs an ascii table

    	**Parameters**

    	+ `x`: a list of lists of strings
    	+ `header`: the number of lines to treat as header lines
    	+ `hsep`: the horizontal separator between columns
    	+ `vsep`: the character to use as vertical separator
    	+ `align`: string of left (`<`) or right (`>`) alignment characters.

    	**Example**

    	```py
    	x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']]
    	print(pretty_table(x))
    	```
    	yields:
    	```
    	--  ------  ---
    	A        B    C
    	--  ------  ---
    	1   1.9999  foo
    	10       x  bar
    	--  ------  ---
    	```

    	'''
    	txt = []
    	widths = [np.max([len(e) for e in c]) for c in zip(*x)]

    	if len(widths) > len(align):
    		align += '>' * (len(widths)-len(align))
    	sepline = hsep.join([vsep*w for w in widths])
    	txt += [sepline]
    	for k,l in enumerate(x):
    		if k and k == header:
    			txt += [sepline]
    		txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])]
    	txt += [sepline]
    	txt += ['']
    	return '\n'.join(txt)

Reads a list of lists of strings and outputs an ascii table

Parameters

- `x`: a list of lists of strings
- `header`: the number of lines to treat as header lines
- `hsep`: the horizontal separator between columns
- `vsep`: the character to use as vertical separator
- `align`: string of left (`<`) or right (`>`) alignment characters.

Example

    x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']]
    print(pretty_table(x))

yields:

    --  ------  ---
    A        B    C
    --  ------  ---
    1   1.9999  foo
    10       x  bar
    --  ------  ---

def transpose_table(x):
View Source

    def transpose_table(x):
    	'''
    	Transpose a list if lists

    	**Parameters**

    	+ `x`: a list of lists

    	**Example**

    	```py
    	x = [[1, 2], [3, 4]]
    	print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
    	```
    	'''
    	return [[e for e in c] for c in zip(*x)]

Transpose a list if lists

Parameters

- `x`: a list of lists

Example

    x = [[1, 2], [3, 4]]
    print(transpose_table(x)) # yields: [[1, 3], [2, 4]]

def w_avg(X, sX):
View Source

    def w_avg(X, sX) :
    	'''
    	Compute variance-weighted average

    	Returns the value and SE of the weighted average of the elements of `X`,
    	with relative weights equal to their inverse variances (`1/sX**2`).

    	**Parameters**

    	+ `X`: array-like of elements to average
    	+ `sX`: array-like of the corresponding SE values

    	**Tip**

    	If `X` and `sX` are initially arranged as a list of `(x, sx)` doublets,
    	they may be rearranged using `zip()`:

    	```python
    	foo = [(0, 1), (1, 0.5), (2, 0.5)]
    	print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)
    	```
    	'''
    	X = [ x for x in X ]
    	sX = [ sx for sx in sX ]
    	W = [ sx**-2 for sx in sX ]
    	W = [ w/sum(W) for w in W ]
    	Xavg = sum([ w*x for w,x in zip(W,X) ])
    	sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5
    	return Xavg, sXavg

Compute variance-weighted average

Returns the value and SE of the weighted average of the elements of `X`, with relative weights equal to their inverse variances (`1/sX**2`).

Parameters

- `X`: array-like of elements to average
- `sX`: array-like of the corresponding SE values

Tip

If `X` and `sX` are initially arranged as a list of `(x, sx)` doublets, they may be rearranged using `zip()`:

    foo = [(0, 1), (1, 0.5), (2, 0.5)]
    print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)

def read_csv(filename, sep=''):
View Source

    def read_csv(filename, sep = ''):
    	'''
    	Read contents of `filename` in csv format and return a list of dictionaries.

    	In the csv string, spaces before and after field separators (`','` by default)
    	are optional.

    	**Parameters**

    	+ `filename`: the csv file to read
    	+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    	whichever appers most often in the contents of `filename`.
    	'''
    	with open(filename) as fid:
    		txt = fid.read()

    	if sep == '':
    		sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    	txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    	return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]]

Read contents of `filename` in csv format and return a list of dictionaries.

In the csv string, spaces before and after field separators (`','` by default) are optional.

Parameters

- `filename`: the csv file to read
- `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or ``, whichever appers most often in the contents of `filename`.

def simulate_single_analysis( sample='MYSAMPLE', d13Cwg_VPDB=-4.0, d18Owg_VSMOW=26.0, d13C_VPDB=None, d18O_VPDB=None, D47=None, D48=None, D49=0.0, D17O=0.0, a47=1.0, b47=0.0, c47=-0.9, a48=1.0, b48=0.0, c48=-0.45, Nominal_D47=None, Nominal_D48=None, Nominal_d13C_VPDB=None, Nominal_d18O_VPDB=None, ALPHA_18O_ACID_REACTION=None, R13_VPDB=None, R17_VSMOW=None, R18_VSMOW=None, LAMBDA_17=None, R18_VPDB=None ):
View Source

    def simulate_single_analysis(
    	sample = 'MYSAMPLE',
    	d13Cwg_VPDB = -4., d18Owg_VSMOW = 26.,
    	d13C_VPDB = None, d18O_VPDB = None,
    	D47 = None, D48 = None, D49 = 0., D17O = 0.,
    	a47 = 1., b47 = 0., c47 = -0.9,
    	a48 = 1., b48 = 0., c48 = -0.45,
    	Nominal_D47 = None,
    	Nominal_D48 = None,
    	Nominal_d13C_VPDB = None,
    	Nominal_d18O_VPDB = None,
    	ALPHA_18O_ACID_REACTION = None,
    	R13_VPDB = None,
    	R17_VSMOW = None,
    	R18_VSMOW = None,
    	LAMBDA_17 = None,
    	R18_VPDB = None,
    	):
    	'''
    	Compute working-gas delta values for a single analysis, assuming a stochastic working
    	gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values).

    	**Parameters**

    	+ `sample`: sample name
    	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
    		(respectively –4 and +26 ‰ by default)
    	+ `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
    	+ `D47`, `D48`, `D49`, `D17O`: clumped-isotope and oxygen-17 anomalies
    		of the carbonate sample
    	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and
    		Δ48 values if `D47` or `D48` are not specified
    	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
    		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
    	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
    	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
    		correction parameters (by default equal to the `D4xdata` default values)

    	Returns a dictionary with fields
    	`['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49']`.
    	'''

    	if Nominal_d13C_VPDB is None:
    		Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB

    	if Nominal_d18O_VPDB is None:
    		Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB

    	if ALPHA_18O_ACID_REACTION is None:
    		ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION

    	if R13_VPDB is None:
    		R13_VPDB = D4xdata().R13_VPDB

    	if R17_VSMOW is None:
    		R17_VSMOW = D4xdata().R17_VSMOW

    	if R18_VSMOW is None:
    		R18_VSMOW = D4xdata().R18_VSMOW

    	if LAMBDA_17 is None:
    		LAMBDA_17 = D4xdata().LAMBDA_17

    	if R18_VPDB is None:
    		R18_VPDB = D4xdata().R18_VPDB

    	R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17

    	if Nominal_D47 is None:
    		Nominal_D47 = D47data().Nominal_D47

    	if Nominal_D48 is None:
    		Nominal_D48 = D48data().Nominal_D48

    	if d13C_VPDB is None:
    		if sample in Nominal_d13C_VPDB:
    			d13C_VPDB = Nominal_d13C_VPDB[sample]
    		else:
    			raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.")

    	if d18O_VPDB is None:
    		if sample in Nominal_d18O_VPDB:
    			d18O_VPDB = Nominal_d18O_VPDB[sample]
    		else:
    			raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.")

    	if D47 is None:
    		if sample in Nominal_D47:
    			D47 = Nominal_D47[sample]
    		else:
    			raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.")

    	if D48 is None:
    		if sample in Nominal_D48:
    			D48 = Nominal_D48[sample]
    		else:
    			raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.")

    	X = D4xdata()
    	X.R13_VPDB = R13_VPDB
    	X.R17_VSMOW = R17_VSMOW
    	X.R18_VSMOW = R18_VSMOW
    	X.LAMBDA_17 = LAMBDA_17
    	X.R18_VPDB = R18_VPDB
    	X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17

    	R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios(
    		R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000),
    		R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000),
    		)
    	R45, R46, R47, R48, R49 = X.compute_isobar_ratios(
    		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
    		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
    		D17O=D17O, D47=D47, D48=D48, D49=D49,
    		)
    	R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios(
    		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
    		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
    		D17O=D17O,
    		)

    	d45 = 1000 * (R45/R45wg - 1)
    	d46 = 1000 * (R46/R46wg - 1)
    	d47 = 1000 * (R47/R47wg - 1)
    	d48 = 1000 * (R48/R48wg - 1)
    	d49 = 1000 * (R49/R49wg - 1)

    	for k in range(3): # dumb iteration to adjust for small changes in d47
    		R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch
    		R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch
    		d47 = 1000 * (R47raw/R47wg - 1)
    		d48 = 1000 * (R48raw/R48wg - 1)

    	return dict(
    		Sample = sample,
    		D17O = D17O,
    		d13Cwg_VPDB = d13Cwg_VPDB,
    		d18Owg_VSMOW = d18Owg_VSMOW,
    		d45 = d45,
    		d46 = d46,
    		d47 = d47,
    		d48 = d48,
    		d49 = d49,
    		)

Compute working-gas delta values for a single analysis, assuming a stochastic working gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values).

Parameters

- `sample`: sample name
- `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas (respectively –4 and +26 ‰ by default)
- `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
- `D47`, `D48`, `D49`, `D17O`: clumped-isotope and oxygen-17 anomalies of the carbonate sample
- `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values if `D47` or `D48` are not specified
- `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
- `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
- `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17 correction parameters (by default equal to the `D4xdata` default values)

Returns a dictionary with fields `['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49']`.

def virtual_data( samples=[], a47=1.0, b47=0.0, c47=-0.9, a48=1.0, b48=0.0, c48=-0.45, rD47=0.015, rD48=0.045, d13Cwg_VPDB=None, d18Owg_VSMOW=None, session=None, Nominal_D47=None, Nominal_D48=None, Nominal_d13C_VPDB=None, Nominal_d18O_VPDB=None, ALPHA_18O_ACID_REACTION=None, R13_VPDB=None, R17_VSMOW=None, R18_VSMOW=None, LAMBDA_17=None, R18_VPDB=None, seed=0 ):
View Source

    def virtual_data(
    	samples = [],
    	a47 = 1., b47 = 0., c47 = -0.9,
    	a48 = 1., b48 = 0., c48 = -0.45,
    	rD47 = 0.015, rD48 = 0.045,
    	d13Cwg_VPDB = None, d18Owg_VSMOW = None,
    	session = None,
    	Nominal_D47 = None, Nominal_D48 = None,
    	Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
    	ALPHA_18O_ACID_REACTION = None,
    	R13_VPDB = None,
    	R17_VSMOW = None,
    	R18_VSMOW = None,
    	LAMBDA_17 = None,
    	R18_VPDB = None,
    	seed = 0,
    	):
    	'''
    	Return list with simulated analyses from a single session.

    	**Parameters**

    	+ `samples`: a list of entries; each entry is a dictionary with the following fields:
    	    * `Sample`: the name of the sample
    	    * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
    	    * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
    	    * `N`: how many analyses to generate for this sample
    	+ `a47`: scrambling factor for Δ47
    	+ `b47`: compositional nonlinearity for Δ47
    	+ `c47`: working gas offset for Δ47
    	+ `a48`: scrambling factor for Δ48
    	+ `b48`: compositional nonlinearity for Δ48
    	+ `c48`: working gas offset for Δ48
    	+ `rD47`: analytical repeatability of Δ47
    	+ `rD48`: analytical repeatability of Δ48
    	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
    		(by default equal to the `simulate_single_analysis` default values)
    	+ `session`: name of the session (no name by default)
    	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
    		if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
    	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
    		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
    		(by default equal to the `simulate_single_analysis` defaults)
    	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
    		(by default equal to the `simulate_single_analysis` defaults)
    	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
    		correction parameters (by default equal to the `simulate_single_analysis` default)
    	+ `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations

    	Here is an example of using this method to generate an arbitrary combination of
    	anchors and unknowns for a bunch of sessions:

    	```py
    	args = dict(
    		samples = [
    			dict(Sample = 'ETH-1', N = 4),
    			dict(Sample = 'ETH-2', N = 5),
    			dict(Sample = 'ETH-3', N = 6),
    			dict(Sample = 'FOO', N = 2,
    				d13C_VPDB = -5., d18O_VPDB = -10.,
    				D47 = 0.3, D48 = 0.15),
    			], rD47 = 0.010, rD48 = 0.030)

    	session1 = virtual_data(session = 'Session_01', **args, seed = 123)
    	session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
    	session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
    	session4 = virtual_data(session = 'Session_04', **args, seed = 123456)

    	D = D47data(session1 + session2 + session3 + session4)

    	D.crunch()
    	D.standardize()

    	D.table_of_sessions(verbose = True, save_to_file = False)
    	D.table_of_samples(verbose = True, save_to_file = False)
    	D.table_of_analyses(verbose = True, save_to_file = False)
    	```

    	This should output something like:

    	```
    	[table_of_sessions]
    	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    	Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
    	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    	Session_01  15   2       -4.000        26.000  0.0000  0.0000  0.0110  0.997 ± 0.017  -0.097 ± 0.244  -0.896 ± 0.006
    	Session_02  15   2       -4.000        26.000  0.0000  0.0000  0.0109  1.002 ± 0.017  -0.110 ± 0.244  -0.901 ± 0.006
    	Session_03  15   2       -4.000        26.000  0.0000  0.0000  0.0107  1.010 ± 0.017  -0.037 ± 0.244  -0.904 ± 0.006
    	Session_04  15   2       -4.000        26.000  0.0000  0.0000  0.0106  1.001 ± 0.017  -0.181 ± 0.244  -0.894 ± 0.006
    	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––

    	[table_of_samples]
    	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    	Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
    	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    	ETH-1   16       2.02       37.02  0.2052                    0.0079
    	ETH-2   20     -10.17       19.88  0.2085                    0.0100
    	ETH-3   24       1.71       37.45  0.6132                    0.0105
    	FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
    	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––

    	[table_of_analyses]
    	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    	UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
    	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    	1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.122986   21.273526   27.780042    2.020000   37.024281  -0.706013  -0.328878  -0.000013  0.192554
    	2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.130144   21.282615   27.780042    2.020000   37.024281  -0.698974  -0.319981  -0.000013  0.199615
    	3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.149219   21.299572   27.780042    2.020000   37.024281  -0.680215  -0.303383  -0.000013  0.218429
    	4    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.136616   21.233128   27.780042    2.020000   37.024281  -0.692609  -0.368421  -0.000013  0.205998
    	5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.697171  -12.203054  -18.023381  -10.170000   19.875825  -0.680771  -0.290128  -0.000002  0.215054
    	6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701124  -12.184422  -18.023381  -10.170000   19.875825  -0.684772  -0.271272  -0.000002  0.211041
    	7    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715105  -12.195251  -18.023381  -10.170000   19.875825  -0.698923  -0.282232  -0.000002  0.196848
    	8    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701529  -12.204963  -18.023381  -10.170000   19.875825  -0.685182  -0.292061  -0.000002  0.210630
    	9    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.711420  -12.228478  -18.023381  -10.170000   19.875825  -0.695193  -0.315859  -0.000002  0.200589
    	10   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.666719   22.296486   28.306614    1.710000   37.450394  -0.290459  -0.147284  -0.000014  0.609363
    	11   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.671553   22.291060   28.306614    1.710000   37.450394  -0.285706  -0.152592  -0.000014  0.614130
    	12   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.652854   22.273271   28.306614    1.710000   37.450394  -0.304093  -0.169990  -0.000014  0.595689
    	13   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684168   22.263156   28.306614    1.710000   37.450394  -0.273302  -0.179883  -0.000014  0.626572
    	14   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.662702   22.253578   28.306614    1.710000   37.450394  -0.294409  -0.189251  -0.000014  0.605401
    	15   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.681957   22.230907   28.306614    1.710000   37.450394  -0.275476  -0.211424  -0.000014  0.624391
    	16   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.312044    5.395798    4.665655   -5.000000   28.907344  -0.598436  -0.268176  -0.000006  0.298996
    	17   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328123    5.307086    4.665655   -5.000000   28.907344  -0.582387  -0.356389  -0.000006  0.315092
    	18   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.122201   21.340606   27.780042    2.020000   37.024281  -0.706785  -0.263217  -0.000013  0.195135
    	19   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.134868   21.305714   27.780042    2.020000   37.024281  -0.694328  -0.297370  -0.000013  0.207564
    	20   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140008   21.261931   27.780042    2.020000   37.024281  -0.689273  -0.340227  -0.000013  0.212607
    	21   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.135540   21.298472   27.780042    2.020000   37.024281  -0.693667  -0.304459  -0.000013  0.208224
    	22   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701213  -12.202602  -18.023381  -10.170000   19.875825  -0.684862  -0.289671  -0.000002  0.213842
    	23   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.685649  -12.190405  -18.023381  -10.170000   19.875825  -0.669108  -0.277327  -0.000002  0.229559
    	24   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.719003  -12.257955  -18.023381  -10.170000   19.875825  -0.702869  -0.345692  -0.000002  0.195876
    	25   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700592  -12.204641  -18.023381  -10.170000   19.875825  -0.684233  -0.291735  -0.000002  0.214469
    	26   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720426  -12.214561  -18.023381  -10.170000   19.875825  -0.704308  -0.301774  -0.000002  0.194439
    	27   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.673044   22.262090   28.306614    1.710000   37.450394  -0.284240  -0.180926  -0.000014  0.616730
    	28   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.666542   22.263401   28.306614    1.710000   37.450394  -0.290634  -0.179643  -0.000014  0.610350
    	29   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.680487   22.243486   28.306614    1.710000   37.450394  -0.276921  -0.199121  -0.000014  0.624031
    	30   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.663900   22.245175   28.306614    1.710000   37.450394  -0.293231  -0.197469  -0.000014  0.607759
    	31   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.674379   22.301309   28.306614    1.710000   37.450394  -0.282927  -0.142568  -0.000014  0.618039
    	32   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.660825   22.270466   28.306614    1.710000   37.450394  -0.296255  -0.172733  -0.000014  0.604742
    	33   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.294076    5.349940    4.665655   -5.000000   28.907344  -0.616369  -0.313776  -0.000006  0.283707
    	34   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.313775    5.292121    4.665655   -5.000000   28.907344  -0.596708  -0.371269  -0.000006  0.303323
    	35   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.121613   21.259909   27.780042    2.020000   37.024281  -0.707364  -0.342207  -0.000013  0.194934
    	36   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.145714   21.304889   27.780042    2.020000   37.024281  -0.683661  -0.298178  -0.000013  0.218401
    	37   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.126573   21.325093   27.780042    2.020000   37.024281  -0.702485  -0.278401  -0.000013  0.199764
    	38   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.132057   21.323211   27.780042    2.020000   37.024281  -0.697092  -0.280244  -0.000013  0.205104
    	39   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.708448  -12.232023  -18.023381  -10.170000   19.875825  -0.692185  -0.319447  -0.000002  0.208915
    	40   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.714417  -12.202504  -18.023381  -10.170000   19.875825  -0.698226  -0.289572  -0.000002  0.202934
    	41   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720039  -12.264469  -18.023381  -10.170000   19.875825  -0.703917  -0.352285  -0.000002  0.197300
    	42   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701953  -12.228550  -18.023381  -10.170000   19.875825  -0.685611  -0.315932  -0.000002  0.215423
    	43   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.704535  -12.213634  -18.023381  -10.170000   19.875825  -0.688224  -0.300836  -0.000002  0.212837
    	44   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.652920   22.230043   28.306614    1.710000   37.450394  -0.304028  -0.212269  -0.000014  0.594265
    	45   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.691485   22.261017   28.306614    1.710000   37.450394  -0.266106  -0.181975  -0.000014  0.631810
    	46   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.679119   22.305357   28.306614    1.710000   37.450394  -0.278266  -0.138609  -0.000014  0.619771
    	47   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.663623   22.327286   28.306614    1.710000   37.450394  -0.293503  -0.117161  -0.000014  0.604685
    	48   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.678524   22.282103   28.306614    1.710000   37.450394  -0.278851  -0.161352  -0.000014  0.619192
    	49   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.666246   22.283361   28.306614    1.710000   37.450394  -0.290925  -0.160121  -0.000014  0.607238
    	50   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.309929    5.340249    4.665655   -5.000000   28.907344  -0.600546  -0.323413  -0.000006  0.300148
    	51   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.317548    5.334102    4.665655   -5.000000   28.907344  -0.592942  -0.329524  -0.000006  0.307676
    	52   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.136865   21.300298   27.780042    2.020000   37.024281  -0.692364  -0.302672  -0.000013  0.204033
    	53   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.133538   21.291260   27.780042    2.020000   37.024281  -0.695637  -0.311519  -0.000013  0.200762
    	54   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.139991   21.319865   27.780042    2.020000   37.024281  -0.689290  -0.283519  -0.000013  0.207107
    	55   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.145748   21.330075   27.780042    2.020000   37.024281  -0.683629  -0.273524  -0.000013  0.212766
    	56   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702989  -12.202762  -18.023381  -10.170000   19.875825  -0.686660  -0.289833  -0.000002  0.204507
    	57   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.692830  -12.240287  -18.023381  -10.170000   19.875825  -0.676377  -0.327811  -0.000002  0.214786
    	58   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702899  -12.180291  -18.023381  -10.170000   19.875825  -0.686568  -0.267091  -0.000002  0.204598
    	59   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709282  -12.282257  -18.023381  -10.170000   19.875825  -0.693029  -0.370287  -0.000002  0.198140
    	60   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.679330  -12.235994  -18.023381  -10.170000   19.875825  -0.662712  -0.323466  -0.000002  0.228446
    	61   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.695594   22.238663   28.306614    1.710000   37.450394  -0.262066  -0.203838  -0.000014  0.634200
    	62   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.663504   22.286354   28.306614    1.710000   37.450394  -0.293620  -0.157194  -0.000014  0.602656
    	63   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666457   22.254290   28.306614    1.710000   37.450394  -0.290717  -0.188555  -0.000014  0.605558
    	64   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666910   22.223232   28.306614    1.710000   37.450394  -0.290271  -0.218930  -0.000014  0.606004
    	65   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.679662   22.257256   28.306614    1.710000   37.450394  -0.277732  -0.185653  -0.000014  0.618539
    	66   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.676768   22.267680   28.306614    1.710000   37.450394  -0.280578  -0.175459  -0.000014  0.615693
    	67   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.307663    5.317330    4.665655   -5.000000   28.907344  -0.602808  -0.346202  -0.000006  0.290853
    	68   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.308562    5.331400    4.665655   -5.000000   28.907344  -0.601911  -0.332212  -0.000006  0.291749
    	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    	```
    	'''

    	kwargs = locals().copy()

    	from numpy import random as nprandom
    	if seed:
    		rng = nprandom.default_rng(seed)
    	else:
    		rng = nprandom.default_rng()

    	N = sum([s['N'] for s in samples])
    	errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    	errors47 *= rD47 / stdev(errors47) # scale errors to rD47
    	errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    	errors48 *= rD48 / stdev(errors48) # scale errors to rD48

    	k = 0
    	out = []
    	for s in samples:
    		kw = {}
    		kw['sample'] = s['Sample']
    		kw = {
    			**kw,
    			**{var: kwargs[var]
    				for var in [
    					'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
    					'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
    					'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
    					'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
    					]
    				if kwargs[var] is not None},
    			**{var: s[var]
    				for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
    				if var in s},
    			}

    		sN = s['N']
    		while sN:
    			out.append(simulate_single_analysis(**kw))
    			out[-1]['d47'] += errors47[k] * a47
    			out[-1]['d48'] += errors48[k] * a48
    			sN -= 1
    			k += 1

    		if session is not None:
    			for r in out:
    				r['Session'] = session
    	return out

Return list with simulated analyses from a single session.

Parameters

- `samples`: a list of entries; each entry is a dictionary with the following fields:
  - `Sample`: the name of the sample
  - `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
  - `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
  - `N`: how many analyses to generate for this sample
- `a47`: scrambling factor for Δ47
- `b47`: compositional nonlinearity for Δ47
- `c47`: working gas offset for Δ47
- `a48`: scrambling factor for Δ48
- `b48`: compositional nonlinearity for Δ48
- `c48`: working gas offset for Δ48
- `rD47`: analytical repeatability of Δ47
- `rD48`: analytical repeatability of Δ48
- `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas (by default equal to the `simulate_single_analysis` default values)
- `session`: name of the session (no name by default)
- `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
- `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified (by default equal to the `simulate_single_analysis` defaults)
- `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor (by default equal to the `simulate_single_analysis` defaults)
- `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17 correction parameters (by default equal to the `simulate_single_analysis` default)
- `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations

Here is an example of using this method to generate an arbitrary combination of anchors and unknowns for a bunch of sessions:

    args = dict(
            samples = [
                    dict(Sample = 'ETH-1', N = 4),
                    dict(Sample = 'ETH-2', N = 5),
                    dict(Sample = 'ETH-3', N = 6),
                    dict(Sample = 'FOO', N = 2,
                            d13C_VPDB = -5., d18O_VPDB = -10.,
                            D47 = 0.3, D48 = 0.15),
                    ], rD47 = 0.010, rD48 = 0.030)

    session1 = virtual_data(session = 'Session_01', **args, seed = 123)
    session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
    session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
    session4 = virtual_data(session = 'Session_04', **args, seed = 123456)

    D = D47data(session1 + session2 + session3 + session4)

    D.crunch()
    D.standardize()

    D.table_of_sessions(verbose = True, save_to_file = False)
    D.table_of_samples(verbose = True, save_to_file = False)
    D.table_of_analyses(verbose = True, save_to_file = False)

This should output something like:

    [table_of_sessions]
    ––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
    ––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    Session_01  15   2       -4.000        26.000  0.0000  0.0000  0.0110  0.997 ± 0.017  -0.097 ± 0.244  -0.896 ± 0.006
    Session_02  15   2       -4.000        26.000  0.0000  0.0000  0.0109  1.002 ± 0.017  -0.110 ± 0.244  -0.901 ± 0.006
    Session_03  15   2       -4.000        26.000  0.0000  0.0000  0.0107  1.010 ± 0.017  -0.037 ± 0.244  -0.904 ± 0.006
    Session_04  15   2       -4.000        26.000  0.0000  0.0000  0.0106  1.001 ± 0.017  -0.181 ± 0.244  -0.894 ± 0.006
    ––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––

    [table_of_samples]
    ––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
    ––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    ETH-1   16       2.02       37.02  0.2052                    0.0079
    ETH-2   20     -10.17       19.88  0.2085                    0.0100
    ETH-3   24       1.71       37.45  0.6132                    0.0105
    FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
    ––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––

    [table_of_analyses]
    –––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
    –––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.122986   21.273526   27.780042    2.020000   37.024281  -0.706013  -0.328878  -0.000013  0.192554
    2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.130144   21.282615   27.780042    2.020000   37.024281  -0.698974  -0.319981  -0.000013  0.199615
    3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.149219   21.299572   27.780042    2.020000   37.024281  -0.680215  -0.303383  -0.000013  0.218429
    4    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.136616   21.233128   27.780042    2.020000   37.024281  -0.692609  -0.368421  -0.000013  0.205998
    5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.697171  -12.203054  -18.023381  -10.170000   19.875825  -0.680771  -0.290128  -0.000002  0.215054
    6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701124  -12.184422  -18.023381  -10.170000   19.875825  -0.684772  -0.271272  -0.000002  0.211041
    7    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715105  -12.195251  -18.023381  -10.170000   19.875825  -0.698923  -0.282232  -0.000002  0.196848
    8    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701529  -12.204963  -18.023381  -10.170000   19.875825  -0.685182  -0.292061  -0.000002  0.210630
    9    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.711420  -12.228478  -18.023381  -10.170000   19.875825  -0.695193  -0.315859  -0.000002  0.200589
    10   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.666719   22.296486   28.306614    1.710000   37.450394  -0.290459  -0.147284  -0.000014  0.609363
    11   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.671553   22.291060   28.306614    1.710000   37.450394  -0.285706  -0.152592  -0.000014  0.614130
    12   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.652854   22.273271   28.306614    1.710000   37.450394  -0.304093  -0.169990  -0.000014  0.595689
    13   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684168   22.263156   28.306614    1.710000   37.450394  -0.273302  -0.179883  -0.000014  0.626572
    14   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.662702   22.253578   28.306614    1.710000   37.450394  -0.294409  -0.189251  -0.000014  0.605401
    15   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.681957   22.230907   28.306614    1.710000   37.450394  -0.275476  -0.211424  -0.000014  0.624391
    16   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.312044    5.395798    4.665655   -5.000000   28.907344  -0.598436  -0.268176  -0.000006  0.298996
    17   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328123    5.307086    4.665655   -5.000000   28.907344  -0.582387  -0.356389  -0.000006  0.315092
    18   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.122201   21.340606   27.780042    2.020000   37.024281  -0.706785  -0.263217  -0.000013  0.195135
    19   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.134868   21.305714   27.780042    2.020000   37.024281  -0.694328  -0.297370  -0.000013  0.207564
    20   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140008   21.261931   27.780042    2.020000   37.024281  -0.689273  -0.340227  -0.000013  0.212607
    21   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.135540   21.298472   27.780042    2.020000   37.024281  -0.693667  -0.304459  -0.000013  0.208224
    22   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701213  -12.202602  -18.023381  -10.170000   19.875825  -0.684862  -0.289671  -0.000002  0.213842
    23   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.685649  -12.190405  -18.023381  -10.170000   19.875825  -0.669108  -0.277327  -0.000002  0.229559
    24   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.719003  -12.257955  -18.023381  -10.170000   19.875825  -0.702869  -0.345692  -0.000002  0.195876
    25   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700592  -12.204641  -18.023381  -10.170000   19.875825  -0.684233  -0.291735  -0.000002  0.214469
    26   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720426  -12.214561  -18.023381  -10.170000   19.875825  -0.704308  -0.301774  -0.000002  0.194439
    27   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.673044   22.262090   28.306614    1.710000   37.450394  -0.284240  -0.180926  -0.000014  0.616730
    28   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.666542   22.263401   28.306614    1.710000   37.450394  -0.290634  -0.179643  -0.000014  0.610350
    29   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.680487   22.243486   28.306614    1.710000   37.450394  -0.276921  -0.199121  -0.000014  0.624031
    30   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.663900   22.245175   28.306614    1.710000   37.450394  -0.293231  -0.197469  -0.000014  0.607759
    31   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.674379   22.301309   28.306614    1.710000   37.450394  -0.282927  -0.142568  -0.000014  0.618039
    32   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.660825   22.270466   28.306614    1.710000   37.450394  -0.296255  -0.172733  -0.000014  0.604742
    33   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.294076    5.349940    4.665655   -5.000000   28.907344  -0.616369  -0.313776  -0.000006  0.283707
    34   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.313775    5.292121    4.665655   -5.000000   28.907344  -0.596708  -0.371269  -0.000006  0.303323
    35   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.121613   21.259909   27.780042    2.020000   37.024281  -0.707364  -0.342207  -0.000013  0.194934
    36   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.145714   21.304889   27.780042    2.020000   37.024281  -0.683661  -0.298178  -0.000013  0.218401
    37   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.126573   21.325093   27.780042    2.020000   37.024281  -0.702485  -0.278401  -0.000013  0.199764
    38   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.132057   21.323211   27.780042    2.020000   37.024281  -0.697092  -0.280244  -0.000013  0.205104
    39   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.708448  -12.232023  -18.023381  -10.170000   19.875825  -0.692185  -0.319447  -0.000002  0.208915
    40   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.714417  -12.202504  -18.023381  -10.170000   19.875825  -0.698226  -0.289572  -0.000002  0.202934
    41   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720039  -12.264469  -18.023381  -10.170000   19.875825  -0.703917  -0.352285  -0.000002  0.197300
    42   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701953  -12.228550  -18.023381  -10.170000   19.875825  -0.685611  -0.315932  -0.000002  0.215423
    43   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.704535  -12.213634  -18.023381  -10.170000   19.875825  -0.688224  -0.300836  -0.000002  0.212837
    44   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.652920   22.230043   28.306614    1.710000   37.450394  -0.304028  -0.212269  -0.000014  0.594265
    45   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.691485   22.261017   28.306614    1.710000   37.450394  -0.266106  -0.181975  -0.000014  0.631810
    46   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.679119   22.305357   28.306614    1.710000   37.450394  -0.278266  -0.138609  -0.000014  0.619771
    47   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.663623   22.327286   28.306614    1.710000   37.450394  -0.293503  -0.117161  -0.000014  0.604685
    48   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.678524   22.282103   28.306614    1.710000   37.450394  -0.278851  -0.161352  -0.000014  0.619192
    49   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.666246   22.283361   28.306614    1.710000   37.450394  -0.290925  -0.160121  -0.000014  0.607238
    50   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.309929    5.340249    4.665655   -5.000000   28.907344  -0.600546  -0.323413  -0.000006  0.300148
    51   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.317548    5.334102    4.665655   -5.000000   28.907344  -0.592942  -0.329524  -0.000006  0.307676
    52   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.136865   21.300298   27.780042    2.020000   37.024281  -0.692364  -0.302672  -0.000013  0.204033
    53   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.133538   21.291260   27.780042    2.020000   37.024281  -0.695637  -0.311519  -0.000013  0.200762
    54   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.139991   21.319865   27.780042    2.020000   37.024281  -0.689290  -0.283519  -0.000013  0.207107
    55   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.145748   21.330075   27.780042    2.020000   37.024281  -0.683629  -0.273524  -0.000013  0.212766
    56   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702989  -12.202762  -18.023381  -10.170000   19.875825  -0.686660  -0.289833  -0.000002  0.204507
    57   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.692830  -12.240287  -18.023381  -10.170000   19.875825  -0.676377  -0.327811  -0.000002  0.214786
    58   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702899  -12.180291  -18.023381  -10.170000   19.875825  -0.686568  -0.267091  -0.000002  0.204598
    59   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709282  -12.282257  -18.023381  -10.170000   19.875825  -0.693029  -0.370287  -0.000002  0.198140
    60   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.679330  -12.235994  -18.023381  -10.170000   19.875825  -0.662712  -0.323466  -0.000002  0.228446
    61   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.695594   22.238663   28.306614    1.710000   37.450394  -0.262066  -0.203838  -0.000014  0.634200
    62   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.663504   22.286354   28.306614    1.710000   37.450394  -0.293620  -0.157194  -0.000014  0.602656
    63   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666457   22.254290   28.306614    1.710000   37.450394  -0.290717  -0.188555  -0.000014  0.605558
    64   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666910   22.223232   28.306614    1.710000   37.450394  -0.290271  -0.218930  -0.000014  0.606004
    65   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.679662   22.257256   28.306614    1.710000   37.450394  -0.277732  -0.185653  -0.000014  0.618539
    66   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.676768   22.267680   28.306614    1.710000   37.450394  -0.280578  -0.175459  -0.000014  0.615693
    67   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.307663    5.317330    4.665655   -5.000000   28.907344  -0.602808  -0.346202  -0.000006  0.290853
    68   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.308562    5.331400    4.665655   -5.000000   28.907344  -0.601911  -0.332212  -0.000006  0.291749
    –––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––

def table_of_samples( data47=None, data48=None, dir='output', filename=None, save_to_file=True, print_out=True, output=None ):
View Source

    def table_of_samples(
    	data47 = None,
    	data48 = None,
    	dir = 'output',
    	filename = None,
    	save_to_file = True,
    	print_out = True,
    	output = None,
    	):
    	'''
    	Print out, save to disk and/or return a combined table of samples
    	for a pair of `D47data` and `D48data` objects.

    	**Parameters**

    	+ `data47`: `D47data` instance
    	+ `data48`: `D48data` instance
    	+ `dir`: the directory in which to save the table
    	+ `filename`: the name to the csv file to write to
    	+ `save_to_file`: whether to save the table to disk
    	+ `print_out`: whether to print out the table
    	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		if set to `'raw'`: return a list of list of strings
    		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    	'''
    	if data47 is None:
    		if data48 is None:
    			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
    		else:
    			return data48.table_of_samples(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    	else:
    		if data48 is None:
    			return data47.table_of_samples(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    		else:
    			out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
    			out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
    			out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:])

    			if save_to_file:
    				if not os.path.exists(dir):
    					os.makedirs(dir)
    				if filename is None:
    					filename = f'D47D48_samples.csv'
    				with open(f'{dir}/{filename}', 'w') as fid:
    					fid.write(make_csv(out))
    			if print_out:
    				print('\n'+pretty_table(out))
    			if output == 'raw':
    				return out
    			elif output == 'pretty':
    				return pretty_table(out)

Print out, save to disk and/or return a combined table of samples for a pair of `D47data` and `D48data` objects.

Parameters

- `data47`: `D47data` instance
- `data48`: `D48data` instance
- `dir`: the directory in which to save the table
- `filename`: the name to the csv file to write to
- `save_to_file`: whether to save the table to disk
- `print_out`: whether to print out the table
- `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); if set to `'raw'`: return a list of list of strings (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)

def table_of_sessions( data47=None, data48=None, dir='output', filename=None, save_to_file=True, print_out=True, output=None ):
View Source

    def table_of_sessions(
    	data47 = None,
    	data48 = None,
    	dir = 'output',
    	filename = None,
    	save_to_file = True,
    	print_out = True,
    	output = None,
    	):
    	'''
    	Print out, save to disk and/or return a combined table of sessions
    	for a pair of `D47data` and `D48data` objects.
    	***Only applicable if the sessions in `data47` and those in `data48`
    	consist of the exact same sets of analyses.***

    	**Parameters**

    	+ `data47`: `D47data` instance
    	+ `data48`: `D48data` instance
    	+ `dir`: the directory in which to save the table
    	+ `filename`: the name to the csv file to write to
    	+ `save_to_file`: whether to save the table to disk
    	+ `print_out`: whether to print out the table
    	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		if set to `'raw'`: return a list of list of strings
    		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    	'''
    	if data47 is None:
    		if data48 is None:
    			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
    		else:
    			return data48.table_of_sessions(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    	else:
    		if data48 is None:
    			return data47.table_of_sessions(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    		else:
    			out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
    			out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
    			for k,x in enumerate(out47[0]):
    				if k>7:
    					out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47')
    					out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48')
    			out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:])

    			if save_to_file:
    				if not os.path.exists(dir):
    					os.makedirs(dir)
    				if filename is None:
    					filename = f'D47D48_sessions.csv'
    				with open(f'{dir}/{filename}', 'w') as fid:
    					fid.write(make_csv(out))
    			if print_out:
    				print('\n'+pretty_table(out))
    			if output == 'raw':
    				return out
    			elif output == 'pretty':
    				return pretty_table(out)

Print out, save to disk and/or return a combined table of sessions for a pair of `D47data` and `D48data` objects. Only applicable if the sessions in `data47` and those in `data48` consist of the exact same sets of analyses.

Parameters

- `data47`: `D47data` instance
- `data48`: `D48data` instance
- `dir`: the directory in which to save the table
- `filename`: the name to the csv file to write to
- `save_to_file`: whether to save the table to disk
- `print_out`: whether to print out the table
- `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); if set to `'raw'`: return a list of list of strings (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)

def table_of_analyses( data47=None, data48=None, dir='output', filename=None, save_to_file=True, print_out=True, output=None ):
View Source

    def table_of_analyses(
    	data47 = None,
    	data48 = None,
    	dir = 'output',
    	filename = None,
    	save_to_file = True,
    	print_out = True,
    	output = None,
    	):
    	'''
    	Print out, save to disk and/or return a combined table of analyses
    	for a pair of `D47data` and `D48data` objects.

    	If the sessions in `data47` and those in `data48` do not consist of
    	the exact same sets of analyses, the table will have two columns
    	`Session_47` and `Session_48` instead of a single `Session` column.

    	**Parameters**

    	+ `data47`: `D47data` instance
    	+ `data48`: `D48data` instance
    	+ `dir`: the directory in which to save the table
    	+ `filename`: the name to the csv file to write to
    	+ `save_to_file`: whether to save the table to disk
    	+ `print_out`: whether to print out the table
    	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		if set to `'raw'`: return a list of list of strings
    		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    	'''
    	if data47 is None:
    		if data48 is None:
    			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
    		else:
    			return data48.table_of_analyses(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    	else:
    		if data48 is None:
    			return data47.table_of_analyses(
    				dir = dir,
    				filename = filename,
    				save_to_file = save_to_file,
    				print_out = print_out,
    				output = output
    				)
    		else:
    			out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
    			out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')

    			if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical
    				out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:])
    			else:
    				out47[0][1] = 'Session_47'
    				out48[0][1] = 'Session_48'
    				out47 = transpose_table(out47)
    				out48 = transpose_table(out48)
    				out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:])

    			if save_to_file:
    				if not os.path.exists(dir):
    					os.makedirs(dir)
    				if filename is None:
    					filename = f'D47D48_sessions.csv'
    				with open(f'{dir}/{filename}', 'w') as fid:
    					fid.write(make_csv(out))
    			if print_out:
    				print('\n'+pretty_table(out))
    			if output == 'raw':
    				return out
    			elif output == 'pretty':
    				return pretty_table(out)

Print out, save to disk and/or return a combined table of analyses for a pair of `D47data` and `D48data` objects.

If the sessions in `data47` and those in `data48` do not consist of the exact same sets of analyses, the table will have two columns `Session_47` and `Session_48` instead of a single `Session` column.

Parameters

- `data47`: `D47data` instance
- `data48`: `D48data` instance
- `dir`: the directory in which to save the table
- `filename`: the name to the csv file to write to
- `save_to_file`: whether to save the table to disk
- `print_out`: whether to print out the table
- `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); if set to `'raw'`: return a list of list of strings (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)

class D4xdata(builtins.list):
View Source

    class D4xdata(list):
    	'''
    	Store and process data for a large set of Δ47 and/or Δ48
    	analyses, usually comprising more than one analytical session.
    	'''

    	### 17O CORRECTION PARAMETERS
    	R13_VPDB = 0.01118  # (Chang & Li, 1990)
    	'''
    	Absolute (13C/12C) ratio of VPDB.
    	By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm))
    	'''

    	R18_VSMOW = 0.0020052  # (Baertschi, 1976)
    	'''
    	Absolute (18O/16C) ratio of VSMOW.
    	By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1))
    	'''

    	LAMBDA_17 = 0.528  # (Barkan & Luz, 2005)
    	'''
    	Mass-dependent exponent for triple oxygen isotopes.
    	By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250))
    	'''

    	R17_VSMOW = 0.00038475  # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB)
    	'''
    	Absolute (17O/16C) ratio of VSMOW.
    	By default equal to 0.00038475
    	([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011),
    	rescaled to `R13_VPDB`)
    	'''

    	R18_VPDB = R18_VSMOW * 1.03092
    	'''
    	Absolute (18O/16C) ratio of VPDB.
    	By definition equal to `R18_VSMOW * 1.03092`.
    	'''

    	R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17
    	'''
    	Absolute (17O/16C) ratio of VPDB.
    	By definition equal to `R17_VSMOW * 1.03092 ** LAMBDA_17`.
    	'''

    	LEVENE_REF_SAMPLE = 'ETH-3'
    	'''
    	After the Δ4x standardization step, each sample is tested to
    	assess whether the Δ4x variance within all analyses for that
    	sample differs significantly from that observed for a given reference
    	sample (using [Levene's test](https://en.wikipedia.org/wiki/Levene%27s_test),
    	which yields a p-value corresponding to the null hypothesis that the
    	underlying variances are equal).

    	`LEVENE_REF_SAMPLE` (by default equal to `'ETH-3'`) specifies which
    	sample should be used as a reference for this test.
    	'''

    	ALPHA_18O_ACID_REACTION = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6)  # (Kim et al., 2007, calcite)
    	'''
    	Specifies the 18O/16O fractionation factor generally applicable
    	to acid reactions in the dataset. Currently used by `D4xdata.wg()`,
    	`D4xdata.standardize_d13C`, and `D4xdata.standardize_d18O`.

    	By default equal to 1.008129 (calcite reacted at 90 °C,
    	[Kim et al., 2007](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)).
    	'''

    	Nominal_d13C_VPDB = {
    		'ETH-1': 2.02,
    		'ETH-2': -10.17,
    		'ETH-3': 1.71,
    		}	# (Bernasconi et al., 2018)
    	'''
    	Nominal δ13C_VPDB values assigned to carbonate standards, used by
    	`D4xdata.standardize_d13C()`.

    	By default equal to `{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}` after
    	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
    	'''

    	Nominal_d18O_VPDB = {
    		'ETH-1': -2.19,
    		'ETH-2': -18.69,
    		'ETH-3': -1.78,
    		}	# (Bernasconi et al., 2018)
    	'''
    	Nominal δ18O_VPDB values assigned to carbonate standards, used by
    	`D4xdata.standardize_d18O()`.

    	By default equal to `{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}` after
    	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
    	'''

    	d13C_STANDARDIZATION_METHOD = '2pt'
    	'''
    	Method by which to standardize δ13C values:

    	+ `none`: do not apply any δ13C standardization.
    	+ `'1pt'`: within each session, offset all initial δ13C values so as to
    	minimize the difference between final δ13C_VPDB values and
    	`Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined).
    	+ `'2pt'`: within each session, apply a affine trasformation to all δ13C
    	values so as to minimize the difference between final δ13C_VPDB
    	values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB`
    	is defined).
    	'''

    	d18O_STANDARDIZATION_METHOD = '2pt'
    	'''
    	Method by which to standardize δ18O values:

    	+ `none`: do not apply any δ18O standardization.
    	+ `'1pt'`: within each session, offset all initial δ18O values so as to
    	minimize the difference between final δ18O_VPDB values and
    	`Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined).
    	+ `'2pt'`: within each session, apply a affine trasformation to all δ18O
    	values so as to minimize the difference between final δ18O_VPDB
    	values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB`
    	is defined).
    	'''

    	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
    		'''
    		**Parameters**

    		+ `l`: a list of dictionaries, with each dictionary including at least the keys
    		`Sample`, `d45`, `d46`, and `d47` or `d48`.
    		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
    		+ `session`: define session name for analyses without a `Session` key
    		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.

    		Returns a `D4xdata` object derived from `list`.
    		'''
    		self._4x = mass
    		self.verbose = verbose
    		self.prefix = 'D4xdata'
    		self.logfile = logfile
    		list.__init__(self, l)
    		self.Nf = None
    		self.repeatability = {}
    		self.refresh(session = session)

    	def make_verbal(oldfun):
    		'''
    		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
    		'''
    		@wraps(oldfun)
    		def newfun(*args, verbose = '', **kwargs):
    			myself = args[0]
    			oldprefix = myself.prefix
    			myself.prefix = oldfun.__name__
    			if verbose != '':
    				oldverbose = myself.verbose
    				myself.verbose = verbose
    			out = oldfun(*args, **kwargs)
    			myself.prefix = oldprefix
    			if verbose != '':
    				myself.verbose = oldverbose
    			return out
    		return newfun

    	def msg(self, txt):
    		'''
    		Log a message to `self.logfile`, and print it out if `verbose = True`
    		'''
    		self.log(txt)
    		if self.verbose:
    			print(f'{f"[{self.prefix}]":<16} {txt}')

    	def vmsg(self, txt):
    		'''
    		Log a message to `self.logfile` and print it out
    		'''
    		self.log(txt)
    		print(txt)

    	def log(self, *txts):
    		'''
    		Log a message to `self.logfile`
    		'''
    		if self.logfile:
    			with open(self.logfile, 'a') as fid:
    				for txt in txts:
    					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')

    	def refresh(self, session = 'mySession'):
    		'''
    		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    		'''
    		self.fill_in_missing_info(session = session)
    		self.refresh_sessions()
    		self.refresh_samples()

    	def refresh_sessions(self):
    		'''
    		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    		to `False` for all sessions.
    		'''
    		self.sessions = {
    			s: {'data': [r for r in self if r['Session'] == s]}
    			for s in sorted({r['Session'] for r in self})
    			}
    		for s in self.sessions:
    			self.sessions[s]['scrambling_drift'] = False
    			self.sessions[s]['slope_drift'] = False
    			self.sessions[s]['wg_drift'] = False
    			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD

    	def refresh_samples(self):
    		'''
    		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    		'''
    		self.samples = {
    			s: {'data': [r for r in self if r['Sample'] == s]}
    			for s in sorted({r['Sample'] for r in self})
    			}
    		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}

    	def read(self, filename, sep = '', session = ''):
    		'''
    		Read file in csv format to load data into a `D47data` object.

    		In the csv file, spaces before and after field separators (`','` by default)
    		are optional. Each line corresponds to a single analysis.

    		The required fields are:

    		+ `UID`: a unique identifier
    		+ `Session`: an identifier for the analytical session
    		+ `Sample`: a sample identifier
    		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values

    		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    		and `d49` are optional, and set to NaN by default.

    		**Parameters**

    		+ `fileneme`: the path of the file to read
    		+ `sep`: csv separator delimiting the fields
    		+ `session`: set `Session` field to this string for all analyses
    		'''
    		with open(filename) as fid:
    			self.input(fid.read(), sep = sep, session = session)

    	def input(self, txt, sep = '', session = ''):
    		'''
    		Read `txt` string in csv format to load analysis data into a `D47data` object.

    		In the csv string, spaces before and after field separators (`','` by default)
    		are optional. Each line corresponds to a single analysis.

    		The required fields are:

    		+ `UID`: a unique identifier
    		+ `Session`: an identifier for the analytical session
    		+ `Sample`: a sample identifier
    		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values

    		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    		and `d49` are optional, and set to NaN by default.

    		**Parameters**

    		+ `txt`: the csv string to read
    		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    		whichever appers most often in `txt`.
    		+ `session`: set `Session` field to this string for all analyses
    		'''
    		if sep == '':
    			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    		data = [{k: v if k in ['UID', 'Session', 'Sample'] else smart_type(v) for k,v in zip(txt[0], l) if v != ''} for l in txt[1:]]

    		if session != '':
    			for r in data:
    				r['Session'] = session

    		self += data
    		self.refresh()

    	@make_verbal
    	def wg(self, samples = None, a18_acid = None):
    		'''
    		Compute bulk composition of the working gas for each session based on
    		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
    		`self.Nominal_d18O_VPDB`.
    		'''

    		self.msg('Computing WG composition:')

    		if a18_acid is None:
    			a18_acid = self.ALPHA_18O_ACID_REACTION
    		if samples is None:
    			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]

    		assert a18_acid, f'Acid fractionation factor should not be zero.'

    		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
    		R45R46_standards = {}
    		for sample in samples:
    			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
    			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
    			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
    			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
    			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid

    			C12_s = 1 / (1 + R13_s)
    			C13_s = R13_s / (1 + R13_s)
    			C16_s = 1 / (1 + R17_s + R18_s)
    			C17_s = R17_s / (1 + R17_s + R18_s)
    			C18_s = R18_s / (1 + R17_s + R18_s)

    			C626_s = C12_s * C16_s ** 2
    			C627_s = 2 * C12_s * C16_s * C17_s
    			C628_s = 2 * C12_s * C16_s * C18_s
    			C636_s = C13_s * C16_s ** 2
    			C637_s = 2 * C13_s * C16_s * C17_s
    			C727_s = C12_s * C17_s ** 2

    			R45_s = (C627_s + C636_s) / C626_s
    			R46_s = (C628_s + C637_s + C727_s) / C626_s
    			R45R46_standards[sample] = (R45_s, R46_s)

    		for s in self.sessions:
    			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
    			assert db, f'No sample from {samples} found in session "{s}".'
    # 			dbsamples = sorted({r['Sample'] for r in db})

    			X = [r['d45'] for r in db]
    			Y = [R45R46_standards[r['Sample']][0] for r in db]
    			x1, x2 = np.min(X), np.max(X)

    			if x1 < x2:
    				wgcoord = x1/(x1-x2)
    			else:
    				wgcoord = 999

    			if wgcoord < -.5 or wgcoord > 1.5:
    				# unreasonable to extrapolate to d45 = 0
    				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    			else :
    				# d45 = 0 is reasonably well bracketed
    				R45_wg = np.polyfit(X, Y, 1)[1]

    			X = [r['d46'] for r in db]
    			Y = [R45R46_standards[r['Sample']][1] for r in db]
    			x1, x2 = np.min(X), np.max(X)

    			if x1 < x2:
    				wgcoord = x1/(x1-x2)
    			else:
    				wgcoord = 999

    			if wgcoord < -.5 or wgcoord > 1.5:
    				# unreasonable to extrapolate to d46 = 0
    				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    			else :
    				# d46 = 0 is reasonably well bracketed
    				R46_wg = np.polyfit(X, Y, 1)[1]

    			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)

    			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')

    			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
    			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
    			for r in self.sessions[s]['data']:
    				r['d13Cwg_VPDB'] = d13Cwg_VPDB
    				r['d18Owg_VSMOW'] = d18Owg_VSMOW

    	def compute_bulk_delta(self, R45, R46, D17O = 0):
    		'''
    		Compute δ13C_VPDB and δ18O_VSMOW,
    		by solving the generalized form of equation (17) from
    		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
    		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
    		solving the corresponding second-order Taylor polynomial.
    		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
    		'''

    		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17

    		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
    		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
    		C = 2 * self.R18_VSMOW
    		D = -R46

    		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
    		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
    		cc = A + B + C + D

    		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)

    		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
    		R17 = K * R18 ** self.LAMBDA_17
    		R13 = R45 - 2 * R17

    		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)

    		return d13C_VPDB, d18O_VSMOW

    	@make_verbal
    	def crunch(self, verbose = ''):
    		'''
    		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    		'''
    		for r in self:
    			self.compute_bulk_and_clumping_deltas(r)
    		self.standardize_d13C()
    		self.standardize_d18O()
    		self.msg(f"Crunched {len(self)} analyses.")

    	def fill_in_missing_info(self, session = 'mySession'):
    		'''
    		Fill in optional fields with default values
    		'''
    		for i,r in enumerate(self):
    			if 'D17O' not in r:
    				r['D17O'] = 0.
    			if 'UID' not in r:
    				r['UID'] = f'{i+1}'
    			if 'Session' not in r:
    				r['Session'] = session
    			for k in ['d47', 'd48', 'd49']:
    				if k not in r:
    					r[k] = np.nan

    	def standardize_d13C(self):
    		'''
    		Perform δ13C standadization within each session `s` according to
    		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
    		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
    		may be redefined abitrarily at a later stage.
    		'''
    		for s in self.sessions:
    			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
    				XY = [(r['d13C_VPDB'], self.Nominal_d13C_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d13C_VPDB]
    				X,Y = zip(*XY)
    				if self.sessions[s]['d13C_standardization_method'] == '1pt':
    					offset = np.mean(Y) - np.mean(X)
    					for r in self.sessions[s]['data']:
    						r['d13C_VPDB'] += offset
    				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
    					a,b = np.polyfit(X,Y,1)
    					for r in self.sessions[s]['data']:
    						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b

    	def standardize_d18O(self):
    		'''
    		Perform δ18O standadization within each session `s` according to
    		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
    		which is defined by default by `D47data.refresh_sessions()`as equal to
    		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
    		'''
    		for s in self.sessions:
    			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
    				XY = [(r['d18O_VSMOW'], self.Nominal_d18O_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d18O_VPDB]
    				X,Y = zip(*XY)
    				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
    				if self.sessions[s]['d18O_standardization_method'] == '1pt':
    					offset = np.mean(Y) - np.mean(X)
    					for r in self.sessions[s]['data']:
    						r['d18O_VSMOW'] += offset
    				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
    					a,b = np.polyfit(X,Y,1)
    					for r in self.sessions[s]['data']:
    						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b

    	def compute_bulk_and_clumping_deltas(self, r):
    		'''
    		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
    		'''

    		# Compute working gas R13, R18, and isobar ratios
    		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
    		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
    		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)

    		# Compute analyte isobar ratios
    		R45 = (1 + r['d45'] / 1000) * R45_wg
    		R46 = (1 + r['d46'] / 1000) * R46_wg
    		R47 = (1 + r['d47'] / 1000) * R47_wg
    		R48 = (1 + r['d48'] / 1000) * R48_wg
    		R49 = (1 + r['d49'] / 1000) * R49_wg

    		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
    		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
    		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW

    		# Compute stochastic isobar ratios of the analyte
    		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
    			R13, R18, D17O = r['D17O']
    		)

    		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
    		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
    		if (R45 / R45stoch - 1) > 5e-8:
    			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
    		if (R46 / R46stoch - 1) > 5e-8:
    			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')

    		# Compute raw clumped isotope anomalies
    		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
    		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
    		r['D49raw'] = 1000 * (R49 / R49stoch - 1)

    	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
    		'''
    		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
    		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
    		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
    		'''

    		# Compute R17
    		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17

    		# Compute isotope concentrations
    		C12 = (1 + R13) ** -1
    		C13 = C12 * R13
    		C16 = (1 + R17 + R18) ** -1
    		C17 = C16 * R17
    		C18 = C16 * R18

    		# Compute stochastic isotopologue concentrations
    		C626 = C16 * C12 * C16
    		C627 = C16 * C12 * C17 * 2
    		C628 = C16 * C12 * C18 * 2
    		C636 = C16 * C13 * C16
    		C637 = C16 * C13 * C17 * 2
    		C638 = C16 * C13 * C18 * 2
    		C727 = C17 * C12 * C17
    		C728 = C17 * C12 * C18 * 2
    		C737 = C17 * C13 * C17
    		C738 = C17 * C13 * C18 * 2
    		C828 = C18 * C12 * C18
    		C838 = C18 * C13 * C18

    		# Compute stochastic isobar ratios
    		R45 = (C636 + C627) / C626
    		R46 = (C628 + C637 + C727) / C626
    		R47 = (C638 + C728 + C737) / C626
    		R48 = (C738 + C828) / C626
    		R49 = C838 / C626

    		# Account for stochastic anomalies
    		R47 *= 1 + D47 / 1000
    		R48 *= 1 + D48 / 1000
    		R49 *= 1 + D49 / 1000

    		# Return isobar ratios
    		return R45, R46, R47, R48, R49

    	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
    		'''
    		Split unknown samples by UID (treat all analyses as different samples)
    		or by session (treat analyses of a given sample in different sessions as
    		different samples).

    		**Parameters**

    		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
    		+ `grouping`: `by_uid` | `by_session`
    		'''
    		if samples_to_split == 'all':
    			samples_to_split = [s for s in self.unknowns]
    		gkeys = {'by_uid':'UID', 'by_session':'Session'}
    		self.grouping = grouping.lower()
    		if self.grouping in gkeys:
    			gkey = gkeys[self.grouping]
    		for r in self:
    			if r['Sample'] in samples_to_split:
    				r['Sample_original'] = r['Sample']
    				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
    			elif r['Sample'] in self.unknowns:
    				r['Sample_original'] = r['Sample']
    		self.refresh_samples()

    	def unsplit_samples(self, tables = False):
    		'''
    		Reverse the effects of `D47data.split_samples()`.

    		This should only be used after `D4xdata.standardize()` with `method='pooled'`.

    		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
    		probably use `D4xdata.combine_samples()` instead to reverse the effects of
    		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
    		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
    		that case session-averaged Δ4x values are statistically independent).
    		'''
    		unknowns_old = sorted({s for s in self.unknowns})
    		CM_old = self.standardization.covar[:,:]
    		VD_old = self.standardization.params.valuesdict().copy()
    		vars_old = self.standardization.var_names

    		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})

    		Ns = len(vars_old) - len(unknowns_old)
    		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
    		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}

    		W = np.zeros((len(vars_new), len(vars_old)))
    		W[:Ns,:Ns] = np.eye(Ns)
    		for u in unknowns_new:
    			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
    			if self.grouping == 'by_session':
    				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
    			elif self.grouping == 'by_uid':
    				weights = [1 for s in splits]
    			sw = sum(weights)
    			weights = [w/sw for w in weights]
    			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]

    		CM_new = W @ CM_old @ W.T
    		V = W @ np.array([[VD_old[k]] for k in vars_old])
    		VD_new = {k:v[0] for k,v in zip(vars_new, V)}

    		self.standardization.covar = CM_new
    		self.standardization.params.valuesdict = lambda : VD_new
    		self.standardization.var_names = vars_new

    		for r in self:
    			if r['Sample'] in self.unknowns:
    				r['Sample_split'] = r['Sample']
    				r['Sample'] = r['Sample_original']

    		self.refresh_samples()
    		self.consolidate_samples()
    		self.repeatabilities()

    		if tables:
    			self.table_of_analyses()
    			self.table_of_samples()

    	def assign_timestamps(self):
    		'''
    		Assign a time field `t` of type `float` to each analysis.

    		If `TimeTag` is one of the data fields, `t` is equal within a given session
    		to `TimeTag` minus the mean value of `TimeTag` for that session.
    		Otherwise, `TimeTag` is by default equal to the index of each analysis
    		in the dataset and `t` is defined as above.
    		'''
    		for session in self.sessions:
    			sdata = self.sessions[session]['data']
    			try:
    				t0 = np.mean([r['TimeTag'] for r in sdata])
    				for r in sdata:
    					r['t'] = r['TimeTag'] - t0
    			except KeyError:
    				t0 = (len(sdata)-1)/2
    				for t,r in enumerate(sdata):
    					r['t'] = t - t0

    	def report(self):
    		'''
    		Prints a report on the standardization fit.
    		Only applicable after `D4xdata.standardize(method='pooled')`.
    		'''
    		report_fit(self.standardization)

    	def combine_samples(self, sample_groups):
    		'''
    		Combine analyses of different samples to compute weighted average Δ4x
    		and new error (co)variances corresponding to the groups defined by the `sample_groups`
    		dictionary.

    		Caution: samples are weighted by number of replicate analyses, which is a
    		reasonable default behavior but is not always optimal (e.g., in the case of strongly
    		correlated analytical errors for one or more samples).

    		Returns a tuplet of:

    		+ the list of group names
    		+ an array of the corresponding Δ4x values
    		+ the corresponding (co)variance matrix

    		**Parameters**

    		+ `sample_groups`: a dictionary of the form:
    		```py
    		{'group1': ['sample_1', 'sample_2'],
    		 'group2': ['sample_3', 'sample_4', 'sample_5']}
    		```
    		'''

    		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
    		groups = sorted(sample_groups.keys())
    		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
    		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
    		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
    		W = np.array([
    			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
    			for j in groups])
    		D4x_new = W @ D4x_old
    		CM_new = W @ CM_old @ W.T

    		return groups, D4x_new[:,0], CM_new

    	@make_verbal
    	def standardize(self,
    		method = 'pooled',
    		weighted_sessions = [],
    		consolidate = True,
    		consolidate_tables = False,
    		consolidate_plots = False,
    		constraints = {},
    		):
    		'''
    		Compute absolute Δ4x values for all replicate analyses and for sample averages.
    		If `method` argument is set to `'pooled'`, the standardization processes all sessions
    		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
    		i.e. that their true Δ4x value does not change between sessions,
    		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
    		`'indep_sessions'`, the standardization processes each session independently, based only
    		on anchors analyses.
    		'''

    		self.standardization_method = method
    		self.assign_timestamps()

    		if method == 'pooled':
    			if weighted_sessions:
    				for session_group in weighted_sessions:
    					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
    					X.Nominal_D4x = self.Nominal_D4x.copy()
    					X.refresh()
    					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
    					w = np.sqrt(result.redchi)
    					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
    					for r in X:
    						r[f'wD{self._4x}raw'] *= w
    			else:
    				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
    				for r in self:
    					r[f'wD{self._4x}raw'] = 1.

    			params = Parameters()
    			for k,session in enumerate(self.sessions):
    				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
    				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
    				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
    				s = pf(session)
    				params.add(f'a_{s}', value = 0.9)
    				params.add(f'b_{s}', value = 0.)
    				params.add(f'c_{s}', value = -0.9)
    				params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
    				params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
    				params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
    			for sample in self.unknowns:
    				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)

    			for k in constraints:
    				params[k].expr = constraints[k]

    			def residuals(p):
    				R = []
    				for r in self:
    					session = pf(r['Session'])
    					sample = pf(r['Sample'])
    					if r['Sample'] in self.Nominal_D4x:
    						R += [ (
    							r[f'D{self._4x}raw'] - (
    								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
    								+ p[f'b_{session}'] * r[f'd{self._4x}']
    								+	p[f'c_{session}']
    								+ r['t'] * (
    									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
    									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    									+	p[f'c2_{session}']
    									)
    								)
    							) / r[f'wD{self._4x}raw'] ]
    					else:
    						R += [ (
    							r[f'D{self._4x}raw'] - (
    								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
    								+ p[f'b_{session}'] * r[f'd{self._4x}']
    								+	p[f'c_{session}']
    								+ r['t'] * (
    									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
    									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    									+	p[f'c2_{session}']
    									)
    								)
    							) / r[f'wD{self._4x}raw'] ]
    				return R

    			M = Minimizer(residuals, params)
    			result = M.least_squares()
    			self.Nf = result.nfree
    			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
    # 			if self.verbose:
    # 				report_fit(result)

    			for r in self:
    				s = pf(r["Session"])
    				a = result.params.valuesdict()[f'a_{s}']
    				b = result.params.valuesdict()[f'b_{s}']
    				c = result.params.valuesdict()[f'c_{s}']
    				a2 = result.params.valuesdict()[f'a2_{s}']
    				b2 = result.params.valuesdict()[f'b2_{s}']
    				c2 = result.params.valuesdict()[f'c2_{s}']
    				r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])

    			self.standardization = result

    			for session in self.sessions:
    				self.sessions[session]['Np'] = 3
    				for k in ['scrambling', 'slope', 'wg']:
    					if self.sessions[session][f'{k}_drift']:
    						self.sessions[session]['Np'] += 1

    			if consolidate:
    				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
    			return result

    		elif method == 'indep_sessions':

    			if weighted_sessions:
    				for session_group in weighted_sessions:
    					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
    					X.Nominal_D4x = self.Nominal_D4x.copy()
    					X.refresh()
    					# This is only done to assign r['wD47raw'] for r in X:
    					X.standardize(method = method, weighted_sessions = [], consolidate = False)
    					self.msg(f'D{self._4x}raw weights set to {1000*X[0][f"wD{self._4x}raw"]:.1f} ppm for sessions in {session_group}')
    			else:
    				self.msg('All weights set to 1 ‰')
    				for r in self:
    					r[f'wD{self._4x}raw'] = 1

    			for session in self.sessions:
    				s = self.sessions[session]
    				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
    				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
    				s['Np'] = sum(p_active)
    				sdata = s['data']

    				A = np.array([
    					[
    						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
    						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
    						1 / r[f'wD{self._4x}raw'],
    						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
    						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
    						r['t'] / r[f'wD{self._4x}raw']
    						]
    					for r in sdata if r['Sample'] in self.anchors
    					])[:,p_active] # only keep columns for the active parameters
    				Y = np.array([[r[f'D{self._4x}raw'] / r[f'wD{self._4x}raw']] for r in sdata if r['Sample'] in self.anchors])
    				s['Na'] = Y.size
    				CM = linalg.inv(A.T @ A)
    				bf = (CM @ A.T @ Y).T[0,:]
    				k = 0
    				for n,a in zip(p_names, p_active):
    					if a:
    						s[n] = bf[k]
    # 						self.msg(f'{n} = {bf[k]}')
    						k += 1
    					else:
    						s[n] = 0.
    # 						self.msg(f'{n} = 0.0')

    				for r in sdata :
    					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
    					r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
    					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])

    				s['CM'] = np.zeros((6,6))
    				i = 0
    				k_active = [j for j,a in enumerate(p_active) if a]
    				for j,a in enumerate(p_active):
    					if a:
    						s['CM'][j,k_active] = CM[i,:]
    						i += 1

    			if not weighted_sessions:
    				w = self.rmswd()['rmswd']
    				for r in self:
    						r[f'wD{self._4x}'] *= w
    						r[f'wD{self._4x}raw'] *= w
    				for session in self.sessions:
    					self.sessions[session]['CM'] *= w**2

    			for session in self.sessions:
    				s = self.sessions[session]
    				s['SE_a'] = s['CM'][0,0]**.5
    				s['SE_b'] = s['CM'][1,1]**.5
    				s['SE_c'] = s['CM'][2,2]**.5
    				s['SE_a2'] = s['CM'][3,3]**.5
    				s['SE_b2'] = s['CM'][4,4]**.5
    				s['SE_c2'] = s['CM'][5,5]**.5

    			if not weighted_sessions:
    				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
    			else:
    				self.Nf = 0
    				for sg in weighted_sessions:
    					self.Nf += self.rmswd(sessions = sg)['Nf']

    			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)

    			avgD4x = {
    				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
    				for sample in self.samples
    				}
    			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
    			rD4x = (chi2/self.Nf)**.5
    			self.repeatability[f'sigma_{self._4x}'] = rD4x

    			if consolidate:
    				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)

    	def standardization_error(self, session, d4x, D4x, t = 0):
    		'''
    		Compute standardization error for a given session and
    		(δ47, Δ47) composition.
    		'''
    		a = self.sessions[session]['a']
    		b = self.sessions[session]['b']
    		c = self.sessions[session]['c']
    		a2 = self.sessions[session]['a2']
    		b2 = self.sessions[session]['b2']
    		c2 = self.sessions[session]['c2']
    		CM = self.sessions[session]['CM']

    		x, y = D4x, d4x
    		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
    # 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
    		dxdy = -(b+b2*t) / (a+a2*t)
    		dxdz = 1. / (a+a2*t)
    		dxda = -x / (a+a2*t)
    		dxdb = -y / (a+a2*t)
    		dxdc = -1. / (a+a2*t)
    		dxda2 = -x * a2 / (a+a2*t)
    		dxdb2 = -y * t / (a+a2*t)
    		dxdc2 = -t / (a+a2*t)
    		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
    		sx = (V @ CM @ V.T) ** .5
    		return sx

    	@make_verbal
    	def summary(self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		):
    		'''
    		Print out an/or save to disk a summary of the standardization results.

    		**Parameters**

    		+ `dir`: the directory in which to save the table
    		+ `filename`: the name to the csv file to write to
    		+ `save_to_file`: whether to save the table to disk
    		+ `print_out`: whether to print out the table
    		'''

    		out = []
    		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
    		out += [['N analyses (anchors + unknowns)', f"{len(self)} ({len([r for r in self if r['Sample'] in self.anchors])} + {len([r for r in self if r['Sample'] in self.unknowns])})"]]
    		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
    		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
    		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
    		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
    		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
    		out += [['Model degrees of freedom', f"{self.Nf}"]]
    		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
    		out += [['Standardization method', self.standardization_method]]

    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_summary.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n' + pretty_table(out, header = 0))

    	@make_verbal
    	def table_of_sessions(self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out an/or save to disk a table of sessions.

    		**Parameters**

    		+ `dir`: the directory in which to save the table
    		+ `filename`: the name to the csv file to write to
    		+ `save_to_file`: whether to save the table to disk
    		+ `print_out`: whether to print out the table
    		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''
    		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
    		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
    		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])

    		out = [['Session','Na','Nu','d13Cwg_VPDB','d18Owg_VSMOW','r_d13C','r_d18O',f'r_D{self._4x}','a ± SE','1e3 x b ± SE','c ± SE']]
    		if include_a2:
    			out[-1] += ['a2 ± SE']
    		if include_b2:
    			out[-1] += ['b2 ± SE']
    		if include_c2:
    			out[-1] += ['c2 ± SE']
    		for session in self.sessions:
    			out += [[
    				session,
    				f"{self.sessions[session]['Na']}",
    				f"{self.sessions[session]['Nu']}",
    				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
    				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
    				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
    				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
    				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
    				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
    				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
    				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
    				]]
    			if include_a2:
    				if self.sessions[session]['scrambling_drift']:
    					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
    				else:
    					out[-1] += ['']
    			if include_b2:
    				if self.sessions[session]['slope_drift']:
    					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
    				else:
    					out[-1] += ['']
    			if include_c2:
    				if self.sessions[session]['wg_drift']:
    					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
    				else:
    					out[-1] += ['']

    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_sessions.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n' + pretty_table(out))
    		if output == 'raw':
    			return out
    		elif output == 'pretty':
    			return pretty_table(out)

    	@make_verbal
    	def table_of_analyses(
    		self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out an/or save to disk a table of analyses.

    		**Parameters**

    		+ `dir`: the directory in which to save the table
    		+ `filename`: the name to the csv file to write to
    		+ `save_to_file`: whether to save the table to disk
    		+ `print_out`: whether to print out the table
    		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''

    		out = [['UID','Session','Sample']]
    		extra_fields = [f for f in [('SampleMass','.2f'),('ColdFingerPressure','.1f'),('AcidReactionYield','.3f')] if f[0] in {k for r in self for k in r}]
    		for f in extra_fields:
    			out[-1] += [f[0]]
    		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
    		for r in self:
    			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
    			for f in extra_fields:
    				out[-1] += [f"{r[f[0]]:{f[1]}}"]
    			out[-1] += [
    				f"{r['d13Cwg_VPDB']:.3f}",
    				f"{r['d18Owg_VSMOW']:.3f}",
    				f"{r['d45']:.6f}",
    				f"{r['d46']:.6f}",
    				f"{r['d47']:.6f}",
    				f"{r['d48']:.6f}",
    				f"{r['d49']:.6f}",
    				f"{r['d13C_VPDB']:.6f}",
    				f"{r['d18O_VSMOW']:.6f}",
    				f"{r['D47raw']:.6f}",
    				f"{r['D48raw']:.6f}",
    				f"{r['D49raw']:.6f}",
    				f"{r[f'D{self._4x}']:.6f}"
    				]
    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_analyses.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n' + pretty_table(out))
    		return out

    	@make_verbal
    	def covar_table(
    		self,
    		correl = False,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out, save to disk and/or return the variance-covariance matrix of D4x
    		for all unknown samples.

    		**Parameters**

    		+ `dir`: the directory in which to save the csv
    		+ `filename`: the name of the csv file to write to
    		+ `save_to_file`: whether to save the csv
    		+ `print_out`: whether to print out the matrix
    		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''
    		samples = sorted([u for u in self.unknowns])
    		out = [[''] + samples]
    		for s1 in samples:
    			out.append([s1])
    			for s2 in samples:
    				if correl:
    					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
    				else:
    					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')

    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				if correl:
    					filename = f'D{self._4x}_correl.csv'
    				else:
    					filename = f'D{self._4x}_covar.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n'+pretty_table(out))
    		if output == 'raw':
    			return out
    		elif output == 'pretty':
    			return pretty_table(out)

    	@make_verbal
    	def table_of_samples(
    		self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out, save to disk and/or return a table of samples.

    		**Parameters**

    		+ `dir`: the directory in which to save the csv
    		+ `filename`: the name of the csv file to write to
    		+ `save_to_file`: whether to save the csv
    		+ `print_out`: whether to print out the table
    		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''

    		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
    		for sample in self.anchors:
    			out += [[
    				f"{sample}",
    				f"{self.samples[sample]['N']}",
    				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
    				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
    				]]
    		for sample in self.unknowns:
    			out += [[
    				f"{sample}",
    				f"{self.samples[sample]['N']}",
    				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    				f"{self.samples[sample][f'D{self._4x}']:.4f}",
    				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
    				f"± {self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
    				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
    				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
    				]]
    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_samples.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n'+pretty_table(out))
    		if output == 'raw':
    			return out
    		elif output == 'pretty':
    			return pretty_table(out)

    	def plot_sessions(self, dir = 'output', figsize = (8,8)):
    		'''
    		Generate session plots and save them to disk.

    		**Parameters**

    		+ `dir`: the directory in which to save the plots
    		+ `figsize`: the width and height (in inches) of each plot
    		'''
    		if not os.path.exists(dir):
    			os.makedirs(dir)

    		for session in self.sessions:
    			sp = self.plot_single_session(session, xylimits = 'constant')
    			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
    			ppl.close(sp.fig)

    	@make_verbal
    	def consolidate_samples(self):
    		'''
    		Compile various statistics for each sample.

    		For each anchor sample:

    		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
    		+ `SE_D47` or `SE_D48`: set to zero by definition

    		For each unknown sample:

    		+ `D47` or `D48`: the standardized Δ4x value for this unknown
    		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown

    		For each anchor and unknown:

    		+ `N`: the total number of analyses of this sample
    		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
    		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
    		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
    		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
    		variance, indicating whether the Δ4x repeatability this sample differs significantly from
    		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
    		'''
    		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
    		for sample in self.samples:
    			self.samples[sample]['N'] = len(self.samples[sample]['data'])
    			if self.samples[sample]['N'] > 1:
    				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])

    			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
    			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])

    			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
    			if len(D4x_pop) > 2:
    				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]

    		if self.standardization_method == 'pooled':
    			for sample in self.anchors:
    				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    				self.samples[sample][f'SE_D{self._4x}'] = 0.
    			for sample in self.unknowns:
    				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
    				try:
    					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
    				except ValueError:
    					# when `sample` is constrained by self.standardize(constraints = {...}),
    					# it is no longer listed in self.standardization.var_names.
    					# Temporary fix: define SE as zero for now
    					self.samples[sample][f'SE_D4{self._4x}'] = 0.

    		elif self.standardization_method == 'indep_sessions':
    			for sample in self.anchors:
    				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    				self.samples[sample][f'SE_D{self._4x}'] = 0.
    			for sample in self.unknowns:
    				self.msg(f'Consolidating sample {sample}')
    				self.unknowns[sample][f'session_D{self._4x}'] = {}
    				session_avg = []
    				for session in self.sessions:
    					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
    					if sdata:
    						self.msg(f'{sample} found in session {session}')
    						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
    						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
    						# !! TODO: sigma_s below does not account for temporal changes in standardization error
    						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
    						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
    						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
    						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
    				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
    				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
    				wsum = sum([weights[s] for s in weights])
    				for s in weights:
    					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]

    	def consolidate_sessions(self):
    		'''
    		Compute various statistics for each session.

    		+ `Na`: Number of anchor analyses in the session
    		+ `Nu`: Number of unknown analyses in the session
    		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
    		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
    		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
    		+ `a`: scrambling factor
    		+ `b`: compositional slope
    		+ `c`: WG offset
    		+ `SE_a`: Model stadard erorr of `a`
    		+ `SE_b`: Model stadard erorr of `b`
    		+ `SE_c`: Model stadard erorr of `c`
    		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
    		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
    		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
    		+ `a2`: scrambling factor drift
    		+ `b2`: compositional slope drift
    		+ `c2`: WG offset drift
    		+ `Np`: Number of standardization parameters to fit
    		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
    		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
    		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
    		'''
    		for session in self.sessions:
    			if 'd13Cwg_VPDB' not in self.sessions[session]:
    				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
    			if 'd18Owg_VSMOW' not in self.sessions[session]:
    				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
    			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
    			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])

    			self.msg(f'Computing repeatabilities for session {session}')
    			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
    			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
    			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])

    		if self.standardization_method == 'pooled':
    			for session in self.sessions:

    				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
    				i = self.standardization.var_names.index(f'a_{pf(session)}')
    				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5

    				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
    				i = self.standardization.var_names.index(f'b_{pf(session)}')
    				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5

    				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
    				i = self.standardization.var_names.index(f'c_{pf(session)}')
    				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5

    				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
    				if self.sessions[session]['scrambling_drift']:
    					i = self.standardization.var_names.index(f'a2_{pf(session)}')
    					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
    				else:
    					self.sessions[session]['SE_a2'] = 0.

    				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
    				if self.sessions[session]['slope_drift']:
    					i = self.standardization.var_names.index(f'b2_{pf(session)}')
    					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
    				else:
    					self.sessions[session]['SE_b2'] = 0.

    				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
    				if self.sessions[session]['wg_drift']:
    					i = self.standardization.var_names.index(f'c2_{pf(session)}')
    					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
    				else:
    					self.sessions[session]['SE_c2'] = 0.

    				i = self.standardization.var_names.index(f'a_{pf(session)}')
    				j = self.standardization.var_names.index(f'b_{pf(session)}')
    				k = self.standardization.var_names.index(f'c_{pf(session)}')
    				CM = np.zeros((6,6))
    				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
    				try:
    					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
    					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
    					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
    					try:
    						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    						CM[3,4] = self.standardization.covar[i2,j2]
    						CM[4,3] = self.standardization.covar[j2,i2]
    					except ValueError:
    						pass
    					try:
    						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    						CM[3,5] = self.standardization.covar[i2,k2]
    						CM[5,3] = self.standardization.covar[k2,i2]
    					except ValueError:
    						pass
    				except ValueError:
    					pass
    				try:
    					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
    					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
    					try:
    						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    						CM[4,5] = self.standardization.covar[j2,k2]
    						CM[5,4] = self.standardization.covar[k2,j2]
    					except ValueError:
    						pass
    				except ValueError:
    					pass
    				try:
    					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
    					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
    				except ValueError:
    					pass

    				self.sessions[session]['CM'] = CM

    		elif self.standardization_method == 'indep_sessions':
    			pass # Not implemented yet

    	@make_verbal
    	def repeatabilities(self):
    		'''
    		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    		(for all samples, for anchors, and for unknowns).
    		'''
    		self.msg('Computing reproducibilities for all sessions')

    		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')

    	@make_verbal
    	def consolidate(self, tables = True, plots = True):
    		'''
    		Collect information about samples, sessions and repeatabilities.
    		'''
    		self.consolidate_samples()
    		self.consolidate_sessions()
    		self.repeatabilities()

    		if tables:
    			self.summary()
    			self.table_of_sessions()
    			self.table_of_analyses()
    			self.table_of_samples()

    		if plots:
    			self.plot_sessions()

    	@make_verbal
    	def rmswd(self,
    		samples = 'all samples',
    		sessions = 'all sessions',
    		):
    		'''
    		Compute the χ2, root mean squared weighted deviation
    		(i.e. reduced χ2), and corresponding degrees of freedom of the
    		Δ4x values for samples in `samples` and sessions in `sessions`.

    		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
    		'''
    		if samples == 'all samples':
    			mysamples = [k for k in self.samples]
    		elif samples == 'anchors':
    			mysamples = [k for k in self.anchors]
    		elif samples == 'unknowns':
    			mysamples = [k for k in self.unknowns]
    		else:
    			mysamples = samples

    		if sessions == 'all sessions':
    			sessions = [k for k in self.sessions]

    		chisq, Nf = 0, 0
    		for sample in mysamples :
    			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    			if len(G) > 1 :
    				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
    				Nf += (len(G) - 1)
    				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
    		r = (chisq / Nf)**.5 if Nf > 0 else 0
    		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
    		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}

    	@make_verbal
    	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
    		'''
    		Compute the repeatability of `[r[key] for r in self]`
    		'''
    		# NB: it's debatable whether rD47 should be computed
    		# with Nf = len(self)-len(self.samples) instead of
    		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)

    		if samples == 'all samples':
    			mysamples = [k for k in self.samples]
    		elif samples == 'anchors':
    			mysamples = [k for k in self.anchors]
    		elif samples == 'unknowns':
    			mysamples = [k for k in self.unknowns]
    		else:
    			mysamples = samples

    		if sessions == 'all sessions':
    			sessions = [k for k in self.sessions]

    		if key in ['D47', 'D48']:
    			chisq, Nf = 0, 0
    			for sample in mysamples :
    				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    				if len(X) > 1 :
    					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
    					if sample in self.unknowns:
    						Nf += len(X) - 1
    					else:
    						Nf += len(X)
    			if samples in ['anchors', 'all samples']:
    				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
    			r = (chisq / Nf)**.5 if Nf > 0 else 0

    		else: # if key not in ['D47', 'D48']
    			chisq, Nf = 0, 0
    			for sample in mysamples :
    				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    				if len(X) > 1 :
    					Nf += len(X) - 1
    					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
    			r = (chisq / Nf)**.5 if Nf > 0 else 0

    		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
    		return r

    	def sample_average(self, samples, weights = 'equal', normalize = True):
    		'''
    		Weighted average Δ4x value of a group of samples, accounting for covariance.

    		Returns the weighed average Δ4x value and associated SE
    		of a group of samples. Weights are equal by default. If `normalize` is
    		true, `weights` will be rescaled so that their sum equals 1.

    		**Examples**

    		```python
    		self.sample_average(['X','Y'], [1, 2])
    		```

    		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
    		where Δ4x(X) and Δ4x(Y) are the average Δ4x
    		values of samples X and Y, respectively.

    		```python
    		self.sample_average(['X','Y'], [1, -1], normalize = False)
    		```

    		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
    		'''
    		if weights == 'equal':
    			weights = [1/len(samples)] * len(samples)

    		if normalize:
    			s = sum(weights)
    			if s:
    				weights = [w/s for w in weights]

    		try:
    # 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
    # 			C = self.standardization.covar[indices,:][:,indices]
    			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
    			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
    			return correlated_sum(X, C, weights)
    		except ValueError:
    			return (0., 0.)

    	def sample_D4x_covar(self, sample1, sample2 = None):
    		'''
    		Covariance between Δ4x values of samples

    		Returns the error covariance between the average Δ4x values of two
    		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
    		returns the Δ4x variance for that sample.
    		'''
    		if sample2 is None:
    			sample2 = sample1
    		if self.standardization_method == 'pooled':
    			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
    			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
    			return self.standardization.covar[i, j]
    		elif self.standardization_method == 'indep_sessions':
    			if sample1 == sample2:
    				return self.samples[sample1][f'SE_D{self._4x}']**2
    			else:
    				c = 0
    				for session in self.sessions:
    					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
    					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
    					if sdata1 and sdata2:
    						a = self.sessions[session]['a']
    						# !! TODO: CM below does not account for temporal changes in standardization parameters
    						CM = self.sessions[session]['CM'][:3,:3]
    						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
    						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
    						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
    						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
    						c += (
    							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
    							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
    							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
    							@ CM
    							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
    							) / a**2
    				return float(c)

    	def sample_D4x_correl(self, sample1, sample2 = None):
    		'''
    		Correlation between Δ4x errors of samples

    		Returns the error correlation between the average Δ4x values of two samples.
    		'''
    		if sample2 is None or sample2 == sample1:
    			return 1.
    		return (
    			self.sample_D4x_covar(sample1, sample2)
    			/ self.unknowns[sample1][f'SE_D{self._4x}']
    			/ self.unknowns[sample2][f'SE_D{self._4x}']
    			)

    	def plot_single_session(self,
    		session,
    		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
    		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
    		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
    		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
    		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
    		xylimits = 'free', # | 'constant'
    		x_label = None,
    		y_label = None,
    		error_contour_interval = 'auto',
    		fig = 'new',
    		):
    		'''
    		Generate plot for a single session
    		'''
    		if x_label is None:
    			x_label = f'δ$_{{{self._4x}}}$ (‰)'
    		if y_label is None:
    			y_label = f'Δ$_{{{self._4x}}}$ (‰)'

    		out = _SessionPlot()
    		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
    		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]

    		if fig == 'new':
    			out.fig = ppl.figure(figsize = (6,6))
    			ppl.subplots_adjust(.1,.1,.9,.9)

    		out.anchor_analyses, = ppl.plot(
    			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    			**kw_plot_anchors)
    		out.unknown_analyses, = ppl.plot(
    			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    			**kw_plot_unknowns)
    		out.anchor_avg = ppl.plot(
    			np.array([ np.array([
    				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    				]) for sample in anchors]).T,
    			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
    			**kw_plot_anchor_avg)
    		out.unknown_avg = ppl.plot(
    			np.array([ np.array([
    				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    				]) for sample in unknowns]).T,
    			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
    			**kw_plot_unknown_avg)
    		if xylimits == 'constant':
    			x = [r[f'd{self._4x}'] for r in self]
    			y = [r[f'D{self._4x}'] for r in self]
    			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
    			w, h = x2-x1, y2-y1
    			x1 -= w/20
    			x2 += w/20
    			y1 -= h/20
    			y2 += h/20
    			ppl.axis([x1, x2, y1, y2])
    		elif xylimits == 'free':
    			x1, x2, y1, y2 = ppl.axis()
    		else:
    			x1, x2, y1, y2 = ppl.axis(xylimits)

    		if error_contour_interval != 'none':
    			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
    			XI,YI = np.meshgrid(xi, yi)
    			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
    			if error_contour_interval == 'auto':
    				rng = np.max(SI) - np.min(SI)
    				if rng <= 0.01:
    					cinterval = 0.001
    				elif rng <= 0.03:
    					cinterval = 0.004
    				elif rng <= 0.1:
    					cinterval = 0.01
    				elif rng <= 0.3:
    					cinterval = 0.03
    				elif rng <= 1.:
    					cinterval = 0.1
    				else:
    					cinterval = 0.5
    			else:
    				cinterval = error_contour_interval

    			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
    			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
    			out.clabel = ppl.clabel(out.contour)

    		ppl.xlabel(x_label)
    		ppl.ylabel(y_label)
    		ppl.title(session, weight = 'bold')
    		ppl.grid(alpha = .2)
    		out.ax = ppl.gca()

    		return out

    	def plot_residuals(
    		self,
    		hist = False,
    		binwidth = 2/3,
    		dir = 'output',
    		filename = None,
    		highlight = [],
    		colors = None,
    		figsize = None,
    		):
    		'''
    		Plot residuals of each analysis as a function of time (actually, as a function of
    		the order of analyses in the `D4xdata` object)

    		+ `hist`: whether to add a histogram of residuals
    		+ `histbins`: specify bin edges for the histogram
    		+ `dir`: the directory in which to save the plot
    		+ `highlight`: a list of samples to highlight
    		+ `colors`: a dict of `{<sample>: <color>}` for all samples
    		+ `figsize`: (width, height) of figure
    		'''
    		# Layout
    		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
    		if hist:
    			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
    			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
    		else:
    			ppl.subplots_adjust(.08,.05,.78,.8)
    			ax1 = ppl.subplot(111)

    		# Colors
    		N = len(self.anchors)
    		if colors is None:
    			if len(highlight) > 0:
    				Nh = len(highlight)
    				if Nh == 1:
    					colors = {highlight[0]: (0,0,0)}
    				elif Nh == 3:
    					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
    				elif Nh == 4:
    					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    				else:
    					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
    			else:
    				if N == 3:
    					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
    				elif N == 4:
    					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    				else:
    					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}

    		ppl.sca(ax1)

    		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)

    		session = self[0]['Session']
    		x1 = 0
    # 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
    		x_sessions = {}
    		one_or_more_singlets = False
    		one_or_more_multiplets = False
    		for k,r in enumerate(self):
    			if r['Session'] != session:
    				x2 = k-1
    				x_sessions[session] = (x1+x2)/2
    				ppl.axvline(k - 0.5, color = 'k', lw = .5)
    				session = r['Session']
    				x1 = k
    			singlet = len(self.samples[r['Sample']]['data']) == 1
    			if r['Sample'] in self.unknowns:
    				if singlet:
    					one_or_more_singlets = True
    				else:
    					one_or_more_multiplets = True
    			kw = dict(
    				marker = 'x' if singlet else '+',
    				ms = 4 if singlet else 5,
    				ls = 'None',
    				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
    				mew = 1,
    				alpha = 0.2 if singlet else 1,
    				)
    			if highlight and r['Sample'] not in highlight:
    				kw['alpha'] = 0.2
    			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
    		x2 = k
    		x_sessions[session] = (x1+x2)/2

    		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
    		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
    		if not hist:
    			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
    			ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f"   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')

    		xmin, xmax, ymin, ymax = ppl.axis()
    		for s in x_sessions:
    			ppl.text(
    				x_sessions[s],
    				ymax +1,
    				s,
    				va = 'bottom',
    				**(
    					dict(ha = 'center')
    					if len(self.sessions[s]['data']) > (0.15 * len(self))
    					else dict(ha = 'left', rotation = 45)
    					)
    				)

    		if hist:
    			ppl.sca(ax2)

    		for s in colors:
    			kw['marker'] = '+'
    			kw['ms'] = 5
    			kw['mec'] = colors[s]
    			kw['label'] = s
    			kw['alpha'] = 1
    			ppl.plot([], [], **kw)

    		kw['mec'] = (0,0,0)

    		if one_or_more_singlets:
    			kw['marker'] = 'x'
    			kw['ms'] = 4
    			kw['alpha'] = .2
    			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
    			ppl.plot([], [], **kw)

    		if one_or_more_multiplets:
    			kw['marker'] = '+'
    			kw['ms'] = 4
    			kw['alpha'] = 1
    			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
    			ppl.plot([], [], **kw)

    		if hist:
    			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
    		else:
    			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
    		leg.set_zorder(-1000)

    		ppl.sca(ax1)

    		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
    		ppl.xticks([])
    		ppl.axis([-1, len(self), None, None])

    		if hist:
    			ppl.sca(ax2)
    			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self]
    			ppl.hist(
    				X,
    				orientation = 'horizontal',
    				histtype = 'stepfilled',
    				ec = [.4]*3,
    				fc = [.25]*3,
    				alpha = .25,
    				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
    				)
    			ppl.axis([None, None, ymin, ymax])
    			ppl.text(0, 0,
    				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
    				size = 8,
    				alpha = 1,
    				va = 'center',
    				ha = 'left',
    				)

    			ppl.xticks([])
    			ppl.yticks([])
    # 			ax2.spines['left'].set_visible(False)
    			ax2.spines['right'].set_visible(False)
    			ax2.spines['top'].set_visible(False)
    			ax2.spines['bottom'].set_visible(False)

    		if not os.path.exists(dir):
    			os.makedirs(dir)
    		if filename is None:
    			return fig
    		elif filename == '':
    			filename = f'D{self._4x}_residuals.pdf'
    		ppl.savefig(f'{dir}/{filename}')
    		ppl.close(fig)

    	def simulate(self, *args, **kwargs):
    		'''
    		Legacy function with warning message pointing to `virtual_data()`
    		'''
    		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')

    	def plot_distribution_of_analyses(self, dir = 'output', filename = None, vs_time = False, output = None):
    		'''
    		Plot temporal distribution of all analyses in the data set.

    		**Parameters**

    		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    		'''

    		asamples = [s for s in self.anchors]
    		usamples = [s for s in self.unknowns]
    		if output is None or output == 'fig':
    			fig = ppl.figure(figsize = (6,4))
    			ppl.subplots_adjust(0.02, 0.03, 0.9, 0.8)
    		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    		for k, s in enumerate(asamples + usamples):
    			if vs_time:
    				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    			else:
    				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    			Y = [k for x in X]
    			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .5)
    			ppl.axhline(k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    			ppl.text(Xmax, k, f'  {s}', va = 'center', ha = 'left', size = 7)
    		if vs_time:
    			t = [r['TimeTag'] for r in self]
    			t1, t2 = min(t), max(t)
    			tspan = t2 - t1
    			t1 -= tspan / len(self)
    			t2 += tspan / len(self)
    			ppl.axis([t1, t2, -1, k+1])
    		else:
    			ppl.axis([-1, len(self), -1, k+1])

    		x2 = 0
    		for session in self.sessions:
    			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    			if vs_time:
    				ppl.axvline(x1, color = 'k', lw = .75)
    			if k:
    				if vs_time:
    					ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .2)
    				else:
    					ppl.axvline((x1+x2)/2, color = 'k', lw = .75)
    			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    # 			from xlrd import xldate_as_datetime
    # 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    			if vs_time:
    				ppl.axvline(x2, color = 'k', lw = .75)
    			ppl.text((2*x1+x2)/3, k+1, session, ha = 'left', va = 'bottom', rotation = 45, size = 8)

    		ppl.xticks([])
    		ppl.yticks([])

    		if output is None:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename == None:
    				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    			ppl.savefig(f'{dir}/{filename}')
    			ppl.close(fig)
    		elif output == 'ax':
    			return ppl.gca()
    		elif output == 'fig':
    			return fig

Store and process data for a large set of Δ47 and/or Δ48 analyses, usually comprising more than one analytical session.

D4xdata(l=[], mass='47', logfile='', session='mySession', verbose=False)
View Source

    	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
    		'''
    		**Parameters**

    		+ `l`: a list of dictionaries, with each dictionary including at least the keys
    		`Sample`, `d45`, `d46`, and `d47` or `d48`.
    		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
    		+ `session`: define session name for analyses without a `Session` key
    		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.

    		Returns a `D4xdata` object derived from `list`.
    		'''
    		self._4x = mass
    		self.verbose = verbose
    		self.prefix = 'D4xdata'
    		self.logfile = logfile
    		list.__init__(self, l)
    		self.Nf = None
    		self.repeatability = {}
    		self.refresh(session = session)

Parameters

- `l`: a list of dictionaries, with each dictionary including at least the keys `Sample`, `d45`, `d46`, and `d47` or `d48`.
- `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
- `session`: define session name for analyses without a `Session` key
- `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.

Returns a `D4xdata` object derived from `list`.

R13_VPDB = 0.01118

Absolute (13C/12C) ratio of VPDB. By default equal to 0.01118 (Chang & Li, 1990)

R18_VSMOW = 0.0020052

Absolute (18O/16C) ratio of VSMOW. By default equal to 0.0020052 (Baertschi, 1976)

LAMBDA_17 = 0.528

Mass-dependent exponent for triple oxygen isotopes. By default equal to 0.528 (Barkan & Luz, 2005)

R17_VSMOW = 0.00038475

Absolute (17O/16C) ratio of VSMOW. By default equal to 0.00038475 (Assonov & Brenninkmeijer, 2003, rescaled to `R13_VPDB`)

R18_VPDB = 0.0020672007840000003

Absolute (18O/16C) ratio of VPDB. By definition equal to `R18_VSMOW * 1.03092`.

R17_VPDB = 0.0003909861828790272

Absolute (17O/16C) ratio of VPDB. By definition equal to `R17_VSMOW * 1.03092 ** LAMBDA_17`.

LEVENE_REF_SAMPLE = 'ETH-3'

After the Δ4x standardization step, each sample is tested to assess whether the Δ4x variance within all analyses for that sample differs significantly from that observed for a given reference sample (using Levene's test, which yields a p-value corresponding to the null hypothesis that the underlying variances are equal).

`LEVENE_REF_SAMPLE` (by default equal to `'ETH-3'`) specifies which sample should be used as a reference for this test.

ALPHA_18O_ACID_REACTION = 1.008129

Specifies the 18O/16O fractionation factor generally applicable to acid reactions in the dataset. Currently used by `D4xdata.wg()`, `D4xdata.standardize_d13C`, and `D4xdata.standardize_d18O`.

By default equal to 1.008129 (calcite reacted at 90 °C, Kim et al., 2007).

Nominal_d13C_VPDB = {'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}

Nominal δ13CVPDB values assigned to carbonate standards, used by `D4xdata.standardize_d13C()`.

By default equal to `{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}` after Bernasconi et al. (2018).

Nominal_d18O_VPDB = {'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}

Nominal δ18OVPDB values assigned to carbonate standards, used by `D4xdata.standardize_d18O()`.

By default equal to `{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}` after Bernasconi et al. (2018).

d13C_STANDARDIZATION_METHOD = '2pt'

Method by which to standardize δ13C values:

- `none`: do not apply any δ13C standardization.
- `'1pt'`: within each session, offset all initial δ13C values so as to minimize the difference between final δ13CVPDB values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined).
- `'2pt'`: within each session, apply a affine trasformation to all δ13C values so as to minimize the difference between final δ13CVPDB values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined).

d18O_STANDARDIZATION_METHOD = '2pt'

Method by which to standardize δ18O values:

- `none`: do not apply any δ18O standardization.
- `'1pt'`: within each session, offset all initial δ18O values so as to minimize the difference between final δ18OVPDB values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined).
- `'2pt'`: within each session, apply a affine trasformation to all δ18O values so as to minimize the difference between final δ18OVPDB values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined).

def make_verbal(oldfun):
View Source

    	def make_verbal(oldfun):
    		'''
    		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
    		'''
    		@wraps(oldfun)
    		def newfun(*args, verbose = '', **kwargs):
    			myself = args[0]
    			oldprefix = myself.prefix
    			myself.prefix = oldfun.__name__
    			if verbose != '':
    				oldverbose = myself.verbose
    				myself.verbose = verbose
    			out = oldfun(*args, **kwargs)
    			myself.prefix = oldprefix
    			if verbose != '':
    				myself.verbose = oldverbose
    			return out
    		return newfun

Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.

def msg(self, txt):
View Source

    	def msg(self, txt):
    		'''
    		Log a message to `self.logfile`, and print it out if `verbose = True`
    		'''
    		self.log(txt)
    		if self.verbose:
    			print(f'{f"[{self.prefix}]":<16} {txt}')

Log a message to `self.logfile`, and print it out if `verbose = True`

def vmsg(self, txt):
View Source

    	def vmsg(self, txt):
    		'''
    		Log a message to `self.logfile` and print it out
    		'''
    		self.log(txt)
    		print(txt)

Log a message to `self.logfile` and print it out

def log(self, *txts):
View Source

    	def log(self, *txts):
    		'''
    		Log a message to `self.logfile`
    		'''
    		if self.logfile:
    			with open(self.logfile, 'a') as fid:
    				for txt in txts:
    					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')

Log a message to `self.logfile`

def refresh(self, session='mySession'):
View Source

    	def refresh(self, session = 'mySession'):
    		'''
    		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    		'''
    		self.fill_in_missing_info(session = session)
    		self.refresh_sessions()
    		self.refresh_samples()

Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.

def refresh_sessions(self):
View Source

    	def refresh_sessions(self):
    		'''
    		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    		to `False` for all sessions.
    		'''
    		self.sessions = {
    			s: {'data': [r for r in self if r['Session'] == s]}
    			for s in sorted({r['Session'] for r in self})
    			}
    		for s in self.sessions:
    			self.sessions[s]['scrambling_drift'] = False
    			self.sessions[s]['slope_drift'] = False
    			self.sessions[s]['wg_drift'] = False
    			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD

Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift` to `False` for all sessions.

def refresh_samples(self):
View Source

    	def refresh_samples(self):
    		'''
    		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    		'''
    		self.samples = {
    			s: {'data': [r for r in self if r['Sample'] == s]}
    			for s in sorted({r['Sample'] for r in self})
    			}
    		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}

Define `self.samples`, `self.anchors`, and `self.unknowns`.

def read(self, filename, sep='', session=''):
View Source

    	def read(self, filename, sep = '', session = ''):
    		'''
    		Read file in csv format to load data into a `D47data` object.

    		In the csv file, spaces before and after field separators (`','` by default)
    		are optional. Each line corresponds to a single analysis.

    		The required fields are:

    		+ `UID`: a unique identifier
    		+ `Session`: an identifier for the analytical session
    		+ `Sample`: a sample identifier
    		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values

    		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    		and `d49` are optional, and set to NaN by default.

    		**Parameters**

    		+ `fileneme`: the path of the file to read
    		+ `sep`: csv separator delimiting the fields
    		+ `session`: set `Session` field to this string for all analyses
    		'''
    		with open(filename) as fid:
    			self.input(fid.read(), sep = sep, session = session)

Read file in csv format to load data into a `D47data` object.

In the csv file, spaces before and after field separators (`','` by default) are optional. Each line corresponds to a single analysis.

The required fields are:

- `UID`: a unique identifier
- `Session`: an identifier for the analytical session
- `Sample`: a sample identifier
- `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values

Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48` and `d49` are optional, and set to NaN by default.

Parameters

- `fileneme`: the path of the file to read
- `sep`: csv separator delimiting the fields
- `session`: set `Session` field to this string for all analyses

def input(self, txt, sep='', session=''):
View Source

    	def input(self, txt, sep = '', session = ''):
    		'''
    		Read `txt` string in csv format to load analysis data into a `D47data` object.

    		In the csv string, spaces before and after field separators (`','` by default)
    		are optional. Each line corresponds to a single analysis.

    		The required fields are:

    		+ `UID`: a unique identifier
    		+ `Session`: an identifier for the analytical session
    		+ `Sample`: a sample identifier
    		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values

    		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    		and `d49` are optional, and set to NaN by default.

    		**Parameters**

    		+ `txt`: the csv string to read
    		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    		whichever appers most often in `txt`.
    		+ `session`: set `Session` field to this string for all analyses
    		'''
    		if sep == '':
    			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    		data = [{k: v if k in ['UID', 'Session', 'Sample'] else smart_type(v) for k,v in zip(txt[0], l) if v != ''} for l in txt[1:]]

    		if session != '':
    			for r in data:
    				r['Session'] = session

    		self += data
    		self.refresh()

Read `txt` string in csv format to load analysis data into a `D47data` object.

In the csv string, spaces before and after field separators (`','` by default) are optional. Each line corresponds to a single analysis.

The required fields are:

- `UID`: a unique identifier
- `Session`: an identifier for the analytical session
- `Sample`: a sample identifier
- `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values

Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48` and `d49` are optional, and set to NaN by default.

Parameters

- `txt`: the csv string to read
- `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or ``, whichever appers most often in `txt`.
- `session`: set `Session` field to this string for all analyses

@make_verbal
def wg(self, samples=None, a18_acid=None):
View Source

    	@make_verbal
    	def wg(self, samples = None, a18_acid = None):
    		'''
    		Compute bulk composition of the working gas for each session based on
    		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
    		`self.Nominal_d18O_VPDB`.
    		'''

    		self.msg('Computing WG composition:')

    		if a18_acid is None:
    			a18_acid = self.ALPHA_18O_ACID_REACTION
    		if samples is None:
    			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]

    		assert a18_acid, f'Acid fractionation factor should not be zero.'

    		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
    		R45R46_standards = {}
    		for sample in samples:
    			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
    			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
    			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
    			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
    			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid

    			C12_s = 1 / (1 + R13_s)
    			C13_s = R13_s / (1 + R13_s)
    			C16_s = 1 / (1 + R17_s + R18_s)
    			C17_s = R17_s / (1 + R17_s + R18_s)
    			C18_s = R18_s / (1 + R17_s + R18_s)

    			C626_s = C12_s * C16_s ** 2
    			C627_s = 2 * C12_s * C16_s * C17_s
    			C628_s = 2 * C12_s * C16_s * C18_s
    			C636_s = C13_s * C16_s ** 2
    			C637_s = 2 * C13_s * C16_s * C17_s
    			C727_s = C12_s * C17_s ** 2

    			R45_s = (C627_s + C636_s) / C626_s
    			R46_s = (C628_s + C637_s + C727_s) / C626_s
    			R45R46_standards[sample] = (R45_s, R46_s)

    		for s in self.sessions:
    			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
    			assert db, f'No sample from {samples} found in session "{s}".'
    # 			dbsamples = sorted({r['Sample'] for r in db})

    			X = [r['d45'] for r in db]
    			Y = [R45R46_standards[r['Sample']][0] for r in db]
    			x1, x2 = np.min(X), np.max(X)

    			if x1 < x2:
    				wgcoord = x1/(x1-x2)
    			else:
    				wgcoord = 999

    			if wgcoord < -.5 or wgcoord > 1.5:
    				# unreasonable to extrapolate to d45 = 0
    				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    			else :
    				# d45 = 0 is reasonably well bracketed
    				R45_wg = np.polyfit(X, Y, 1)[1]

    			X = [r['d46'] for r in db]
    			Y = [R45R46_standards[r['Sample']][1] for r in db]
    			x1, x2 = np.min(X), np.max(X)

    			if x1 < x2:
    				wgcoord = x1/(x1-x2)
    			else:
    				wgcoord = 999

    			if wgcoord < -.5 or wgcoord > 1.5:
    				# unreasonable to extrapolate to d46 = 0
    				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    			else :
    				# d46 = 0 is reasonably well bracketed
    				R46_wg = np.polyfit(X, Y, 1)[1]

    			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)

    			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')

    			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
    			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
    			for r in self.sessions[s]['data']:
    				r['d13Cwg_VPDB'] = d13Cwg_VPDB
    				r['d18Owg_VSMOW'] = d18Owg_VSMOW

Compute bulk composition of the working gas for each session based on the carbonate standards defined in both `self.Nominal_d13C_VPDB` and `self.Nominal_d18O_VPDB`.

def compute_bulk_delta(self, R45, R46, D17O=0):
View Source

    	def compute_bulk_delta(self, R45, R46, D17O = 0):
    		'''
    		Compute δ13C_VPDB and δ18O_VSMOW,
    		by solving the generalized form of equation (17) from
    		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
    		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
    		solving the corresponding second-order Taylor polynomial.
    		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
    		'''

    		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17

    		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
    		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
    		C = 2 * self.R18_VSMOW
    		D = -R46

    		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
    		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
    		cc = A + B + C + D

    		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)

    		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
    		R17 = K * R18 ** self.LAMBDA_17
    		R13 = R45 - 2 * R17

    		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)

    		return d13C_VPDB, d18O_VSMOW

Compute δ13CVPDB and δ18OVSMOW, by solving the generalized form of equation (17) from Brand et al. (2010), assuming that δ18OVSMOW is not too big (0 ± 50 ‰) and solving the corresponding second-order Taylor polynomial. (Appendix A of Daëron et al., 2016)

@make_verbal
def crunch(self, verbose=''):
View Source

    	@make_verbal
    	def crunch(self, verbose = ''):
    		'''
    		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    		'''
    		for r in self:
    			self.compute_bulk_and_clumping_deltas(r)
    		self.standardize_d13C()
    		self.standardize_d18O()
    		self.msg(f"Crunched {len(self)} analyses.")

Compute bulk composition and raw clumped isotope anomalies for all analyses.

def fill_in_missing_info(self, session='mySession'):
View Source

    	def fill_in_missing_info(self, session = 'mySession'):
    		'''
    		Fill in optional fields with default values
    		'''
    		for i,r in enumerate(self):
    			if 'D17O' not in r:
    				r['D17O'] = 0.
    			if 'UID' not in r:
    				r['UID'] = f'{i+1}'
    			if 'Session' not in r:
    				r['Session'] = session
    			for k in ['d47', 'd48', 'd49']:
    				if k not in r:
    					r[k] = np.nan

Fill in optional fields with default values

def standardize_d13C(self):
View Source

    	def standardize_d13C(self):
    		'''
    		Perform δ13C standadization within each session `s` according to
    		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
    		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
    		may be redefined abitrarily at a later stage.
    		'''
    		for s in self.sessions:
    			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
    				XY = [(r['d13C_VPDB'], self.Nominal_d13C_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d13C_VPDB]
    				X,Y = zip(*XY)
    				if self.sessions[s]['d13C_standardization_method'] == '1pt':
    					offset = np.mean(Y) - np.mean(X)
    					for r in self.sessions[s]['data']:
    						r['d13C_VPDB'] += offset
    				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
    					a,b = np.polyfit(X,Y,1)
    					for r in self.sessions[s]['data']:
    						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b

Perform δ13C standadization within each session `s` according to `self.sessions[s]['d13C_standardization_method']`, which is defined by default by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.

def standardize_d18O(self):
View Source

    	def standardize_d18O(self):
    		'''
    		Perform δ18O standadization within each session `s` according to
    		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
    		which is defined by default by `D47data.refresh_sessions()`as equal to
    		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
    		'''
    		for s in self.sessions:
    			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
    				XY = [(r['d18O_VSMOW'], self.Nominal_d18O_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d18O_VPDB]
    				X,Y = zip(*XY)
    				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
    				if self.sessions[s]['d18O_standardization_method'] == '1pt':
    					offset = np.mean(Y) - np.mean(X)
    					for r in self.sessions[s]['data']:
    						r['d18O_VSMOW'] += offset
    				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
    					a,b = np.polyfit(X,Y,1)
    					for r in self.sessions[s]['data']:
    						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b

Perform δ18O standadization within each session `s` according to `self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`, which is defined by default by `D47data.refresh_sessions()`as equal to `self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.

def compute_bulk_and_clumping_deltas(self, r):
View Source

    	def compute_bulk_and_clumping_deltas(self, r):
    		'''
    		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
    		'''

    		# Compute working gas R13, R18, and isobar ratios
    		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
    		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
    		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)

    		# Compute analyte isobar ratios
    		R45 = (1 + r['d45'] / 1000) * R45_wg
    		R46 = (1 + r['d46'] / 1000) * R46_wg
    		R47 = (1 + r['d47'] / 1000) * R47_wg
    		R48 = (1 + r['d48'] / 1000) * R48_wg
    		R49 = (1 + r['d49'] / 1000) * R49_wg

    		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
    		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
    		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW

    		# Compute stochastic isobar ratios of the analyte
    		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
    			R13, R18, D17O = r['D17O']
    		)

    		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
    		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
    		if (R45 / R45stoch - 1) > 5e-8:
    			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
    		if (R46 / R46stoch - 1) > 5e-8:
    			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')

    		# Compute raw clumped isotope anomalies
    		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
    		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
    		r['D49raw'] = 1000 * (R49 / R49stoch - 1)

Compute δ13CVPDB, δ18OVSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.

def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
View Source

    	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
    		'''
    		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
    		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
    		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
    		'''

    		# Compute R17
    		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17

    		# Compute isotope concentrations
    		C12 = (1 + R13) ** -1
    		C13 = C12 * R13
    		C16 = (1 + R17 + R18) ** -1
    		C17 = C16 * R17
    		C18 = C16 * R18

    		# Compute stochastic isotopologue concentrations
    		C626 = C16 * C12 * C16
    		C627 = C16 * C12 * C17 * 2
    		C628 = C16 * C12 * C18 * 2
    		C636 = C16 * C13 * C16
    		C637 = C16 * C13 * C17 * 2
    		C638 = C16 * C13 * C18 * 2
    		C727 = C17 * C12 * C17
    		C728 = C17 * C12 * C18 * 2
    		C737 = C17 * C13 * C17
    		C738 = C17 * C13 * C18 * 2
    		C828 = C18 * C12 * C18
    		C838 = C18 * C13 * C18

    		# Compute stochastic isobar ratios
    		R45 = (C636 + C627) / C626
    		R46 = (C628 + C637 + C727) / C626
    		R47 = (C638 + C728 + C737) / C626
    		R48 = (C738 + C828) / C626
    		R49 = C838 / C626

    		# Account for stochastic anomalies
    		R47 *= 1 + D47 / 1000
    		R48 *= 1 + D48 / 1000
    		R49 *= 1 + D49 / 1000

    		# Return isobar ratios
    		return R45, R46, R47, R48, R49

Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`, optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope anomalies (`D47`, `D48`, `D49`), all expressed in permil.

def split_samples(self, samples_to_split='all', grouping='by_session'):
View Source

    	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
    		'''
    		Split unknown samples by UID (treat all analyses as different samples)
    		or by session (treat analyses of a given sample in different sessions as
    		different samples).

    		**Parameters**

    		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
    		+ `grouping`: `by_uid` | `by_session`
    		'''
    		if samples_to_split == 'all':
    			samples_to_split = [s for s in self.unknowns]
    		gkeys = {'by_uid':'UID', 'by_session':'Session'}
    		self.grouping = grouping.lower()
    		if self.grouping in gkeys:
    			gkey = gkeys[self.grouping]
    		for r in self:
    			if r['Sample'] in samples_to_split:
    				r['Sample_original'] = r['Sample']
    				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
    			elif r['Sample'] in self.unknowns:
    				r['Sample_original'] = r['Sample']
    		self.refresh_samples()

Split unknown samples by UID (treat all analyses as different samples) or by session (treat analyses of a given sample in different sessions as different samples).

Parameters

- `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
- `grouping`: `by_uid` | `by_session`

def unsplit_samples(self, tables=False):
View Source

    	def unsplit_samples(self, tables = False):
    		'''
    		Reverse the effects of `D47data.split_samples()`.

    		This should only be used after `D4xdata.standardize()` with `method='pooled'`.

    		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
    		probably use `D4xdata.combine_samples()` instead to reverse the effects of
    		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
    		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
    		that case session-averaged Δ4x values are statistically independent).
    		'''
    		unknowns_old = sorted({s for s in self.unknowns})
    		CM_old = self.standardization.covar[:,:]
    		VD_old = self.standardization.params.valuesdict().copy()
    		vars_old = self.standardization.var_names

    		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})

    		Ns = len(vars_old) - len(unknowns_old)
    		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
    		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}

    		W = np.zeros((len(vars_new), len(vars_old)))
    		W[:Ns,:Ns] = np.eye(Ns)
    		for u in unknowns_new:
    			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
    			if self.grouping == 'by_session':
    				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
    			elif self.grouping == 'by_uid':
    				weights = [1 for s in splits]
    			sw = sum(weights)
    			weights = [w/sw for w in weights]
    			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]

    		CM_new = W @ CM_old @ W.T
    		V = W @ np.array([[VD_old[k]] for k in vars_old])
    		VD_new = {k:v[0] for k,v in zip(vars_new, V)}

    		self.standardization.covar = CM_new
    		self.standardization.params.valuesdict = lambda : VD_new
    		self.standardization.var_names = vars_new

    		for r in self:
    			if r['Sample'] in self.unknowns:
    				r['Sample_split'] = r['Sample']
    				r['Sample'] = r['Sample_original']

    		self.refresh_samples()
    		self.consolidate_samples()
    		self.repeatabilities()

    		if tables:
    			self.table_of_analyses()
    			self.table_of_samples()

Reverse the effects of `D47data.split_samples()`.

This should only be used after `D4xdata.standardize()` with `method='pooled'`.

After `D4xdata.standardize()` with `method='indep_sessions'`, one should probably use `D4xdata.combine_samples()` instead to reverse the effects of `D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in that case session-averaged Δ4x values are statistically independent).

def assign_timestamps(self):
View Source

    	def assign_timestamps(self):
    		'''
    		Assign a time field `t` of type `float` to each analysis.

    		If `TimeTag` is one of the data fields, `t` is equal within a given session
    		to `TimeTag` minus the mean value of `TimeTag` for that session.
    		Otherwise, `TimeTag` is by default equal to the index of each analysis
    		in the dataset and `t` is defined as above.
    		'''
    		for session in self.sessions:
    			sdata = self.sessions[session]['data']
    			try:
    				t0 = np.mean([r['TimeTag'] for r in sdata])
    				for r in sdata:
    					r['t'] = r['TimeTag'] - t0
    			except KeyError:
    				t0 = (len(sdata)-1)/2
    				for t,r in enumerate(sdata):
    					r['t'] = t - t0

Assign a time field `t` of type `float` to each analysis.

If `TimeTag` is one of the data fields, `t` is equal within a given session to `TimeTag` minus the mean value of `TimeTag` for that session. Otherwise, `TimeTag` is by default equal to the index of each analysis in the dataset and `t` is defined as above.

def report(self):
View Source

    	def report(self):
    		'''
    		Prints a report on the standardization fit.
    		Only applicable after `D4xdata.standardize(method='pooled')`.
    		'''
    		report_fit(self.standardization)

Prints a report on the standardization fit. Only applicable after `D4xdata.standardize(method='pooled')`.

def combine_samples(self, sample_groups):
View Source

    	def combine_samples(self, sample_groups):
    		'''
    		Combine analyses of different samples to compute weighted average Δ4x
    		and new error (co)variances corresponding to the groups defined by the `sample_groups`
    		dictionary.

    		Caution: samples are weighted by number of replicate analyses, which is a
    		reasonable default behavior but is not always optimal (e.g., in the case of strongly
    		correlated analytical errors for one or more samples).

    		Returns a tuplet of:

    		+ the list of group names
    		+ an array of the corresponding Δ4x values
    		+ the corresponding (co)variance matrix

    		**Parameters**

    		+ `sample_groups`: a dictionary of the form:
    		```py
    		{'group1': ['sample_1', 'sample_2'],
    		 'group2': ['sample_3', 'sample_4', 'sample_5']}
    		```
    		'''

    		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
    		groups = sorted(sample_groups.keys())
    		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
    		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
    		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
    		W = np.array([
    			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
    			for j in groups])
    		D4x_new = W @ D4x_old
    		CM_new = W @ CM_old @ W.T

    		return groups, D4x_new[:,0], CM_new

Combine analyses of different samples to compute weighted average Δ4x and new error (co)variances corresponding to the groups defined by the `sample_groups` dictionary.

Caution: samples are weighted by number of replicate analyses, which is a reasonable default behavior but is not always optimal (e.g., in the case of strongly correlated analytical errors for one or more samples).

Returns a tuplet of:

- the list of group names
- an array of the corresponding Δ4x values
- the corresponding (co)variance matrix

Parameters

- `sample_groups`: a dictionary of the form:

    {'group1': ['sample_1', 'sample_2'],
     'group2': ['sample_3', 'sample_4', 'sample_5']}

@make_verbal
def standardize( self, method='pooled', weighted_sessions=[], consolidate=True, consolidate_tables=False, consolidate_plots=False, constraints={} ):
View Source

    	@make_verbal
    	def standardize(self,
    		method = 'pooled',
    		weighted_sessions = [],
    		consolidate = True,
    		consolidate_tables = False,
    		consolidate_plots = False,
    		constraints = {},
    		):
    		'''
    		Compute absolute Δ4x values for all replicate analyses and for sample averages.
    		If `method` argument is set to `'pooled'`, the standardization processes all sessions
    		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
    		i.e. that their true Δ4x value does not change between sessions,
    		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
    		`'indep_sessions'`, the standardization processes each session independently, based only
    		on anchors analyses.
    		'''

    		self.standardization_method = method
    		self.assign_timestamps()

    		if method == 'pooled':
    			if weighted_sessions:
    				for session_group in weighted_sessions:
    					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
    					X.Nominal_D4x = self.Nominal_D4x.copy()
    					X.refresh()
    					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
    					w = np.sqrt(result.redchi)
    					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
    					for r in X:
    						r[f'wD{self._4x}raw'] *= w
    			else:
    				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
    				for r in self:
    					r[f'wD{self._4x}raw'] = 1.

    			params = Parameters()
    			for k,session in enumerate(self.sessions):
    				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
    				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
    				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
    				s = pf(session)
    				params.add(f'a_{s}', value = 0.9)
    				params.add(f'b_{s}', value = 0.)
    				params.add(f'c_{s}', value = -0.9)
    				params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
    				params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
    				params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
    			for sample in self.unknowns:
    				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)

    			for k in constraints:
    				params[k].expr = constraints[k]

    			def residuals(p):
    				R = []
    				for r in self:
    					session = pf(r['Session'])
    					sample = pf(r['Sample'])
    					if r['Sample'] in self.Nominal_D4x:
    						R += [ (
    							r[f'D{self._4x}raw'] - (
    								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
    								+ p[f'b_{session}'] * r[f'd{self._4x}']
    								+	p[f'c_{session}']
    								+ r['t'] * (
    									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
    									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    									+	p[f'c2_{session}']
    									)
    								)
    							) / r[f'wD{self._4x}raw'] ]
    					else:
    						R += [ (
    							r[f'D{self._4x}raw'] - (
    								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
    								+ p[f'b_{session}'] * r[f'd{self._4x}']
    								+	p[f'c_{session}']
    								+ r['t'] * (
    									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
    									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    									+	p[f'c2_{session}']
    									)
    								)
    							) / r[f'wD{self._4x}raw'] ]
    				return R

    			M = Minimizer(residuals, params)
    			result = M.least_squares()
    			self.Nf = result.nfree
    			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
    # 			if self.verbose:
    # 				report_fit(result)

    			for r in self:
    				s = pf(r["Session"])
    				a = result.params.valuesdict()[f'a_{s}']
    				b = result.params.valuesdict()[f'b_{s}']
    				c = result.params.valuesdict()[f'c_{s}']
    				a2 = result.params.valuesdict()[f'a2_{s}']
    				b2 = result.params.valuesdict()[f'b2_{s}']
    				c2 = result.params.valuesdict()[f'c2_{s}']
    				r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])

    			self.standardization = result

    			for session in self.sessions:
    				self.sessions[session]['Np'] = 3
    				for k in ['scrambling', 'slope', 'wg']:
    					if self.sessions[session][f'{k}_drift']:
    						self.sessions[session]['Np'] += 1

    			if consolidate:
    				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
    			return result

    		elif method == 'indep_sessions':

    			if weighted_sessions:
    				for session_group in weighted_sessions:
    					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
    					X.Nominal_D4x = self.Nominal_D4x.copy()
    					X.refresh()
    					# This is only done to assign r['wD47raw'] for r in X:
    					X.standardize(method = method, weighted_sessions = [], consolidate = False)
    					self.msg(f'D{self._4x}raw weights set to {1000*X[0][f"wD{self._4x}raw"]:.1f} ppm for sessions in {session_group}')
    			else:
    				self.msg('All weights set to 1 ‰')
    				for r in self:
    					r[f'wD{self._4x}raw'] = 1

    			for session in self.sessions:
    				s = self.sessions[session]
    				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
    				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
    				s['Np'] = sum(p_active)
    				sdata = s['data']

    				A = np.array([
    					[
    						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
    						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
    						1 / r[f'wD{self._4x}raw'],
    						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
    						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
    						r['t'] / r[f'wD{self._4x}raw']
    						]
    					for r in sdata if r['Sample'] in self.anchors
    					])[:,p_active] # only keep columns for the active parameters
    				Y = np.array([[r[f'D{self._4x}raw'] / r[f'wD{self._4x}raw']] for r in sdata if r['Sample'] in self.anchors])
    				s['Na'] = Y.size
    				CM = linalg.inv(A.T @ A)
    				bf = (CM @ A.T @ Y).T[0,:]
    				k = 0
    				for n,a in zip(p_names, p_active):
    					if a:
    						s[n] = bf[k]
    # 						self.msg(f'{n} = {bf[k]}')
    						k += 1
    					else:
    						s[n] = 0.
    # 						self.msg(f'{n} = 0.0')

    				for r in sdata :
    					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
    					r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
    					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])

    				s['CM'] = np.zeros((6,6))
    				i = 0
    				k_active = [j for j,a in enumerate(p_active) if a]
    				for j,a in enumerate(p_active):
    					if a:
    						s['CM'][j,k_active] = CM[i,:]
    						i += 1

    			if not weighted_sessions:
    				w = self.rmswd()['rmswd']
    				for r in self:
    						r[f'wD{self._4x}'] *= w
    						r[f'wD{self._4x}raw'] *= w
    				for session in self.sessions:
    					self.sessions[session]['CM'] *= w**2

    			for session in self.sessions:
    				s = self.sessions[session]
    				s['SE_a'] = s['CM'][0,0]**.5
    				s['SE_b'] = s['CM'][1,1]**.5
    				s['SE_c'] = s['CM'][2,2]**.5
    				s['SE_a2'] = s['CM'][3,3]**.5
    				s['SE_b2'] = s['CM'][4,4]**.5
    				s['SE_c2'] = s['CM'][5,5]**.5

    			if not weighted_sessions:
    				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
    			else:
    				self.Nf = 0
    				for sg in weighted_sessions:
    					self.Nf += self.rmswd(sessions = sg)['Nf']

    			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)

    			avgD4x = {
    				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
    				for sample in self.samples
    				}
    			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
    			rD4x = (chi2/self.Nf)**.5
    			self.repeatability[f'sigma_{self._4x}'] = rD4x

    			if consolidate:
    				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)

Compute absolute Δ4x values for all replicate analyses and for sample averages. If `method` argument is set to `'pooled'`, the standardization processes all sessions in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous, i.e. that their true Δ4x value does not change between sessions, (Daëron, 2021). If `method` argument is set to `'indep_sessions'`, the standardization processes each session independently, based only on anchors analyses.

def standardization_error(self, session, d4x, D4x, t=0):
View Source

    	def standardization_error(self, session, d4x, D4x, t = 0):
    		'''
    		Compute standardization error for a given session and
    		(δ47, Δ47) composition.
    		'''
    		a = self.sessions[session]['a']
    		b = self.sessions[session]['b']
    		c = self.sessions[session]['c']
    		a2 = self.sessions[session]['a2']
    		b2 = self.sessions[session]['b2']
    		c2 = self.sessions[session]['c2']
    		CM = self.sessions[session]['CM']

    		x, y = D4x, d4x
    		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
    # 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
    		dxdy = -(b+b2*t) / (a+a2*t)
    		dxdz = 1. / (a+a2*t)
    		dxda = -x / (a+a2*t)
    		dxdb = -y / (a+a2*t)
    		dxdc = -1. / (a+a2*t)
    		dxda2 = -x * a2 / (a+a2*t)
    		dxdb2 = -y * t / (a+a2*t)
    		dxdc2 = -t / (a+a2*t)
    		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
    		sx = (V @ CM @ V.T) ** .5
    		return sx

Compute standardization error for a given session and (δ47, Δ47) composition.

@make_verbal
def summary(self, dir='output', filename=None, save_to_file=True, print_out=True):
View Source

    	@make_verbal
    	def summary(self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		):
    		'''
    		Print out an/or save to disk a summary of the standardization results.

    		**Parameters**

    		+ `dir`: the directory in which to save the table
    		+ `filename`: the name to the csv file to write to
    		+ `save_to_file`: whether to save the table to disk
    		+ `print_out`: whether to print out the table
    		'''

    		out = []
    		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
    		out += [['N analyses (anchors + unknowns)', f"{len(self)} ({len([r for r in self if r['Sample'] in self.anchors])} + {len([r for r in self if r['Sample'] in self.unknowns])})"]]
    		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
    		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
    		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
    		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
    		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
    		out += [['Model degrees of freedom', f"{self.Nf}"]]
    		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
    		out += [['Standardization method', self.standardization_method]]

    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_summary.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n' + pretty_table(out, header = 0))

Print out an/or save to disk a summary of the standardization results.

Parameters

- `dir`: the directory in which to save the table
- `filename`: the name to the csv file to write to
- `save_to_file`: whether to save the table to disk
- `print_out`: whether to print out the table

@make_verbal
def table_of_sessions( self, dir='output', filename=None, save_to_file=True, print_out=True, output=None ):
View Source

    	@make_verbal
    	def table_of_sessions(self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out an/or save to disk a table of sessions.

    		**Parameters**

    		+ `dir`: the directory in which to save the table
    		+ `filename`: the name to the csv file to write to
    		+ `save_to_file`: whether to save the table to disk
    		+ `print_out`: whether to print out the table
    		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''
    		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
    		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
    		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])

    		out = [['Session','Na','Nu','d13Cwg_VPDB','d18Owg_VSMOW','r_d13C','r_d18O',f'r_D{self._4x}','a ± SE','1e3 x b ± SE','c ± SE']]
    		if include_a2:
    			out[-1] += ['a2 ± SE']
    		if include_b2:
    			out[-1] += ['b2 ± SE']
    		if include_c2:
    			out[-1] += ['c2 ± SE']
    		for session in self.sessions:
    			out += [[
    				session,
    				f"{self.sessions[session]['Na']}",
    				f"{self.sessions[session]['Nu']}",
    				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
    				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
    				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
    				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
    				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
    				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
    				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
    				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
    				]]
    			if include_a2:
    				if self.sessions[session]['scrambling_drift']:
    					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
    				else:
    					out[-1] += ['']
    			if include_b2:
    				if self.sessions[session]['slope_drift']:
    					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
    				else:
    					out[-1] += ['']
    			if include_c2:
    				if self.sessions[session]['wg_drift']:
    					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
    				else:
    					out[-1] += ['']

    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_sessions.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n' + pretty_table(out))
    		if output == 'raw':
    			return out
    		elif output == 'pretty':
    			return pretty_table(out)

Print out an/or save to disk a table of sessions.

Parameters

- `dir`: the directory in which to save the table
- `filename`: the name to the csv file to write to
- `save_to_file`: whether to save the table to disk
- `print_out`: whether to print out the table
- `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); if set to `'raw'`: return a list of list of strings (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)

@make_verbal
def table_of_analyses( self, dir='output', filename=None, save_to_file=True, print_out=True, output=None ):
View Source

    	@make_verbal
    	def table_of_analyses(
    		self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out an/or save to disk a table of analyses.

    		**Parameters**

    		+ `dir`: the directory in which to save the table
    		+ `filename`: the name to the csv file to write to
    		+ `save_to_file`: whether to save the table to disk
    		+ `print_out`: whether to print out the table
    		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''

    		out = [['UID','Session','Sample']]
    		extra_fields = [f for f in [('SampleMass','.2f'),('ColdFingerPressure','.1f'),('AcidReactionYield','.3f')] if f[0] in {k for r in self for k in r}]
    		for f in extra_fields:
    			out[-1] += [f[0]]
    		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
    		for r in self:
    			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
    			for f in extra_fields:
    				out[-1] += [f"{r[f[0]]:{f[1]}}"]
    			out[-1] += [
    				f"{r['d13Cwg_VPDB']:.3f}",
    				f"{r['d18Owg_VSMOW']:.3f}",
    				f"{r['d45']:.6f}",
    				f"{r['d46']:.6f}",
    				f"{r['d47']:.6f}",
    				f"{r['d48']:.6f}",
    				f"{r['d49']:.6f}",
    				f"{r['d13C_VPDB']:.6f}",
    				f"{r['d18O_VSMOW']:.6f}",
    				f"{r['D47raw']:.6f}",
    				f"{r['D48raw']:.6f}",
    				f"{r['D49raw']:.6f}",
    				f"{r[f'D{self._4x}']:.6f}"
    				]
    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_analyses.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n' + pretty_table(out))
    		return out

Print out an/or save to disk a table of analyses.

Parameters

- `dir`: the directory in which to save the table
- `filename`: the name to the csv file to write to
- `save_to_file`: whether to save the table to disk
- `print_out`: whether to print out the table
- `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); if set to `'raw'`: return a list of list of strings (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)

@make_verbal
def covar_table( self, correl=False, dir='output', filename=None, save_to_file=True, print_out=True, output=None ):
View Source

    	@make_verbal
    	def covar_table(
    		self,
    		correl = False,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out, save to disk and/or return the variance-covariance matrix of D4x
    		for all unknown samples.

    		**Parameters**

    		+ `dir`: the directory in which to save the csv
    		+ `filename`: the name of the csv file to write to
    		+ `save_to_file`: whether to save the csv
    		+ `print_out`: whether to print out the matrix
    		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''
    		samples = sorted([u for u in self.unknowns])
    		out = [[''] + samples]
    		for s1 in samples:
    			out.append([s1])
    			for s2 in samples:
    				if correl:
    					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
    				else:
    					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')

    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				if correl:
    					filename = f'D{self._4x}_correl.csv'
    				else:
    					filename = f'D{self._4x}_covar.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n'+pretty_table(out))
    		if output == 'raw':
    			return out
    		elif output == 'pretty':
    			return pretty_table(out)

Print out, save to disk and/or return the variance-covariance matrix of D4x for all unknown samples.

Parameters

- `dir`: the directory in which to save the csv
- `filename`: the name of the csv file to write to
- `save_to_file`: whether to save the csv
- `print_out`: whether to print out the matrix
- `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`); if set to `'raw'`: return a list of list of strings (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)

@make_verbal
def table_of_samples( self, dir='output', filename=None, save_to_file=True, print_out=True, output=None ):
View Source

    	@make_verbal
    	def table_of_samples(
    		self,
    		dir = 'output',
    		filename = None,
    		save_to_file = True,
    		print_out = True,
    		output = None,
    		):
    		'''
    		Print out, save to disk and/or return a table of samples.

    		**Parameters**

    		+ `dir`: the directory in which to save the csv
    		+ `filename`: the name of the csv file to write to
    		+ `save_to_file`: whether to save the csv
    		+ `print_out`: whether to print out the table
    		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    		    if set to `'raw'`: return a list of list of strings
    		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    		'''

    		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
    		for sample in self.anchors:
    			out += [[
    				f"{sample}",
    				f"{self.samples[sample]['N']}",
    				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
    				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
    				]]
    		for sample in self.unknowns:
    			out += [[
    				f"{sample}",
    				f"{self.samples[sample]['N']}",
    				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    				f"{self.samples[sample][f'D{self._4x}']:.4f}",
    				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
    				f"± {self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
    				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
    				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
    				]]
    		if save_to_file:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename is None:
    				filename = f'D{self._4x}_samples.csv'
    			with open(f'{dir}/{filename}', 'w') as fid:
    				fid.write(make_csv(out))
    		if print_out:
    			self.msg('\n'+pretty_table(out))
    		if output == 'raw':
    			return out
    		elif output == 'pretty':
    			return pretty_table(out)

Print out, save to disk and/or return a table of samples.

Parameters

- `dir`: the directory in which to save the csv
- `filename`: the name of the csv file to write to
- `save_to_file`: whether to save the csv
- `print_out`: whether to print out the table
- `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); if set to `'raw'`: return a list of list of strings (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)

def plot_sessions(self, dir='output', figsize=(8, 8)):
View Source

    	def plot_sessions(self, dir = 'output', figsize = (8,8)):
    		'''
    		Generate session plots and save them to disk.

    		**Parameters**

    		+ `dir`: the directory in which to save the plots
    		+ `figsize`: the width and height (in inches) of each plot
    		'''
    		if not os.path.exists(dir):
    			os.makedirs(dir)

    		for session in self.sessions:
    			sp = self.plot_single_session(session, xylimits = 'constant')
    			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
    			ppl.close(sp.fig)

Generate session plots and save them to disk.

Parameters

- `dir`: the directory in which to save the plots
- `figsize`: the width and height (in inches) of each plot

@make_verbal
def consolidate_samples(self):
View Source

    	@make_verbal
    	def consolidate_samples(self):
    		'''
    		Compile various statistics for each sample.

    		For each anchor sample:

    		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
    		+ `SE_D47` or `SE_D48`: set to zero by definition

    		For each unknown sample:

    		+ `D47` or `D48`: the standardized Δ4x value for this unknown
    		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown

    		For each anchor and unknown:

    		+ `N`: the total number of analyses of this sample
    		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
    		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
    		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
    		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
    		variance, indicating whether the Δ4x repeatability this sample differs significantly from
    		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
    		'''
    		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
    		for sample in self.samples:
    			self.samples[sample]['N'] = len(self.samples[sample]['data'])
    			if self.samples[sample]['N'] > 1:
    				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])

    			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
    			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])

    			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
    			if len(D4x_pop) > 2:
    				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]

    		if self.standardization_method == 'pooled':
    			for sample in self.anchors:
    				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    				self.samples[sample][f'SE_D{self._4x}'] = 0.
    			for sample in self.unknowns:
    				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
    				try:
    					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
    				except ValueError:
    					# when `sample` is constrained by self.standardize(constraints = {...}),
    					# it is no longer listed in self.standardization.var_names.
    					# Temporary fix: define SE as zero for now
    					self.samples[sample][f'SE_D4{self._4x}'] = 0.

    		elif self.standardization_method == 'indep_sessions':
    			for sample in self.anchors:
    				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    				self.samples[sample][f'SE_D{self._4x}'] = 0.
    			for sample in self.unknowns:
    				self.msg(f'Consolidating sample {sample}')
    				self.unknowns[sample][f'session_D{self._4x}'] = {}
    				session_avg = []
    				for session in self.sessions:
    					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
    					if sdata:
    						self.msg(f'{sample} found in session {session}')
    						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
    						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
    						# !! TODO: sigma_s below does not account for temporal changes in standardization error
    						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
    						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
    						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
    						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
    				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
    				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
    				wsum = sum([weights[s] for s in weights])
    				for s in weights:
    					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]

Compile various statistics for each sample.

For each anchor sample:

- `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
- `SE_D47` or `SE_D48`: set to zero by definition

For each unknown sample:

- `D47` or `D48`: the standardized Δ4x value for this unknown
- `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown

For each anchor and unknown:

- `N`: the total number of analyses of this sample
- `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
- `d13C_VPDB`: the average δ13CVPDB value for this sample
- `d18O_VSMOW`: the average δ18OVSMOW value for this sample (as CO2)
- `p_Levene`: the p-value from a Levene test of equal variance, indicating whether the Δ4x repeatability this sample differs significantly from that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.

def consolidate_sessions(self):
View Source

    	def consolidate_sessions(self):
    		'''
    		Compute various statistics for each session.

    		+ `Na`: Number of anchor analyses in the session
    		+ `Nu`: Number of unknown analyses in the session
    		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
    		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
    		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
    		+ `a`: scrambling factor
    		+ `b`: compositional slope
    		+ `c`: WG offset
    		+ `SE_a`: Model stadard erorr of `a`
    		+ `SE_b`: Model stadard erorr of `b`
    		+ `SE_c`: Model stadard erorr of `c`
    		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
    		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
    		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
    		+ `a2`: scrambling factor drift
    		+ `b2`: compositional slope drift
    		+ `c2`: WG offset drift
    		+ `Np`: Number of standardization parameters to fit
    		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
    		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
    		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
    		'''
    		for session in self.sessions:
    			if 'd13Cwg_VPDB' not in self.sessions[session]:
    				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
    			if 'd18Owg_VSMOW' not in self.sessions[session]:
    				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
    			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
    			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])

    			self.msg(f'Computing repeatabilities for session {session}')
    			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
    			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
    			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])

    		if self.standardization_method == 'pooled':
    			for session in self.sessions:

    				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
    				i = self.standardization.var_names.index(f'a_{pf(session)}')
    				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5

    				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
    				i = self.standardization.var_names.index(f'b_{pf(session)}')
    				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5

    				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
    				i = self.standardization.var_names.index(f'c_{pf(session)}')
    				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5

    				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
    				if self.sessions[session]['scrambling_drift']:
    					i = self.standardization.var_names.index(f'a2_{pf(session)}')
    					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
    				else:
    					self.sessions[session]['SE_a2'] = 0.

    				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
    				if self.sessions[session]['slope_drift']:
    					i = self.standardization.var_names.index(f'b2_{pf(session)}')
    					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
    				else:
    					self.sessions[session]['SE_b2'] = 0.

    				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
    				if self.sessions[session]['wg_drift']:
    					i = self.standardization.var_names.index(f'c2_{pf(session)}')
    					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
    				else:
    					self.sessions[session]['SE_c2'] = 0.

    				i = self.standardization.var_names.index(f'a_{pf(session)}')
    				j = self.standardization.var_names.index(f'b_{pf(session)}')
    				k = self.standardization.var_names.index(f'c_{pf(session)}')
    				CM = np.zeros((6,6))
    				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
    				try:
    					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
    					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
    					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
    					try:
    						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    						CM[3,4] = self.standardization.covar[i2,j2]
    						CM[4,3] = self.standardization.covar[j2,i2]
    					except ValueError:
    						pass
    					try:
    						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    						CM[3,5] = self.standardization.covar[i2,k2]
    						CM[5,3] = self.standardization.covar[k2,i2]
    					except ValueError:
    						pass
    				except ValueError:
    					pass
    				try:
    					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
    					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
    					try:
    						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    						CM[4,5] = self.standardization.covar[j2,k2]
    						CM[5,4] = self.standardization.covar[k2,j2]
    					except ValueError:
    						pass
    				except ValueError:
    					pass
    				try:
    					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
    					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
    				except ValueError:
    					pass

    				self.sessions[session]['CM'] = CM

    		elif self.standardization_method == 'indep_sessions':
    			pass # Not implemented yet

Compute various statistics for each session.

- `Na`: Number of anchor analyses in the session
- `Nu`: Number of unknown analyses in the session
- `r_d13C_VPDB`: δ13CVPDB repeatability of analyses within the session
- `r_d18O_VSMOW`: δ18OVSMOW repeatability of analyses within the session
- `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
- `a`: scrambling factor
- `b`: compositional slope
- `c`: WG offset
- `SE_a`: Model stadard erorr of `a`
- `SE_b`: Model stadard erorr of `b`
- `SE_c`: Model stadard erorr of `c`
- `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
- `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
- `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
- `a2`: scrambling factor drift
- `b2`: compositional slope drift
- `c2`: WG offset drift
- `Np`: Number of standardization parameters to fit
- `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
- `d13Cwg_VPDB`: δ13CVPDB of WG
- `d18Owg_VSMOW`: δ18OVSMOW of WG

@make_verbal
def repeatabilities(self):
View Source

    	@make_verbal
    	def repeatabilities(self):
    		'''
    		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    		(for all samples, for anchors, and for unknowns).
    		'''
    		self.msg('Computing reproducibilities for all sessions')

    		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')

Compute analytical repeatabilities for δ13CVPDB, δ18OVSMOW, Δ4x (for all samples, for anchors, and for unknowns).

@make_verbal
def consolidate(self, tables=True, plots=True):
View Source

    	@make_verbal
    	def consolidate(self, tables = True, plots = True):
    		'''
    		Collect information about samples, sessions and repeatabilities.
    		'''
    		self.consolidate_samples()
    		self.consolidate_sessions()
    		self.repeatabilities()

    		if tables:
    			self.summary()
    			self.table_of_sessions()
    			self.table_of_analyses()
    			self.table_of_samples()

    		if plots:
    			self.plot_sessions()

Collect information about samples, sessions and repeatabilities.

@make_verbal
def rmswd(self, samples='all samples', sessions='all sessions'):
View Source

    	@make_verbal
    	def rmswd(self,
    		samples = 'all samples',
    		sessions = 'all sessions',
    		):
    		'''
    		Compute the χ2, root mean squared weighted deviation
    		(i.e. reduced χ2), and corresponding degrees of freedom of the
    		Δ4x values for samples in `samples` and sessions in `sessions`.

    		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
    		'''
    		if samples == 'all samples':
    			mysamples = [k for k in self.samples]
    		elif samples == 'anchors':
    			mysamples = [k for k in self.anchors]
    		elif samples == 'unknowns':
    			mysamples = [k for k in self.unknowns]
    		else:
    			mysamples = samples

    		if sessions == 'all sessions':
    			sessions = [k for k in self.sessions]

    		chisq, Nf = 0, 0
    		for sample in mysamples :
    			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    			if len(G) > 1 :
    				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
    				Nf += (len(G) - 1)
    				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
    		r = (chisq / Nf)**.5 if Nf > 0 else 0
    		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
    		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}

Compute the χ2, root mean squared weighted deviation (i.e. reduced χ2), and corresponding degrees of freedom of the Δ4x values for samples in `samples` and sessions in `sessions`.

Only used in `D4xdata.standardize()` with `method='indep_sessions'`.

@make_verbal
def compute_r(self, key, samples='all samples', sessions='all sessions'):
View Source

    	@make_verbal
    	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
    		'''
    		Compute the repeatability of `[r[key] for r in self]`
    		'''
    		# NB: it's debatable whether rD47 should be computed
    		# with Nf = len(self)-len(self.samples) instead of
    		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)

    		if samples == 'all samples':
    			mysamples = [k for k in self.samples]
    		elif samples == 'anchors':
    			mysamples = [k for k in self.anchors]
    		elif samples == 'unknowns':
    			mysamples = [k for k in self.unknowns]
    		else:
    			mysamples = samples

    		if sessions == 'all sessions':
    			sessions = [k for k in self.sessions]

    		if key in ['D47', 'D48']:
    			chisq, Nf = 0, 0
    			for sample in mysamples :
    				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    				if len(X) > 1 :
    					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
    					if sample in self.unknowns:
    						Nf += len(X) - 1
    					else:
    						Nf += len(X)
    			if samples in ['anchors', 'all samples']:
    				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
    			r = (chisq / Nf)**.5 if Nf > 0 else 0

    		else: # if key not in ['D47', 'D48']
    			chisq, Nf = 0, 0
    			for sample in mysamples :
    				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    				if len(X) > 1 :
    					Nf += len(X) - 1
    					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
    			r = (chisq / Nf)**.5 if Nf > 0 else 0

    		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
    		return r

Compute the repeatability of `[r[key] for r in self]`

def sample_average(self, samples, weights='equal', normalize=True):
View Source

    	def sample_average(self, samples, weights = 'equal', normalize = True):
    		'''
    		Weighted average Δ4x value of a group of samples, accounting for covariance.

    		Returns the weighed average Δ4x value and associated SE
    		of a group of samples. Weights are equal by default. If `normalize` is
    		true, `weights` will be rescaled so that their sum equals 1.

    		**Examples**

    		```python
    		self.sample_average(['X','Y'], [1, 2])
    		```

    		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
    		where Δ4x(X) and Δ4x(Y) are the average Δ4x
    		values of samples X and Y, respectively.

    		```python
    		self.sample_average(['X','Y'], [1, -1], normalize = False)
    		```

    		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
    		'''
    		if weights == 'equal':
    			weights = [1/len(samples)] * len(samples)

    		if normalize:
    			s = sum(weights)
    			if s:
    				weights = [w/s for w in weights]

    		try:
    # 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
    # 			C = self.standardization.covar[indices,:][:,indices]
    			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
    			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
    			return correlated_sum(X, C, weights)
    		except ValueError:
    			return (0., 0.)

Weighted average Δ4x value of a group of samples, accounting for covariance.

Returns the weighed average Δ4x value and associated SE of a group of samples. Weights are equal by default. If `normalize` is true, `weights` will be rescaled so that their sum equals 1.

Examples

    self.sample_average(['X','Y'], [1, 2])

returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3, where Δ4x(X) and Δ4x(Y) are the average Δ4x values of samples X and Y, respectively.

    self.sample_average(['X','Y'], [1, -1], normalize = False)

returns the value and SE of the difference Δ4x(X) - Δ4x(Y).

def sample_D4x_covar(self, sample1, sample2=None):
View Source

    	def sample_D4x_covar(self, sample1, sample2 = None):
    		'''
    		Covariance between Δ4x values of samples

    		Returns the error covariance between the average Δ4x values of two
    		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
    		returns the Δ4x variance for that sample.
    		'''
    		if sample2 is None:
    			sample2 = sample1
    		if self.standardization_method == 'pooled':
    			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
    			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
    			return self.standardization.covar[i, j]
    		elif self.standardization_method == 'indep_sessions':
    			if sample1 == sample2:
    				return self.samples[sample1][f'SE_D{self._4x}']**2
    			else:
    				c = 0
    				for session in self.sessions:
    					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
    					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
    					if sdata1 and sdata2:
    						a = self.sessions[session]['a']
    						# !! TODO: CM below does not account for temporal changes in standardization parameters
    						CM = self.sessions[session]['CM'][:3,:3]
    						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
    						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
    						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
    						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
    						c += (
    							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
    							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
    							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
    							@ CM
    							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
    							) / a**2
    				return float(c)

Covariance between Δ4x values of samples

Returns the error covariance between the average Δ4x values of two samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`), returns the Δ4x variance for that sample.

def sample_D4x_correl(self, sample1, sample2=None):
View Source

    	def sample_D4x_correl(self, sample1, sample2 = None):
    		'''
    		Correlation between Δ4x errors of samples

    		Returns the error correlation between the average Δ4x values of two samples.
    		'''
    		if sample2 is None or sample2 == sample1:
    			return 1.
    		return (
    			self.sample_D4x_covar(sample1, sample2)
    			/ self.unknowns[sample1][f'SE_D{self._4x}']
    			/ self.unknowns[sample2][f'SE_D{self._4x}']
    			)

Correlation between Δ4x errors of samples

Returns the error correlation between the average Δ4x values of two samples.

def plot_single_session( self, session, kw_plot_anchors={'ls': 'None', 'marker': 'x', 'mec': (0.75, 0, 0), 'mew': 0.75, 'ms': 4}, kw_plot_unknowns={'ls': 'None', 'marker': 'x', 'mec': (0, 0, 0.75), 'mew': 0.75, 'ms': 4}, kw_plot_anchor_avg={'ls': '-', 'marker': 'None', 'color': (0.75, 0, 0), 'lw': 0.75}, kw_plot_unknown_avg={'ls': '-', 'marker': 'None', 'color': (0, 0, 0.75), 'lw': 0.75}, kw_contour_error={'colors': [[0, 0, 0]], 'alpha': 0.5, 'linewidths': 0.75}, xylimits='free', x_label=None, y_label=None, error_contour_interval='auto', fig='new' ):
View Source

    	def plot_single_session(self,
    		session,
    		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
    		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
    		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
    		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
    		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
    		xylimits = 'free', # | 'constant'
    		x_label = None,
    		y_label = None,
    		error_contour_interval = 'auto',
    		fig = 'new',
    		):
    		'''
    		Generate plot for a single session
    		'''
    		if x_label is None:
    			x_label = f'δ$_{{{self._4x}}}$ (‰)'
    		if y_label is None:
    			y_label = f'Δ$_{{{self._4x}}}$ (‰)'

    		out = _SessionPlot()
    		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
    		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]

    		if fig == 'new':
    			out.fig = ppl.figure(figsize = (6,6))
    			ppl.subplots_adjust(.1,.1,.9,.9)

    		out.anchor_analyses, = ppl.plot(
    			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    			**kw_plot_anchors)
    		out.unknown_analyses, = ppl.plot(
    			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    			**kw_plot_unknowns)
    		out.anchor_avg = ppl.plot(
    			np.array([ np.array([
    				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    				]) for sample in anchors]).T,
    			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
    			**kw_plot_anchor_avg)
    		out.unknown_avg = ppl.plot(
    			np.array([ np.array([
    				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    				]) for sample in unknowns]).T,
    			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
    			**kw_plot_unknown_avg)
    		if xylimits == 'constant':
    			x = [r[f'd{self._4x}'] for r in self]
    			y = [r[f'D{self._4x}'] for r in self]
    			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
    			w, h = x2-x1, y2-y1
    			x1 -= w/20
    			x2 += w/20
    			y1 -= h/20
    			y2 += h/20
    			ppl.axis([x1, x2, y1, y2])
    		elif xylimits == 'free':
    			x1, x2, y1, y2 = ppl.axis()
    		else:
    			x1, x2, y1, y2 = ppl.axis(xylimits)

    		if error_contour_interval != 'none':
    			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
    			XI,YI = np.meshgrid(xi, yi)
    			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
    			if error_contour_interval == 'auto':
    				rng = np.max(SI) - np.min(SI)
    				if rng <= 0.01:
    					cinterval = 0.001
    				elif rng <= 0.03:
    					cinterval = 0.004
    				elif rng <= 0.1:
    					cinterval = 0.01
    				elif rng <= 0.3:
    					cinterval = 0.03
    				elif rng <= 1.:
    					cinterval = 0.1
    				else:
    					cinterval = 0.5
    			else:
    				cinterval = error_contour_interval

    			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
    			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
    			out.clabel = ppl.clabel(out.contour)

    		ppl.xlabel(x_label)
    		ppl.ylabel(y_label)
    		ppl.title(session, weight = 'bold')
    		ppl.grid(alpha = .2)
    		out.ax = ppl.gca()

    		return out

Generate plot for a single session

def plot_residuals( self, hist=False, binwidth=0.6666666666666666, dir='output', filename=None, highlight=[], colors=None, figsize=None ):
View Source

    	def plot_residuals(
    		self,
    		hist = False,
    		binwidth = 2/3,
    		dir = 'output',
    		filename = None,
    		highlight = [],
    		colors = None,
    		figsize = None,
    		):
    		'''
    		Plot residuals of each analysis as a function of time (actually, as a function of
    		the order of analyses in the `D4xdata` object)

    		+ `hist`: whether to add a histogram of residuals
    		+ `histbins`: specify bin edges for the histogram
    		+ `dir`: the directory in which to save the plot
    		+ `highlight`: a list of samples to highlight
    		+ `colors`: a dict of `{<sample>: <color>}` for all samples
    		+ `figsize`: (width, height) of figure
    		'''
    		# Layout
    		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
    		if hist:
    			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
    			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
    		else:
    			ppl.subplots_adjust(.08,.05,.78,.8)
    			ax1 = ppl.subplot(111)

    		# Colors
    		N = len(self.anchors)
    		if colors is None:
    			if len(highlight) > 0:
    				Nh = len(highlight)
    				if Nh == 1:
    					colors = {highlight[0]: (0,0,0)}
    				elif Nh == 3:
    					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
    				elif Nh == 4:
    					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    				else:
    					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
    			else:
    				if N == 3:
    					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
    				elif N == 4:
    					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    				else:
    					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}

    		ppl.sca(ax1)

    		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)

    		session = self[0]['Session']
    		x1 = 0
    # 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
    		x_sessions = {}
    		one_or_more_singlets = False
    		one_or_more_multiplets = False
    		for k,r in enumerate(self):
    			if r['Session'] != session:
    				x2 = k-1
    				x_sessions[session] = (x1+x2)/2
    				ppl.axvline(k - 0.5, color = 'k', lw = .5)
    				session = r['Session']
    				x1 = k
    			singlet = len(self.samples[r['Sample']]['data']) == 1
    			if r['Sample'] in self.unknowns:
    				if singlet:
    					one_or_more_singlets = True
    				else:
    					one_or_more_multiplets = True
    			kw = dict(
    				marker = 'x' if singlet else '+',
    				ms = 4 if singlet else 5,
    				ls = 'None',
    				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
    				mew = 1,
    				alpha = 0.2 if singlet else 1,
    				)
    			if highlight and r['Sample'] not in highlight:
    				kw['alpha'] = 0.2
    			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
    		x2 = k
    		x_sessions[session] = (x1+x2)/2

    		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
    		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
    		if not hist:
    			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
    			ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f"   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')

    		xmin, xmax, ymin, ymax = ppl.axis()
    		for s in x_sessions:
    			ppl.text(
    				x_sessions[s],
    				ymax +1,
    				s,
    				va = 'bottom',
    				**(
    					dict(ha = 'center')
    					if len(self.sessions[s]['data']) > (0.15 * len(self))
    					else dict(ha = 'left', rotation = 45)
    					)
    				)

    		if hist:
    			ppl.sca(ax2)

    		for s in colors:
    			kw['marker'] = '+'
    			kw['ms'] = 5
    			kw['mec'] = colors[s]
    			kw['label'] = s
    			kw['alpha'] = 1
    			ppl.plot([], [], **kw)

    		kw['mec'] = (0,0,0)

    		if one_or_more_singlets:
    			kw['marker'] = 'x'
    			kw['ms'] = 4
    			kw['alpha'] = .2
    			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
    			ppl.plot([], [], **kw)

    		if one_or_more_multiplets:
    			kw['marker'] = '+'
    			kw['ms'] = 4
    			kw['alpha'] = 1
    			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
    			ppl.plot([], [], **kw)

    		if hist:
    			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
    		else:
    			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
    		leg.set_zorder(-1000)

    		ppl.sca(ax1)

    		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
    		ppl.xticks([])
    		ppl.axis([-1, len(self), None, None])

    		if hist:
    			ppl.sca(ax2)
    			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self]
    			ppl.hist(
    				X,
    				orientation = 'horizontal',
    				histtype = 'stepfilled',
    				ec = [.4]*3,
    				fc = [.25]*3,
    				alpha = .25,
    				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
    				)
    			ppl.axis([None, None, ymin, ymax])
    			ppl.text(0, 0,
    				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
    				size = 8,
    				alpha = 1,
    				va = 'center',
    				ha = 'left',
    				)

    			ppl.xticks([])
    			ppl.yticks([])
    # 			ax2.spines['left'].set_visible(False)
    			ax2.spines['right'].set_visible(False)
    			ax2.spines['top'].set_visible(False)
    			ax2.spines['bottom'].set_visible(False)

    		if not os.path.exists(dir):
    			os.makedirs(dir)
    		if filename is None:
    			return fig
    		elif filename == '':
    			filename = f'D{self._4x}_residuals.pdf'
    		ppl.savefig(f'{dir}/{filename}')
    		ppl.close(fig)

Plot residuals of each analysis as a function of time (actually, as a function of the order of analyses in the `D4xdata` object)

- `hist`: whether to add a histogram of residuals
- `histbins`: specify bin edges for the histogram
- `dir`: the directory in which to save the plot
- `highlight`: a list of samples to highlight
- `colors`: a dict of `{<sample>: <color>}` for all samples
- `figsize`: (width, height) of figure

def simulate(self, *args, **kwargs):
View Source

    	def simulate(self, *args, **kwargs):
    		'''
    		Legacy function with warning message pointing to `virtual_data()`
    		'''
    		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')

Legacy function with warning message pointing to `virtual_data()`

def plot_distribution_of_analyses(self, dir='output', filename=None, vs_time=False, output=None):
View Source

    	def plot_distribution_of_analyses(self, dir = 'output', filename = None, vs_time = False, output = None):
    		'''
    		Plot temporal distribution of all analyses in the data set.

    		**Parameters**

    		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    		'''

    		asamples = [s for s in self.anchors]
    		usamples = [s for s in self.unknowns]
    		if output is None or output == 'fig':
    			fig = ppl.figure(figsize = (6,4))
    			ppl.subplots_adjust(0.02, 0.03, 0.9, 0.8)
    		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    		for k, s in enumerate(asamples + usamples):
    			if vs_time:
    				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    			else:
    				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    			Y = [k for x in X]
    			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .5)
    			ppl.axhline(k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    			ppl.text(Xmax, k, f'  {s}', va = 'center', ha = 'left', size = 7)
    		if vs_time:
    			t = [r['TimeTag'] for r in self]
    			t1, t2 = min(t), max(t)
    			tspan = t2 - t1
    			t1 -= tspan / len(self)
    			t2 += tspan / len(self)
    			ppl.axis([t1, t2, -1, k+1])
    		else:
    			ppl.axis([-1, len(self), -1, k+1])

    		x2 = 0
    		for session in self.sessions:
    			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    			if vs_time:
    				ppl.axvline(x1, color = 'k', lw = .75)
    			if k:
    				if vs_time:
    					ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .2)
    				else:
    					ppl.axvline((x1+x2)/2, color = 'k', lw = .75)
    			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    # 			from xlrd import xldate_as_datetime
    # 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    			if vs_time:
    				ppl.axvline(x2, color = 'k', lw = .75)
    			ppl.text((2*x1+x2)/3, k+1, session, ha = 'left', va = 'bottom', rotation = 45, size = 8)

    		ppl.xticks([])
    		ppl.yticks([])

    		if output is None:
    			if not os.path.exists(dir):
    				os.makedirs(dir)
    			if filename == None:
    				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    			ppl.savefig(f'{dir}/{filename}')
    			ppl.close(fig)
    		elif output == 'ax':
    			return ppl.gca()
    		elif output == 'fig':
    			return fig

Plot temporal distribution of all analyses in the data set.

Parameters

- `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.

##### Inherited Members

builtins.list
  clear
  copy
  append
  insert
  extend
  pop
  remove
  index
  count
  reverse
  sort

class D47data(D4xdata):
View Source

    class D47data(D4xdata):
    	'''
    	Store and process data for a large set of Δ47 analyses,
    	usually comprising more than one analytical session.
    	'''

    	Nominal_D4x = {
    		'ETH-1':   0.2052,
    		'ETH-2':   0.2085,
    		'ETH-3':   0.6132,
    		'ETH-4':   0.4511,
    		'IAEA-C1': 0.3018,
    		'IAEA-C2': 0.6409,
    		'MERCK':   0.5135,
    		} # I-CDES (Bernasconi et al., 2021)
    	'''
    	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
    	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
    	reference frame.

    	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
    	```py
    	{
    		'ETH-1'   : 0.2052,
    		'ETH-2'   : 0.2085,
    		'ETH-3'   : 0.6132,
    		'ETH-4'   : 0.4511,
    		'IAEA-C1' : 0.3018,
    		'IAEA-C2' : 0.6409,
    		'MERCK'   : 0.5135,
    	}
    	```
    	'''

    	@property
    	def Nominal_D47(self):
    		return self.Nominal_D4x

    	@Nominal_D47.setter
    	def Nominal_D47(self, new):
    		self.Nominal_D4x = dict(**new)
    		self.refresh()

    	def __init__(self, l = [], **kwargs):
    		'''
    		**Parameters:** same as `D4xdata.__init__()`
    		'''
    		D4xdata.__init__(self, l = l, mass = '47', **kwargs)

    	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    		'''
    		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    		value for that temperature, and add treat these samples as additional anchors.

    		**Parameters**

    		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    		if `new`: keep pre-existing anchors but update them in case of conflict
    		between old and new Δ47 values;
    		if `old`: keep pre-existing anchors but preserve their original Δ47
    		values in case of conflict.
    		'''
    		f = {
    			'petersen': fCO2eqD47_Petersen,
    			'wang': fCO2eqD47_Wang,
    			}[fCo2eqD47]
    		foo = {}
    		for r in self:
    			if 'Teq' in r:
    				if r['Sample'] in foo:
    					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    				else:
    					foo[r['Sample']] = f(r['Teq'])
    			else:
    					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'

    		if priority == 'replace':
    			self.Nominal_D47 = {}
    		for s in foo:
    			if priority != 'old' or s not in self.Nominal_D47:
    				self.Nominal_D47[s] = foo[s]

Store and process data for a large set of Δ47 analyses, usually comprising more than one analytical session.

D47data(l=[], **kwargs)
View Source

    	def __init__(self, l = [], **kwargs):
    		'''
    		**Parameters:** same as `D4xdata.__init__()`
    		'''
    		D4xdata.__init__(self, l = l, mass = '47', **kwargs)

Parameters: same as `D4xdata.__init__()`

Nominal_D4x = {'ETH-1': 0.2052, 'ETH-2': 0.2085, 'ETH-3': 0.6132, 'ETH-4': 0.4511, 'IAEA-C1': 0.3018, 'IAEA-C2': 0.6409, 'MERCK': 0.5135}

Nominal Δ47 values assigned to the Δ47 anchor samples, used by `D47data.standardize()` to normalize unknown samples to an absolute Δ47 reference frame.

By default equal to (after Bernasconi et al. (2021)):

    {
            'ETH-1'   : 0.2052,
            'ETH-2'   : 0.2085,
            'ETH-3'   : 0.6132,
            'ETH-4'   : 0.4511,
            'IAEA-C1' : 0.3018,
            'IAEA-C2' : 0.6409,
            'MERCK'   : 0.5135,
    }

Nominal_D47
def D47fromTeq(self, fCo2eqD47='petersen', priority='new'):
View Source

    	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    		'''
    		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    		value for that temperature, and add treat these samples as additional anchors.

    		**Parameters**

    		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    		if `new`: keep pre-existing anchors but update them in case of conflict
    		between old and new Δ47 values;
    		if `old`: keep pre-existing anchors but preserve their original Δ47
    		values in case of conflict.
    		'''
    		f = {
    			'petersen': fCO2eqD47_Petersen,
    			'wang': fCO2eqD47_Wang,
    			}[fCo2eqD47]
    		foo = {}
    		for r in self:
    			if 'Teq' in r:
    				if r['Sample'] in foo:
    					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    				else:
    					foo[r['Sample']] = f(r['Teq'])
    			else:
    					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'

    		if priority == 'replace':
    			self.Nominal_D47 = {}
    		for s in foo:
    			if priority != 'old' or s not in self.Nominal_D47:
    				self.Nominal_D47[s] = foo[s]

Find all samples for which `Teq` is specified, compute equilibrium Δ47 value for that temperature, and add treat these samples as additional anchors.

Parameters

- `fCo2eqD47`: Which CO2 equilibrium law to use (`petersen`: Petersen et al. (2019); `wang`: Wang et al. (2019)).
- `priority`: if `replace`: forget old anchors and only use the new ones; if `new`: keep pre-existing anchors but update them in case of conflict between old and new Δ47 values; if `old`: keep pre-existing anchors but preserve their original Δ47 values in case of conflict.

##### Inherited Members

D4xdata
  R13_VPDB
  R18_VSMOW
  LAMBDA_17
  R17_VSMOW
  R18_VPDB
  R17_VPDB
  LEVENE_REF_SAMPLE
  ALPHA_18O_ACID_REACTION
  Nominal_d13C_VPDB
  Nominal_d18O_VPDB
  d13C_STANDARDIZATION_METHOD
  d18O_STANDARDIZATION_METHOD
  make_verbal
  msg
  vmsg
  log
  refresh
  refresh_sessions
  refresh_samples
  read
  input
  wg
  compute_bulk_delta
  crunch
  fill_in_missing_info
  standardize_d13C
  standardize_d18O
  compute_bulk_and_clumping_deltas
  compute_isobar_ratios
  split_samples
  unsplit_samples
  assign_timestamps
  report
  combine_samples
  standardize
  standardization_error
  summary
  table_of_sessions
  table_of_analyses
  covar_table
  table_of_samples
  plot_sessions
  consolidate_samples
  consolidate_sessions
  repeatabilities
  consolidate
  rmswd
  compute_r
  sample_average
  sample_D4x_covar
  sample_D4x_correl
  plot_single_session
  plot_residuals
  simulate
  plot_distribution_of_analyses
builtins.list
  clear
  copy
  append
  insert
  extend
  pop
  remove
  index
  count
  reverse
  sort

class D48data(D4xdata):
View Source

    class D48data(D4xdata):
    	'''
    	Store and process data for a large set of Δ48 analyses,
    	usually comprising more than one analytical session.
    	'''

    	Nominal_D4x = {
    		'ETH-1':  0.138,
    		'ETH-2':  0.138,
    		'ETH-3':  0.270,
    		'ETH-4':  0.223,
    		'GU-1':  -0.419,
    		} # (Fiebig et al., 2019, 2021)
    	'''
    	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
    	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
    	reference frame.

    	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
    	Fiebig et al. (in press)):

    	```py
    	{
    		'ETH-1' :  0.138,
    		'ETH-2' :  0.138,
    		'ETH-3' :  0.270,
    		'ETH-4' :  0.223,
    		'GU-1'  : -0.419,
    	}
    	```
    	'''

    	@property
    	def Nominal_D48(self):
    		return self.Nominal_D4x

    	@Nominal_D48.setter
    	def Nominal_D48(self, new):
    		self.Nominal_D4x = dict(**new)
    		self.refresh()

    	def __init__(self, l = [], **kwargs):
    		'''
    		**Parameters:** same as `D4xdata.__init__()`
    		'''
    		D4xdata.__init__(self, l = l, mass = '48', **kwargs)

Store and process data for a large set of Δ48 analyses, usually comprising more than one analytical session.

D48data(l=[], **kwargs)
View Source

    	def __init__(self, l = [], **kwargs):
    		'''
    		**Parameters:** same as `D4xdata.__init__()`
    		'''
    		D4xdata.__init__(self, l = l, mass = '48', **kwargs)

Parameters: same as `D4xdata.__init__()`

Nominal_D4x = {'ETH-1': 0.138, 'ETH-2': 0.138, 'ETH-3': 0.27, 'ETH-4': 0.223, 'GU-1': -0.419}

Nominal Δ48 values assigned to the Δ48 anchor samples, used by `D48data.standardize()` to normalize unknown samples to an absolute Δ48 reference frame.

By default equal to (after Fiebig et al. (2019), Fiebig et al. (in press)):

    {
            'ETH-1' :  0.138,
            'ETH-2' :  0.138,
            'ETH-3' :  0.270,
            'ETH-4' :  0.223,
            'GU-1'  : -0.419,
    }

Nominal_D48

##### Inherited Members

D4xdata
  R13_VPDB
  R18_VSMOW
  LAMBDA_17
  R17_VSMOW
  R18_VPDB
  R17_VPDB
  LEVENE_REF_SAMPLE
  ALPHA_18O_ACID_REACTION
  Nominal_d13C_VPDB
  Nominal_d18O_VPDB
  d13C_STANDARDIZATION_METHOD
  d18O_STANDARDIZATION_METHOD
  make_verbal
  msg
  vmsg
  log
  refresh
  refresh_sessions
  refresh_samples
  read
  input
  wg
  compute_bulk_delta
  crunch
  fill_in_missing_info
  standardize_d13C
  standardize_d18O
  compute_bulk_and_clumping_deltas
  compute_isobar_ratios
  split_samples
  unsplit_samples
  assign_timestamps
  report
  combine_samples
  standardize
  standardization_error
  summary
  table_of_sessions
  table_of_analyses
  covar_table
  table_of_samples
  plot_sessions
  consolidate_samples
  consolidate_sessions
  repeatabilities
  consolidate
  rmswd
  compute_r
  sample_average
  sample_D4x_covar
  sample_D4x_correl
  plot_single_session
  plot_residuals
  simulate
  plot_distribution_of_analyses
builtins.list
  clear
  copy
  append
  insert
  extend
  pop
  remove
  index
  count
  reverse
  sort