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.2.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.2.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.2.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 oxygen-17 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
    –––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––  ––––––––

## API Documentation

View Source

       1'''
       2Standardization and analytical error propagation of Δ47 and Δ48 clumped-isotope measurements
       3
       4Process and standardize carbonate and/or CO2 clumped-isotope analyses,
       5from low-level data out of a dual-inlet mass spectrometer to final, “absolute”
       6Δ47 and Δ48 values with fully propagated analytical error estimates
       7([Daëron, 2021](https://doi.org/10.1029/2020GC009592)).
       8
       9The **tutorial** section takes you through a series of simple steps to import/process data and print out the results.
      10The **how-to** section provides instructions applicable to various specific tasks.
      11
      12.. include:: ../docs/tutorial.md
      13.. include:: ../docs/howto.md
      14
      15## API Documentation
      16'''
      17
      18__docformat__ = "restructuredtext"
      19__author__    = 'Mathieu Daëron'
      20__contact__   = 'daeron@lsce.ipsl.fr'
      21__copyright__ = 'Copyright (c) 2023 Mathieu Daëron'
      22__license__   = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause'
      23__date__      = '2023-05-13'
      24__version__   = '2.0.6'
      25
      26import os
      27import numpy as np
      28from statistics import stdev
      29from scipy.stats import t as tstudent
      30from scipy.stats import levene
      31from scipy.interpolate import interp1d
      32from numpy import linalg
      33from lmfit import Minimizer, Parameters, report_fit
      34from matplotlib import pyplot as ppl
      35from datetime import datetime as dt
      36from functools import wraps
      37from colorsys import hls_to_rgb
      38from matplotlib import rcParams
      39
      40rcParams['font.family'] = 'sans-serif'
      41rcParams['font.sans-serif'] = 'Helvetica'
      42rcParams['font.size'] = 10
      43rcParams['mathtext.fontset'] = 'custom'
      44rcParams['mathtext.rm'] = 'sans'
      45rcParams['mathtext.bf'] = 'sans:bold'
      46rcParams['mathtext.it'] = 'sans:italic'
      47rcParams['mathtext.cal'] = 'sans:italic'
      48rcParams['mathtext.default'] = 'rm'
      49rcParams['xtick.major.size'] = 4
      50rcParams['xtick.major.width'] = 1
      51rcParams['ytick.major.size'] = 4
      52rcParams['ytick.major.width'] = 1
      53rcParams['axes.grid'] = False
      54rcParams['axes.linewidth'] = 1
      55rcParams['grid.linewidth'] = .75
      56rcParams['grid.linestyle'] = '-'
      57rcParams['grid.alpha'] = .15
      58rcParams['savefig.dpi'] = 150
      59
      60Petersen_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]])
      61_fCO2eqD47_Petersen = interp1d(Petersen_etal_CO2eqD47[:,0], Petersen_etal_CO2eqD47[:,1])
      62def fCO2eqD47_Petersen(T):
      63	'''
      64	CO2 equilibrium Δ47 value as a function of T (in degrees C)
      65	according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).
      66
      67	'''
      68	return float(_fCO2eqD47_Petersen(T))
      69
      70
      71Wang_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]])
      72_fCO2eqD47_Wang = interp1d(Wang_etal_CO2eqD47[:,0] - 0.15, Wang_etal_CO2eqD47[:,1])
      73def fCO2eqD47_Wang(T):
      74	'''
      75	CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
      76	according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
      77	(supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
      78	'''
      79	return float(_fCO2eqD47_Wang(T))
      80
      81
      82def correlated_sum(X, C, w = None):
      83	'''
      84	Compute covariance-aware linear combinations
      85
      86	**Parameters**
      87
      88	+ `X`: list or 1-D array of values to sum
      89	+ `C`: covariance matrix for the elements of `X`
      90	+ `w`: list or 1-D array of weights to apply to the elements of `X`
      91	       (all equal to 1 by default)
      92
      93	Return the sum (and its SE) of the elements of `X`, with optional weights equal
      94	to the elements of `w`, accounting for covariances between the elements of `X`.
      95	'''
      96	if w is None:
      97		w = [1 for x in X]
      98	return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5
      99
     100
     101def make_csv(x, hsep = ',', vsep = '\n'):
     102	'''
     103	Formats a list of lists of strings as a CSV
     104
     105	**Parameters**
     106
     107	+ `x`: the list of lists of strings to format
     108	+ `hsep`: the field separator (`,` by default)
     109	+ `vsep`: the line-ending convention to use (`\\n` by default)
     110
     111	**Example**
     112
     113	```py
     114	print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))
     115	```
     116
     117	outputs:
     118
     119	```py
     120	a,b,c
     121	d,e,f
     122	```
     123	'''
     124	return vsep.join([hsep.join(l) for l in x])
     125
     126
     127def pf(txt):
     128	'''
     129	Modify string `txt` to follow `lmfit.Parameter()` naming rules.
     130	'''
     131	return txt.replace('-','_').replace('.','_').replace(' ','_')
     132
     133
     134def smart_type(x):
     135	'''
     136	Tries to convert string `x` to a float if it includes a decimal point, or
     137	to an integer if it does not. If both attempts fail, return the original
     138	string unchanged.
     139	'''
     140	try:
     141		y = float(x)
     142	except ValueError:
     143		return x
     144	if '.' not in x:
     145		return int(y)
     146	return y
     147
     148
     149def pretty_table(x, header = 1, hsep = '  ', vsep = '–', align = '<'):
     150	'''
     151	Reads a list of lists of strings and outputs an ascii table
     152
     153	**Parameters**
     154
     155	+ `x`: a list of lists of strings
     156	+ `header`: the number of lines to treat as header lines
     157	+ `hsep`: the horizontal separator between columns
     158	+ `vsep`: the character to use as vertical separator
     159	+ `align`: string of left (`<`) or right (`>`) alignment characters.
     160
     161	**Example**
     162
     163	```py
     164	x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']]
     165	print(pretty_table(x))
     166	```
     167	yields:
     168	```
     169	--  ------  ---
     170	A        B    C
     171	--  ------  ---
     172	1   1.9999  foo
     173	10       x  bar
     174	--  ------  ---
     175	```
     176
     177	'''
     178	txt = []
     179	widths = [np.max([len(e) for e in c]) for c in zip(*x)]
     180
     181	if len(widths) > len(align):
     182		align += '>' * (len(widths)-len(align))
     183	sepline = hsep.join([vsep*w for w in widths])
     184	txt += [sepline]
     185	for k,l in enumerate(x):
     186		if k and k == header:
     187			txt += [sepline]
     188		txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])]
     189	txt += [sepline]
     190	txt += ['']
     191	return '\n'.join(txt)
     192
     193
     194def transpose_table(x):
     195	'''
     196	Transpose a list if lists
     197
     198	**Parameters**
     199
     200	+ `x`: a list of lists
     201
     202	**Example**
     203
     204	```py
     205	x = [[1, 2], [3, 4]]
     206	print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
     207	```
     208	'''
     209	return [[e for e in c] for c in zip(*x)]
     210
     211
     212def w_avg(X, sX) :
     213	'''
     214	Compute variance-weighted average
     215
     216	Returns the value and SE of the weighted average of the elements of `X`,
     217	with relative weights equal to their inverse variances (`1/sX**2`).
     218
     219	**Parameters**
     220
     221	+ `X`: array-like of elements to average
     222	+ `sX`: array-like of the corresponding SE values
     223
     224	**Tip**
     225
     226	If `X` and `sX` are initially arranged as a list of `(x, sx)` doublets,
     227	they may be rearranged using `zip()`:
     228
     229	```python
     230	foo = [(0, 1), (1, 0.5), (2, 0.5)]
     231	print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)
     232	```
     233	'''
     234	X = [ x for x in X ]
     235	sX = [ sx for sx in sX ]
     236	W = [ sx**-2 for sx in sX ]
     237	W = [ w/sum(W) for w in W ]
     238	Xavg = sum([ w*x for w,x in zip(W,X) ])
     239	sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5
     240	return Xavg, sXavg
     241
     242
     243def read_csv(filename, sep = ''):
     244	'''
     245	Read contents of `filename` in csv format and return a list of dictionaries.
     246
     247	In the csv string, spaces before and after field separators (`','` by default)
     248	are optional.
     249
     250	**Parameters**
     251
     252	+ `filename`: the csv file to read
     253	+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
     254	whichever appers most often in the contents of `filename`.
     255	'''
     256	with open(filename) as fid:
     257		txt = fid.read()
     258
     259	if sep == '':
     260		sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
     261	txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
     262	return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]]
     263
     264
     265def simulate_single_analysis(
     266	sample = 'MYSAMPLE',
     267	d13Cwg_VPDB = -4., d18Owg_VSMOW = 26.,
     268	d13C_VPDB = None, d18O_VPDB = None,
     269	D47 = None, D48 = None, D49 = 0., D17O = 0.,
     270	a47 = 1., b47 = 0., c47 = -0.9,
     271	a48 = 1., b48 = 0., c48 = -0.45,
     272	Nominal_D47 = None,
     273	Nominal_D48 = None,
     274	Nominal_d13C_VPDB = None,
     275	Nominal_d18O_VPDB = None,
     276	ALPHA_18O_ACID_REACTION = None,
     277	R13_VPDB = None,
     278	R17_VSMOW = None,
     279	R18_VSMOW = None,
     280	LAMBDA_17 = None,
     281	R18_VPDB = None,
     282	):
     283	'''
     284	Compute working-gas delta values for a single analysis, assuming a stochastic working
     285	gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values).
     286
     287	**Parameters**
     288
     289	+ `sample`: sample name
     290	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
     291		(respectively –4 and +26 ‰ by default)
     292	+ `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
     293	+ `D47`, `D48`, `D49`, `D17O`: clumped-isotope and oxygen-17 anomalies
     294		of the carbonate sample
     295	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and
     296		Δ48 values if `D47` or `D48` are not specified
     297	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
     298		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
     299	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
     300	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
     301		correction parameters (by default equal to the `D4xdata` default values)
     302
     303	Returns a dictionary with fields
     304	`['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49']`.
     305	'''
     306
     307	if Nominal_d13C_VPDB is None:
     308		Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB
     309
     310	if Nominal_d18O_VPDB is None:
     311		Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB
     312
     313	if ALPHA_18O_ACID_REACTION is None:
     314		ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION
     315
     316	if R13_VPDB is None:
     317		R13_VPDB = D4xdata().R13_VPDB
     318
     319	if R17_VSMOW is None:
     320		R17_VSMOW = D4xdata().R17_VSMOW
     321
     322	if R18_VSMOW is None:
     323		R18_VSMOW = D4xdata().R18_VSMOW
     324
     325	if LAMBDA_17 is None:
     326		LAMBDA_17 = D4xdata().LAMBDA_17
     327
     328	if R18_VPDB is None:
     329		R18_VPDB = D4xdata().R18_VPDB
     330
     331	R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17
     332
     333	if Nominal_D47 is None:
     334		Nominal_D47 = D47data().Nominal_D47
     335
     336	if Nominal_D48 is None:
     337		Nominal_D48 = D48data().Nominal_D48
     338
     339	if d13C_VPDB is None:
     340		if sample in Nominal_d13C_VPDB:
     341			d13C_VPDB = Nominal_d13C_VPDB[sample]
     342		else:
     343			raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.")
     344
     345	if d18O_VPDB is None:
     346		if sample in Nominal_d18O_VPDB:
     347			d18O_VPDB = Nominal_d18O_VPDB[sample]
     348		else:
     349			raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.")
     350
     351	if D47 is None:
     352		if sample in Nominal_D47:
     353			D47 = Nominal_D47[sample]
     354		else:
     355			raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.")
     356
     357	if D48 is None:
     358		if sample in Nominal_D48:
     359			D48 = Nominal_D48[sample]
     360		else:
     361			raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.")
     362
     363	X = D4xdata()
     364	X.R13_VPDB = R13_VPDB
     365	X.R17_VSMOW = R17_VSMOW
     366	X.R18_VSMOW = R18_VSMOW
     367	X.LAMBDA_17 = LAMBDA_17
     368	X.R18_VPDB = R18_VPDB
     369	X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17
     370
     371	R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios(
     372		R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000),
     373		R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000),
     374		)
     375	R45, R46, R47, R48, R49 = X.compute_isobar_ratios(
     376		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
     377		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
     378		D17O=D17O, D47=D47, D48=D48, D49=D49,
     379		)
     380	R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios(
     381		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
     382		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
     383		D17O=D17O,
     384		)
     385
     386	d45 = 1000 * (R45/R45wg - 1)
     387	d46 = 1000 * (R46/R46wg - 1)
     388	d47 = 1000 * (R47/R47wg - 1)
     389	d48 = 1000 * (R48/R48wg - 1)
     390	d49 = 1000 * (R49/R49wg - 1)
     391
     392	for k in range(3): # dumb iteration to adjust for small changes in d47
     393		R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch
     394		R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch
     395		d47 = 1000 * (R47raw/R47wg - 1)
     396		d48 = 1000 * (R48raw/R48wg - 1)
     397
     398	return dict(
     399		Sample = sample,
     400		D17O = D17O,
     401		d13Cwg_VPDB = d13Cwg_VPDB,
     402		d18Owg_VSMOW = d18Owg_VSMOW,
     403		d45 = d45,
     404		d46 = d46,
     405		d47 = d47,
     406		d48 = d48,
     407		d49 = d49,
     408		)
     409
     410
     411def virtual_data(
     412	samples = [],
     413	a47 = 1., b47 = 0., c47 = -0.9,
     414	a48 = 1., b48 = 0., c48 = -0.45,
     415	rD47 = 0.015, rD48 = 0.045,
     416	d13Cwg_VPDB = None, d18Owg_VSMOW = None,
     417	session = None,
     418	Nominal_D47 = None, Nominal_D48 = None,
     419	Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
     420	ALPHA_18O_ACID_REACTION = None,
     421	R13_VPDB = None,
     422	R17_VSMOW = None,
     423	R18_VSMOW = None,
     424	LAMBDA_17 = None,
     425	R18_VPDB = None,
     426	seed = 0,
     427	):
     428	'''
     429	Return list with simulated analyses from a single session.
     430
     431	**Parameters**
     432
     433	+ `samples`: a list of entries; each entry is a dictionary with the following fields:
     434	    * `Sample`: the name of the sample
     435	    * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
     436	    * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
     437	    * `N`: how many analyses to generate for this sample
     438	+ `a47`: scrambling factor for Δ47
     439	+ `b47`: compositional nonlinearity for Δ47
     440	+ `c47`: working gas offset for Δ47
     441	+ `a48`: scrambling factor for Δ48
     442	+ `b48`: compositional nonlinearity for Δ48
     443	+ `c48`: working gas offset for Δ48
     444	+ `rD47`: analytical repeatability of Δ47
     445	+ `rD48`: analytical repeatability of Δ48
     446	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
     447		(by default equal to the `simulate_single_analysis` default values)
     448	+ `session`: name of the session (no name by default)
     449	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
     450		if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
     451	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
     452		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
     453		(by default equal to the `simulate_single_analysis` defaults)
     454	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
     455		(by default equal to the `simulate_single_analysis` defaults)
     456	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
     457		correction parameters (by default equal to the `simulate_single_analysis` default)
     458	+ `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations
     459
     460
     461	Here is an example of using this method to generate an arbitrary combination of
     462	anchors and unknowns for a bunch of sessions:
     463
     464	```py
     465	args = dict(
     466		samples = [
     467			dict(Sample = 'ETH-1', N = 4),
     468			dict(Sample = 'ETH-2', N = 5),
     469			dict(Sample = 'ETH-3', N = 6),
     470			dict(Sample = 'FOO', N = 2,
     471				d13C_VPDB = -5., d18O_VPDB = -10.,
     472				D47 = 0.3, D48 = 0.15),
     473			], rD47 = 0.010, rD48 = 0.030)
     474
     475	session1 = virtual_data(session = 'Session_01', **args, seed = 123)
     476	session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
     477	session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
     478	session4 = virtual_data(session = 'Session_04', **args, seed = 123456)
     479
     480	D = D47data(session1 + session2 + session3 + session4)
     481
     482	D.crunch()
     483	D.standardize()
     484
     485	D.table_of_sessions(verbose = True, save_to_file = False)
     486	D.table_of_samples(verbose = True, save_to_file = False)
     487	D.table_of_analyses(verbose = True, save_to_file = False)
     488	```
     489
     490	This should output something like:
     491
     492	```
     493	[table_of_sessions]
     494	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
     495	Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
     496	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
     497	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
     498	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
     499	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
     500	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
     501	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
     502
     503	[table_of_samples]
     504	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
     505	Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
     506	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
     507	ETH-1   16       2.02       37.02  0.2052                    0.0079
     508	ETH-2   20     -10.17       19.88  0.2085                    0.0100
     509	ETH-3   24       1.71       37.45  0.6132                    0.0105
     510	FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
     511	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
     512
     513	[table_of_analyses]
     514	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
     515	UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
     516	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
     517	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
     518	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
     519	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
     520	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
     521	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
     522	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
     523	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
     524	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
     525	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
     526	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
     527	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
     528	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
     529	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
     530	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
     531	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
     532	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
     533	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
     534	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
     535	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
     536	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
     537	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
     538	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
     539	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
     540	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
     541	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
     542	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
     543	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
     544	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
     545	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
     546	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
     547	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
     548	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
     549	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
     550	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
     551	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
     552	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
     553	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
     554	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
     555	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
     556	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
     557	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
     558	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
     559	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
     560	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
     561	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
     562	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
     563	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
     564	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
     565	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
     566	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
     567	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
     568	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
     569	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
     570	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
     571	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
     572	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
     573	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
     574	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
     575	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
     576	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
     577	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
     578	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
     579	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
     580	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
     581	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
     582	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
     583	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
     584	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
     585	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
     586	```
     587	'''
     588
     589	kwargs = locals().copy()
     590
     591	from numpy import random as nprandom
     592	if seed:
     593		rng = nprandom.default_rng(seed)
     594	else:
     595		rng = nprandom.default_rng()
     596
     597	N = sum([s['N'] for s in samples])
     598	errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
     599	errors47 *= rD47 / stdev(errors47) # scale errors to rD47
     600	errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
     601	errors48 *= rD48 / stdev(errors48) # scale errors to rD48
     602
     603	k = 0
     604	out = []
     605	for s in samples:
     606		kw = {}
     607		kw['sample'] = s['Sample']
     608		kw = {
     609			**kw,
     610			**{var: kwargs[var]
     611				for var in [
     612					'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
     613					'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
     614					'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
     615					'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
     616					]
     617				if kwargs[var] is not None},
     618			**{var: s[var]
     619				for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
     620				if var in s},
     621			}
     622
     623		sN = s['N']
     624		while sN:
     625			out.append(simulate_single_analysis(**kw))
     626			out[-1]['d47'] += errors47[k] * a47
     627			out[-1]['d48'] += errors48[k] * a48
     628			sN -= 1
     629			k += 1
     630
     631		if session is not None:
     632			for r in out:
     633				r['Session'] = session
     634	return out
     635
     636def table_of_samples(
     637	data47 = None,
     638	data48 = None,
     639	dir = 'output',
     640	filename = None,
     641	save_to_file = True,
     642	print_out = True,
     643	output = None,
     644	):
     645	'''
     646	Print out, save to disk and/or return a combined table of samples
     647	for a pair of `D47data` and `D48data` objects.
     648
     649	**Parameters**
     650
     651	+ `data47`: `D47data` instance
     652	+ `data48`: `D48data` instance
     653	+ `dir`: the directory in which to save the table
     654	+ `filename`: the name to the csv file to write to
     655	+ `save_to_file`: whether to save the table to disk
     656	+ `print_out`: whether to print out the table
     657	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
     658		if set to `'raw'`: return a list of list of strings
     659		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
     660	'''
     661	if data47 is None:
     662		if data48 is None:
     663			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
     664		else:
     665			return data48.table_of_samples(
     666				dir = dir,
     667				filename = filename,
     668				save_to_file = save_to_file,
     669				print_out = print_out,
     670				output = output
     671				)
     672	else:
     673		if data48 is None:
     674			return data47.table_of_samples(
     675				dir = dir,
     676				filename = filename,
     677				save_to_file = save_to_file,
     678				print_out = print_out,
     679				output = output
     680				)
     681		else:
     682			out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
     683			out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
     684			out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:])
     685
     686			if save_to_file:
     687				if not os.path.exists(dir):
     688					os.makedirs(dir)
     689				if filename is None:
     690					filename = f'D47D48_samples.csv'
     691				with open(f'{dir}/{filename}', 'w') as fid:
     692					fid.write(make_csv(out))
     693			if print_out:
     694				print('\n'+pretty_table(out))
     695			if output == 'raw':
     696				return out
     697			elif output == 'pretty':
     698				return pretty_table(out)
     699
     700
     701def table_of_sessions(
     702	data47 = None,
     703	data48 = None,
     704	dir = 'output',
     705	filename = None,
     706	save_to_file = True,
     707	print_out = True,
     708	output = None,
     709	):
     710	'''
     711	Print out, save to disk and/or return a combined table of sessions
     712	for a pair of `D47data` and `D48data` objects.
     713	***Only applicable if the sessions in `data47` and those in `data48`
     714	consist of the exact same sets of analyses.***
     715
     716	**Parameters**
     717
     718	+ `data47`: `D47data` instance
     719	+ `data48`: `D48data` instance
     720	+ `dir`: the directory in which to save the table
     721	+ `filename`: the name to the csv file to write to
     722	+ `save_to_file`: whether to save the table to disk
     723	+ `print_out`: whether to print out the table
     724	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
     725		if set to `'raw'`: return a list of list of strings
     726		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
     727	'''
     728	if data47 is None:
     729		if data48 is None:
     730			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
     731		else:
     732			return data48.table_of_sessions(
     733				dir = dir,
     734				filename = filename,
     735				save_to_file = save_to_file,
     736				print_out = print_out,
     737				output = output
     738				)
     739	else:
     740		if data48 is None:
     741			return data47.table_of_sessions(
     742				dir = dir,
     743				filename = filename,
     744				save_to_file = save_to_file,
     745				print_out = print_out,
     746				output = output
     747				)
     748		else:
     749			out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
     750			out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
     751			for k,x in enumerate(out47[0]):
     752				if k>7:
     753					out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47')
     754					out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48')
     755			out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:])
     756
     757			if save_to_file:
     758				if not os.path.exists(dir):
     759					os.makedirs(dir)
     760				if filename is None:
     761					filename = f'D47D48_sessions.csv'
     762				with open(f'{dir}/{filename}', 'w') as fid:
     763					fid.write(make_csv(out))
     764			if print_out:
     765				print('\n'+pretty_table(out))
     766			if output == 'raw':
     767				return out
     768			elif output == 'pretty':
     769				return pretty_table(out)
     770
     771
     772def table_of_analyses(
     773	data47 = None,
     774	data48 = None,
     775	dir = 'output',
     776	filename = None,
     777	save_to_file = True,
     778	print_out = True,
     779	output = None,
     780	):
     781	'''
     782	Print out, save to disk and/or return a combined table of analyses
     783	for a pair of `D47data` and `D48data` objects.
     784
     785	If the sessions in `data47` and those in `data48` do not consist of
     786	the exact same sets of analyses, the table will have two columns
     787	`Session_47` and `Session_48` instead of a single `Session` column.
     788
     789	**Parameters**
     790
     791	+ `data47`: `D47data` instance
     792	+ `data48`: `D48data` instance
     793	+ `dir`: the directory in which to save the table
     794	+ `filename`: the name to the csv file to write to
     795	+ `save_to_file`: whether to save the table to disk
     796	+ `print_out`: whether to print out the table
     797	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
     798		if set to `'raw'`: return a list of list of strings
     799		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
     800	'''
     801	if data47 is None:
     802		if data48 is None:
     803			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
     804		else:
     805			return data48.table_of_analyses(
     806				dir = dir,
     807				filename = filename,
     808				save_to_file = save_to_file,
     809				print_out = print_out,
     810				output = output
     811				)
     812	else:
     813		if data48 is None:
     814			return data47.table_of_analyses(
     815				dir = dir,
     816				filename = filename,
     817				save_to_file = save_to_file,
     818				print_out = print_out,
     819				output = output
     820				)
     821		else:
     822			out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
     823			out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
     824
     825			if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical
     826				out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:])
     827			else:
     828				out47[0][1] = 'Session_47'
     829				out48[0][1] = 'Session_48'
     830				out47 = transpose_table(out47)
     831				out48 = transpose_table(out48)
     832				out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:])
     833
     834			if save_to_file:
     835				if not os.path.exists(dir):
     836					os.makedirs(dir)
     837				if filename is None:
     838					filename = f'D47D48_sessions.csv'
     839				with open(f'{dir}/{filename}', 'w') as fid:
     840					fid.write(make_csv(out))
     841			if print_out:
     842				print('\n'+pretty_table(out))
     843			if output == 'raw':
     844				return out
     845			elif output == 'pretty':
     846				return pretty_table(out)
     847
     848
     849def _fullcovar(minresult, epsilon = 0.01, named = False):
     850	'''
     851	Construct full covariance matrix in the case of constrained parameters
     852	'''
     853
     854	import asteval
     855
     856	def f(values):
     857		interp = asteval.Interpreter()
     858		for n,v in zip(minresult.var_names, values):
     859			interp(f'{n} = {v}')
     860		for q in minresult.params:
     861			if minresult.params[q].expr:
     862				interp(f'{q} = {minresult.params[q].expr}')
     863		return np.array([interp.symtable[q] for q in minresult.params])
     864
     865	# construct Jacobian
     866	J = np.zeros((minresult.nvarys, len(minresult.params)))
     867	X = np.array([minresult.params[p].value for p in minresult.var_names])
     868	sX = np.array([minresult.params[p].stderr for p in minresult.var_names])
     869
     870	for j in range(minresult.nvarys):
     871		x1 = [_ for _ in X]
     872		x1[j] += epsilon * sX[j]
     873		x2 = [_ for _ in X]
     874		x2[j] -= epsilon * sX[j]
     875		J[j,:] = (f(x1) - f(x2)) / (2 * epsilon * sX[j])
     876
     877	_names = [q for q in minresult.params]
     878	_covar = J.T @ minresult.covar @ J
     879	_se = np.diag(_covar)**.5
     880	_correl = _covar.copy()
     881	for k,s in enumerate(_se):
     882		if s:
     883			_correl[k,:] /= s
     884			_correl[:,k] /= s
     885
     886	if named:
     887		_covar = {i: {j:_covar[i,j] for j in minresult.params} for i in minresult.params}
     888		_se = {i: _se[i] for i in minresult.params}
     889		_correl = {i: {j:_correl[i,j] for j in minresult.params} for i in minresult.params}
     890
     891	return _names, _covar, _se, _correl
     892
     893
     894class D4xdata(list):
     895	'''
     896	Store and process data for a large set of Δ47 and/or Δ48
     897	analyses, usually comprising more than one analytical session.
     898	'''
     899
     900	### 17O CORRECTION PARAMETERS
     901	R13_VPDB = 0.01118  # (Chang & Li, 1990)
     902	'''
     903	Absolute (13C/12C) ratio of VPDB.
     904	By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm))
     905	'''
     906
     907	R18_VSMOW = 0.0020052  # (Baertschi, 1976)
     908	'''
     909	Absolute (18O/16C) ratio of VSMOW.
     910	By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1))
     911	'''
     912
     913	LAMBDA_17 = 0.528  # (Barkan & Luz, 2005)
     914	'''
     915	Mass-dependent exponent for triple oxygen isotopes.
     916	By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250))
     917	'''
     918
     919	R17_VSMOW = 0.00038475  # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB)
     920	'''
     921	Absolute (17O/16C) ratio of VSMOW.
     922	By default equal to 0.00038475
     923	([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011),
     924	rescaled to `R13_VPDB`)
     925	'''
     926
     927	R18_VPDB = R18_VSMOW * 1.03092
     928	'''
     929	Absolute (18O/16C) ratio of VPDB.
     930	By definition equal to `R18_VSMOW * 1.03092`.
     931	'''
     932
     933	R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17
     934	'''
     935	Absolute (17O/16C) ratio of VPDB.
     936	By definition equal to `R17_VSMOW * 1.03092 ** LAMBDA_17`.
     937	'''
     938
     939	LEVENE_REF_SAMPLE = 'ETH-3'
     940	'''
     941	After the Δ4x standardization step, each sample is tested to
     942	assess whether the Δ4x variance within all analyses for that
     943	sample differs significantly from that observed for a given reference
     944	sample (using [Levene's test](https://en.wikipedia.org/wiki/Levene%27s_test),
     945	which yields a p-value corresponding to the null hypothesis that the
     946	underlying variances are equal).
     947
     948	`LEVENE_REF_SAMPLE` (by default equal to `'ETH-3'`) specifies which
     949	sample should be used as a reference for this test.
     950	'''
     951
     952	ALPHA_18O_ACID_REACTION = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6)  # (Kim et al., 2007, calcite)
     953	'''
     954	Specifies the 18O/16O fractionation factor generally applicable
     955	to acid reactions in the dataset. Currently used by `D4xdata.wg()`,
     956	`D4xdata.standardize_d13C`, and `D4xdata.standardize_d18O`.
     957
     958	By default equal to 1.008129 (calcite reacted at 90 °C,
     959	[Kim et al., 2007](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)).
     960	'''
     961
     962	Nominal_d13C_VPDB = {
     963		'ETH-1': 2.02,
     964		'ETH-2': -10.17,
     965		'ETH-3': 1.71,
     966		}	# (Bernasconi et al., 2018)
     967	'''
     968	Nominal δ13C_VPDB values assigned to carbonate standards, used by
     969	`D4xdata.standardize_d13C()`.
     970
     971	By default equal to `{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}` after
     972	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
     973	'''
     974
     975	Nominal_d18O_VPDB = {
     976		'ETH-1': -2.19,
     977		'ETH-2': -18.69,
     978		'ETH-3': -1.78,
     979		}	# (Bernasconi et al., 2018)
     980	'''
     981	Nominal δ18O_VPDB values assigned to carbonate standards, used by
     982	`D4xdata.standardize_d18O()`.
     983
     984	By default equal to `{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}` after
     985	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
     986	'''
     987
     988	d13C_STANDARDIZATION_METHOD = '2pt'
     989	'''
     990	Method by which to standardize δ13C values:
     991
     992	+ `none`: do not apply any δ13C standardization.
     993	+ `'1pt'`: within each session, offset all initial δ13C values so as to
     994	minimize the difference between final δ13C_VPDB values and
     995	`Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined).
     996	+ `'2pt'`: within each session, apply a affine trasformation to all δ13C
     997	values so as to minimize the difference between final δ13C_VPDB
     998	values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB`
     999	is defined).
    1000	'''
    1001
    1002	d18O_STANDARDIZATION_METHOD = '2pt'
    1003	'''
    1004	Method by which to standardize δ18O values:
    1005
    1006	+ `none`: do not apply any δ18O standardization.
    1007	+ `'1pt'`: within each session, offset all initial δ18O values so as to
    1008	minimize the difference between final δ18O_VPDB values and
    1009	`Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined).
    1010	+ `'2pt'`: within each session, apply a affine trasformation to all δ18O
    1011	values so as to minimize the difference between final δ18O_VPDB
    1012	values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB`
    1013	is defined).
    1014	'''
    1015
    1016	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
    1017		'''
    1018		**Parameters**
    1019
    1020		+ `l`: a list of dictionaries, with each dictionary including at least the keys
    1021		`Sample`, `d45`, `d46`, and `d47` or `d48`.
    1022		+ `mass`: `'47'` or `'48'`
    1023		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
    1024		+ `session`: define session name for analyses without a `Session` key
    1025		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
    1026
    1027		Returns a `D4xdata` object derived from `list`.
    1028		'''
    1029		self._4x = mass
    1030		self.verbose = verbose
    1031		self.prefix = 'D4xdata'
    1032		self.logfile = logfile
    1033		list.__init__(self, l)
    1034		self.Nf = None
    1035		self.repeatability = {}
    1036		self.refresh(session = session)
    1037
    1038
    1039	def make_verbal(oldfun):
    1040		'''
    1041		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
    1042		'''
    1043		@wraps(oldfun)
    1044		def newfun(*args, verbose = '', **kwargs):
    1045			myself = args[0]
    1046			oldprefix = myself.prefix
    1047			myself.prefix = oldfun.__name__
    1048			if verbose != '':
    1049				oldverbose = myself.verbose
    1050				myself.verbose = verbose
    1051			out = oldfun(*args, **kwargs)
    1052			myself.prefix = oldprefix
    1053			if verbose != '':
    1054				myself.verbose = oldverbose
    1055			return out
    1056		return newfun
    1057
    1058
    1059	def msg(self, txt):
    1060		'''
    1061		Log a message to `self.logfile`, and print it out if `verbose = True`
    1062		'''
    1063		self.log(txt)
    1064		if self.verbose:
    1065			print(f'{f"[{self.prefix}]":<16} {txt}')
    1066
    1067
    1068	def vmsg(self, txt):
    1069		'''
    1070		Log a message to `self.logfile` and print it out
    1071		'''
    1072		self.log(txt)
    1073		print(txt)
    1074
    1075
    1076	def log(self, *txts):
    1077		'''
    1078		Log a message to `self.logfile`
    1079		'''
    1080		if self.logfile:
    1081			with open(self.logfile, 'a') as fid:
    1082				for txt in txts:
    1083					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
    1084
    1085
    1086	def refresh(self, session = 'mySession'):
    1087		'''
    1088		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    1089		'''
    1090		self.fill_in_missing_info(session = session)
    1091		self.refresh_sessions()
    1092		self.refresh_samples()
    1093
    1094
    1095	def refresh_sessions(self):
    1096		'''
    1097		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    1098		to `False` for all sessions.
    1099		'''
    1100		self.sessions = {
    1101			s: {'data': [r for r in self if r['Session'] == s]}
    1102			for s in sorted({r['Session'] for r in self})
    1103			}
    1104		for s in self.sessions:
    1105			self.sessions[s]['scrambling_drift'] = False
    1106			self.sessions[s]['slope_drift'] = False
    1107			self.sessions[s]['wg_drift'] = False
    1108			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    1109			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
    1110
    1111
    1112	def refresh_samples(self):
    1113		'''
    1114		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    1115		'''
    1116		self.samples = {
    1117			s: {'data': [r for r in self if r['Sample'] == s]}
    1118			for s in sorted({r['Sample'] for r in self})
    1119			}
    1120		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    1121		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
    1122
    1123
    1124	def read(self, filename, sep = '', session = ''):
    1125		'''
    1126		Read file in csv format to load data into a `D47data` object.
    1127
    1128		In the csv file, spaces before and after field separators (`','` by default)
    1129		are optional. Each line corresponds to a single analysis.
    1130
    1131		The required fields are:
    1132
    1133		+ `UID`: a unique identifier
    1134		+ `Session`: an identifier for the analytical session
    1135		+ `Sample`: a sample identifier
    1136		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    1137
    1138		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    1139		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    1140		and `d49` are optional, and set to NaN by default.
    1141
    1142		**Parameters**
    1143
    1144		+ `fileneme`: the path of the file to read
    1145		+ `sep`: csv separator delimiting the fields
    1146		+ `session`: set `Session` field to this string for all analyses
    1147		'''
    1148		with open(filename) as fid:
    1149			self.input(fid.read(), sep = sep, session = session)
    1150
    1151
    1152	def input(self, txt, sep = '', session = ''):
    1153		'''
    1154		Read `txt` string in csv format to load analysis data into a `D47data` object.
    1155
    1156		In the csv string, spaces before and after field separators (`','` by default)
    1157		are optional. Each line corresponds to a single analysis.
    1158
    1159		The required fields are:
    1160
    1161		+ `UID`: a unique identifier
    1162		+ `Session`: an identifier for the analytical session
    1163		+ `Sample`: a sample identifier
    1164		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    1165
    1166		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    1167		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    1168		and `d49` are optional, and set to NaN by default.
    1169
    1170		**Parameters**
    1171
    1172		+ `txt`: the csv string to read
    1173		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    1174		whichever appers most often in `txt`.
    1175		+ `session`: set `Session` field to this string for all analyses
    1176		'''
    1177		if sep == '':
    1178			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    1179		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    1180		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:]]
    1181
    1182		if session != '':
    1183			for r in data:
    1184				r['Session'] = session
    1185
    1186		self += data
    1187		self.refresh()
    1188
    1189
    1190	@make_verbal
    1191	def wg(self, samples = None, a18_acid = None):
    1192		'''
    1193		Compute bulk composition of the working gas for each session based on
    1194		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
    1195		`self.Nominal_d18O_VPDB`.
    1196		'''
    1197
    1198		self.msg('Computing WG composition:')
    1199
    1200		if a18_acid is None:
    1201			a18_acid = self.ALPHA_18O_ACID_REACTION
    1202		if samples is None:
    1203			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
    1204
    1205		assert a18_acid, f'Acid fractionation factor should not be zero.'
    1206
    1207		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
    1208		R45R46_standards = {}
    1209		for sample in samples:
    1210			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
    1211			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
    1212			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
    1213			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
    1214			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
    1215
    1216			C12_s = 1 / (1 + R13_s)
    1217			C13_s = R13_s / (1 + R13_s)
    1218			C16_s = 1 / (1 + R17_s + R18_s)
    1219			C17_s = R17_s / (1 + R17_s + R18_s)
    1220			C18_s = R18_s / (1 + R17_s + R18_s)
    1221
    1222			C626_s = C12_s * C16_s ** 2
    1223			C627_s = 2 * C12_s * C16_s * C17_s
    1224			C628_s = 2 * C12_s * C16_s * C18_s
    1225			C636_s = C13_s * C16_s ** 2
    1226			C637_s = 2 * C13_s * C16_s * C17_s
    1227			C727_s = C12_s * C17_s ** 2
    1228
    1229			R45_s = (C627_s + C636_s) / C626_s
    1230			R46_s = (C628_s + C637_s + C727_s) / C626_s
    1231			R45R46_standards[sample] = (R45_s, R46_s)
    1232
    1233		for s in self.sessions:
    1234			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
    1235			assert db, f'No sample from {samples} found in session "{s}".'
    1236# 			dbsamples = sorted({r['Sample'] for r in db})
    1237
    1238			X = [r['d45'] for r in db]
    1239			Y = [R45R46_standards[r['Sample']][0] for r in db]
    1240			x1, x2 = np.min(X), np.max(X)
    1241
    1242			if x1 < x2:
    1243				wgcoord = x1/(x1-x2)
    1244			else:
    1245				wgcoord = 999
    1246
    1247			if wgcoord < -.5 or wgcoord > 1.5:
    1248				# unreasonable to extrapolate to d45 = 0
    1249				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    1250			else :
    1251				# d45 = 0 is reasonably well bracketed
    1252				R45_wg = np.polyfit(X, Y, 1)[1]
    1253
    1254			X = [r['d46'] for r in db]
    1255			Y = [R45R46_standards[r['Sample']][1] for r in db]
    1256			x1, x2 = np.min(X), np.max(X)
    1257
    1258			if x1 < x2:
    1259				wgcoord = x1/(x1-x2)
    1260			else:
    1261				wgcoord = 999
    1262
    1263			if wgcoord < -.5 or wgcoord > 1.5:
    1264				# unreasonable to extrapolate to d46 = 0
    1265				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    1266			else :
    1267				# d46 = 0 is reasonably well bracketed
    1268				R46_wg = np.polyfit(X, Y, 1)[1]
    1269
    1270			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
    1271
    1272			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
    1273
    1274			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
    1275			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
    1276			for r in self.sessions[s]['data']:
    1277				r['d13Cwg_VPDB'] = d13Cwg_VPDB
    1278				r['d18Owg_VSMOW'] = d18Owg_VSMOW
    1279
    1280
    1281	def compute_bulk_delta(self, R45, R46, D17O = 0):
    1282		'''
    1283		Compute δ13C_VPDB and δ18O_VSMOW,
    1284		by solving the generalized form of equation (17) from
    1285		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
    1286		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
    1287		solving the corresponding second-order Taylor polynomial.
    1288		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
    1289		'''
    1290
    1291		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
    1292
    1293		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
    1294		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
    1295		C = 2 * self.R18_VSMOW
    1296		D = -R46
    1297
    1298		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
    1299		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
    1300		cc = A + B + C + D
    1301
    1302		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
    1303
    1304		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
    1305		R17 = K * R18 ** self.LAMBDA_17
    1306		R13 = R45 - 2 * R17
    1307
    1308		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
    1309
    1310		return d13C_VPDB, d18O_VSMOW
    1311
    1312
    1313	@make_verbal
    1314	def crunch(self, verbose = ''):
    1315		'''
    1316		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    1317		'''
    1318		for r in self:
    1319			self.compute_bulk_and_clumping_deltas(r)
    1320		self.standardize_d13C()
    1321		self.standardize_d18O()
    1322		self.msg(f"Crunched {len(self)} analyses.")
    1323
    1324
    1325	def fill_in_missing_info(self, session = 'mySession'):
    1326		'''
    1327		Fill in optional fields with default values
    1328		'''
    1329		for i,r in enumerate(self):
    1330			if 'D17O' not in r:
    1331				r['D17O'] = 0.
    1332			if 'UID' not in r:
    1333				r['UID'] = f'{i+1}'
    1334			if 'Session' not in r:
    1335				r['Session'] = session
    1336			for k in ['d47', 'd48', 'd49']:
    1337				if k not in r:
    1338					r[k] = np.nan
    1339
    1340
    1341	def standardize_d13C(self):
    1342		'''
    1343		Perform δ13C standadization within each session `s` according to
    1344		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
    1345		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
    1346		may be redefined abitrarily at a later stage.
    1347		'''
    1348		for s in self.sessions:
    1349			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
    1350				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]
    1351				X,Y = zip(*XY)
    1352				if self.sessions[s]['d13C_standardization_method'] == '1pt':
    1353					offset = np.mean(Y) - np.mean(X)
    1354					for r in self.sessions[s]['data']:
    1355						r['d13C_VPDB'] += offset
    1356				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
    1357					a,b = np.polyfit(X,Y,1)
    1358					for r in self.sessions[s]['data']:
    1359						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
    1360
    1361	def standardize_d18O(self):
    1362		'''
    1363		Perform δ18O standadization within each session `s` according to
    1364		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
    1365		which is defined by default by `D47data.refresh_sessions()`as equal to
    1366		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
    1367		'''
    1368		for s in self.sessions:
    1369			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
    1370				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]
    1371				X,Y = zip(*XY)
    1372				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
    1373				if self.sessions[s]['d18O_standardization_method'] == '1pt':
    1374					offset = np.mean(Y) - np.mean(X)
    1375					for r in self.sessions[s]['data']:
    1376						r['d18O_VSMOW'] += offset
    1377				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
    1378					a,b = np.polyfit(X,Y,1)
    1379					for r in self.sessions[s]['data']:
    1380						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
    1381
    1382
    1383	def compute_bulk_and_clumping_deltas(self, r):
    1384		'''
    1385		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
    1386		'''
    1387
    1388		# Compute working gas R13, R18, and isobar ratios
    1389		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
    1390		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
    1391		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
    1392
    1393		# Compute analyte isobar ratios
    1394		R45 = (1 + r['d45'] / 1000) * R45_wg
    1395		R46 = (1 + r['d46'] / 1000) * R46_wg
    1396		R47 = (1 + r['d47'] / 1000) * R47_wg
    1397		R48 = (1 + r['d48'] / 1000) * R48_wg
    1398		R49 = (1 + r['d49'] / 1000) * R49_wg
    1399
    1400		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
    1401		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
    1402		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
    1403
    1404		# Compute stochastic isobar ratios of the analyte
    1405		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
    1406			R13, R18, D17O = r['D17O']
    1407		)
    1408
    1409		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
    1410		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
    1411		if (R45 / R45stoch - 1) > 5e-8:
    1412			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
    1413		if (R46 / R46stoch - 1) > 5e-8:
    1414			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
    1415
    1416		# Compute raw clumped isotope anomalies
    1417		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
    1418		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
    1419		r['D49raw'] = 1000 * (R49 / R49stoch - 1)
    1420
    1421
    1422	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
    1423		'''
    1424		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
    1425		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
    1426		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
    1427		'''
    1428
    1429		# Compute R17
    1430		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
    1431
    1432		# Compute isotope concentrations
    1433		C12 = (1 + R13) ** -1
    1434		C13 = C12 * R13
    1435		C16 = (1 + R17 + R18) ** -1
    1436		C17 = C16 * R17
    1437		C18 = C16 * R18
    1438
    1439		# Compute stochastic isotopologue concentrations
    1440		C626 = C16 * C12 * C16
    1441		C627 = C16 * C12 * C17 * 2
    1442		C628 = C16 * C12 * C18 * 2
    1443		C636 = C16 * C13 * C16
    1444		C637 = C16 * C13 * C17 * 2
    1445		C638 = C16 * C13 * C18 * 2
    1446		C727 = C17 * C12 * C17
    1447		C728 = C17 * C12 * C18 * 2
    1448		C737 = C17 * C13 * C17
    1449		C738 = C17 * C13 * C18 * 2
    1450		C828 = C18 * C12 * C18
    1451		C838 = C18 * C13 * C18
    1452
    1453		# Compute stochastic isobar ratios
    1454		R45 = (C636 + C627) / C626
    1455		R46 = (C628 + C637 + C727) / C626
    1456		R47 = (C638 + C728 + C737) / C626
    1457		R48 = (C738 + C828) / C626
    1458		R49 = C838 / C626
    1459
    1460		# Account for stochastic anomalies
    1461		R47 *= 1 + D47 / 1000
    1462		R48 *= 1 + D48 / 1000
    1463		R49 *= 1 + D49 / 1000
    1464
    1465		# Return isobar ratios
    1466		return R45, R46, R47, R48, R49
    1467
    1468
    1469	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
    1470		'''
    1471		Split unknown samples by UID (treat all analyses as different samples)
    1472		or by session (treat analyses of a given sample in different sessions as
    1473		different samples).
    1474
    1475		**Parameters**
    1476
    1477		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
    1478		+ `grouping`: `by_uid` | `by_session`
    1479		'''
    1480		if samples_to_split == 'all':
    1481			samples_to_split = [s for s in self.unknowns]
    1482		gkeys = {'by_uid':'UID', 'by_session':'Session'}
    1483		self.grouping = grouping.lower()
    1484		if self.grouping in gkeys:
    1485			gkey = gkeys[self.grouping]
    1486		for r in self:
    1487			if r['Sample'] in samples_to_split:
    1488				r['Sample_original'] = r['Sample']
    1489				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
    1490			elif r['Sample'] in self.unknowns:
    1491				r['Sample_original'] = r['Sample']
    1492		self.refresh_samples()
    1493
    1494
    1495	def unsplit_samples(self, tables = False):
    1496		'''
    1497		Reverse the effects of `D47data.split_samples()`.
    1498
    1499		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
    1500
    1501		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
    1502		probably use `D4xdata.combine_samples()` instead to reverse the effects of
    1503		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
    1504		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
    1505		that case session-averaged Δ4x values are statistically independent).
    1506		'''
    1507		unknowns_old = sorted({s for s in self.unknowns})
    1508		CM_old = self.standardization.covar[:,:]
    1509		VD_old = self.standardization.params.valuesdict().copy()
    1510		vars_old = self.standardization.var_names
    1511
    1512		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
    1513
    1514		Ns = len(vars_old) - len(unknowns_old)
    1515		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
    1516		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
    1517
    1518		W = np.zeros((len(vars_new), len(vars_old)))
    1519		W[:Ns,:Ns] = np.eye(Ns)
    1520		for u in unknowns_new:
    1521			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
    1522			if self.grouping == 'by_session':
    1523				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
    1524			elif self.grouping == 'by_uid':
    1525				weights = [1 for s in splits]
    1526			sw = sum(weights)
    1527			weights = [w/sw for w in weights]
    1528			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
    1529
    1530		CM_new = W @ CM_old @ W.T
    1531		V = W @ np.array([[VD_old[k]] for k in vars_old])
    1532		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
    1533
    1534		self.standardization.covar = CM_new
    1535		self.standardization.params.valuesdict = lambda : VD_new
    1536		self.standardization.var_names = vars_new
    1537
    1538		for r in self:
    1539			if r['Sample'] in self.unknowns:
    1540				r['Sample_split'] = r['Sample']
    1541				r['Sample'] = r['Sample_original']
    1542
    1543		self.refresh_samples()
    1544		self.consolidate_samples()
    1545		self.repeatabilities()
    1546
    1547		if tables:
    1548			self.table_of_analyses()
    1549			self.table_of_samples()
    1550
    1551	def assign_timestamps(self):
    1552		'''
    1553		Assign a time field `t` of type `float` to each analysis.
    1554
    1555		If `TimeTag` is one of the data fields, `t` is equal within a given session
    1556		to `TimeTag` minus the mean value of `TimeTag` for that session.
    1557		Otherwise, `TimeTag` is by default equal to the index of each analysis
    1558		in the dataset and `t` is defined as above.
    1559		'''
    1560		for session in self.sessions:
    1561			sdata = self.sessions[session]['data']
    1562			try:
    1563				t0 = np.mean([r['TimeTag'] for r in sdata])
    1564				for r in sdata:
    1565					r['t'] = r['TimeTag'] - t0
    1566			except KeyError:
    1567				t0 = (len(sdata)-1)/2
    1568				for t,r in enumerate(sdata):
    1569					r['t'] = t - t0
    1570
    1571
    1572	def report(self):
    1573		'''
    1574		Prints a report on the standardization fit.
    1575		Only applicable after `D4xdata.standardize(method='pooled')`.
    1576		'''
    1577		report_fit(self.standardization)
    1578
    1579
    1580	def combine_samples(self, sample_groups):
    1581		'''
    1582		Combine analyses of different samples to compute weighted average Δ4x
    1583		and new error (co)variances corresponding to the groups defined by the `sample_groups`
    1584		dictionary.
    1585
    1586		Caution: samples are weighted by number of replicate analyses, which is a
    1587		reasonable default behavior but is not always optimal (e.g., in the case of strongly
    1588		correlated analytical errors for one or more samples).
    1589
    1590		Returns a tuplet of:
    1591
    1592		+ the list of group names
    1593		+ an array of the corresponding Δ4x values
    1594		+ the corresponding (co)variance matrix
    1595
    1596		**Parameters**
    1597
    1598		+ `sample_groups`: a dictionary of the form:
    1599		```py
    1600		{'group1': ['sample_1', 'sample_2'],
    1601		 'group2': ['sample_3', 'sample_4', 'sample_5']}
    1602		```
    1603		'''
    1604
    1605		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
    1606		groups = sorted(sample_groups.keys())
    1607		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
    1608		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
    1609		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
    1610		W = np.array([
    1611			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
    1612			for j in groups])
    1613		D4x_new = W @ D4x_old
    1614		CM_new = W @ CM_old @ W.T
    1615
    1616		return groups, D4x_new[:,0], CM_new
    1617
    1618
    1619	@make_verbal
    1620	def standardize(self,
    1621		method = 'pooled',
    1622		weighted_sessions = [],
    1623		consolidate = True,
    1624		consolidate_tables = False,
    1625		consolidate_plots = False,
    1626		constraints = {},
    1627		):
    1628		'''
    1629		Compute absolute Δ4x values for all replicate analyses and for sample averages.
    1630		If `method` argument is set to `'pooled'`, the standardization processes all sessions
    1631		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
    1632		i.e. that their true Δ4x value does not change between sessions,
    1633		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
    1634		`'indep_sessions'`, the standardization processes each session independently, based only
    1635		on anchors analyses.
    1636		'''
    1637
    1638		self.standardization_method = method
    1639		self.assign_timestamps()
    1640
    1641		if method == 'pooled':
    1642			if weighted_sessions:
    1643				for session_group in weighted_sessions:
    1644					if self._4x == '47':
    1645						X = D47data([r for r in self if r['Session'] in session_group])
    1646					elif self._4x == '48':
    1647						X = D48data([r for r in self if r['Session'] in session_group])
    1648					X.Nominal_D4x = self.Nominal_D4x.copy()
    1649					X.refresh()
    1650					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
    1651					w = np.sqrt(result.redchi)
    1652					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
    1653					for r in X:
    1654						r[f'wD{self._4x}raw'] *= w
    1655			else:
    1656				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
    1657				for r in self:
    1658					r[f'wD{self._4x}raw'] = 1.
    1659
    1660			params = Parameters()
    1661			for k,session in enumerate(self.sessions):
    1662				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
    1663				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
    1664				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
    1665				s = pf(session)
    1666				params.add(f'a_{s}', value = 0.9)
    1667				params.add(f'b_{s}', value = 0.)
    1668				params.add(f'c_{s}', value = -0.9)
    1669				params.add(f'a2_{s}', value = 0.,
    1670# 					vary = self.sessions[session]['scrambling_drift'],
    1671					)
    1672				params.add(f'b2_{s}', value = 0.,
    1673# 					vary = self.sessions[session]['slope_drift'],
    1674					)
    1675				params.add(f'c2_{s}', value = 0.,
    1676# 					vary = self.sessions[session]['wg_drift'],
    1677					)
    1678				if not self.sessions[session]['scrambling_drift']:
    1679					params[f'a2_{s}'].expr = '0'
    1680				if not self.sessions[session]['slope_drift']:
    1681					params[f'b2_{s}'].expr = '0'
    1682				if not self.sessions[session]['wg_drift']:
    1683					params[f'c2_{s}'].expr = '0'
    1684
    1685			for sample in self.unknowns:
    1686				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
    1687
    1688			for k in constraints:
    1689				params[k].expr = constraints[k]
    1690
    1691			def residuals(p):
    1692				R = []
    1693				for r in self:
    1694					session = pf(r['Session'])
    1695					sample = pf(r['Sample'])
    1696					if r['Sample'] in self.Nominal_D4x:
    1697						R += [ (
    1698							r[f'D{self._4x}raw'] - (
    1699								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
    1700								+ p[f'b_{session}'] * r[f'd{self._4x}']
    1701								+	p[f'c_{session}']
    1702								+ r['t'] * (
    1703									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
    1704									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    1705									+	p[f'c2_{session}']
    1706									)
    1707								)
    1708							) / r[f'wD{self._4x}raw'] ]
    1709					else:
    1710						R += [ (
    1711							r[f'D{self._4x}raw'] - (
    1712								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
    1713								+ p[f'b_{session}'] * r[f'd{self._4x}']
    1714								+	p[f'c_{session}']
    1715								+ r['t'] * (
    1716									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
    1717									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    1718									+	p[f'c2_{session}']
    1719									)
    1720								)
    1721							) / r[f'wD{self._4x}raw'] ]
    1722				return R
    1723
    1724			M = Minimizer(residuals, params)
    1725			result = M.least_squares()
    1726			self.Nf = result.nfree
    1727			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
    1728			new_names, new_covar, new_se = _fullcovar(result)[:3]
    1729			result.var_names = new_names
    1730			result.covar = new_covar
    1731
    1732			for r in self:
    1733				s = pf(r["Session"])
    1734				a = result.params.valuesdict()[f'a_{s}']
    1735				b = result.params.valuesdict()[f'b_{s}']
    1736				c = result.params.valuesdict()[f'c_{s}']
    1737				a2 = result.params.valuesdict()[f'a2_{s}']
    1738				b2 = result.params.valuesdict()[f'b2_{s}']
    1739				c2 = result.params.valuesdict()[f'c2_{s}']
    1740				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'])
    1741
    1742			self.standardization = result
    1743
    1744			for session in self.sessions:
    1745				self.sessions[session]['Np'] = 3
    1746				for k in ['scrambling', 'slope', 'wg']:
    1747					if self.sessions[session][f'{k}_drift']:
    1748						self.sessions[session]['Np'] += 1
    1749
    1750			if consolidate:
    1751				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
    1752			return result
    1753
    1754
    1755		elif method == 'indep_sessions':
    1756
    1757			if weighted_sessions:
    1758				for session_group in weighted_sessions:
    1759					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
    1760					X.Nominal_D4x = self.Nominal_D4x.copy()
    1761					X.refresh()
    1762					# This is only done to assign r['wD47raw'] for r in X:
    1763					X.standardize(method = method, weighted_sessions = [], consolidate = False)
    1764					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}')
    1765			else:
    1766				self.msg('All weights set to 1 ‰')
    1767				for r in self:
    1768					r[f'wD{self._4x}raw'] = 1
    1769
    1770			for session in self.sessions:
    1771				s = self.sessions[session]
    1772				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
    1773				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
    1774				s['Np'] = sum(p_active)
    1775				sdata = s['data']
    1776
    1777				A = np.array([
    1778					[
    1779						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
    1780						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
    1781						1 / r[f'wD{self._4x}raw'],
    1782						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
    1783						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
    1784						r['t'] / r[f'wD{self._4x}raw']
    1785						]
    1786					for r in sdata if r['Sample'] in self.anchors
    1787					])[:,p_active] # only keep columns for the active parameters
    1788				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])
    1789				s['Na'] = Y.size
    1790				CM = linalg.inv(A.T @ A)
    1791				bf = (CM @ A.T @ Y).T[0,:]
    1792				k = 0
    1793				for n,a in zip(p_names, p_active):
    1794					if a:
    1795						s[n] = bf[k]
    1796# 						self.msg(f'{n} = {bf[k]}')
    1797						k += 1
    1798					else:
    1799						s[n] = 0.
    1800# 						self.msg(f'{n} = 0.0')
    1801
    1802				for r in sdata :
    1803					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
    1804					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'])
    1805					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
    1806
    1807				s['CM'] = np.zeros((6,6))
    1808				i = 0
    1809				k_active = [j for j,a in enumerate(p_active) if a]
    1810				for j,a in enumerate(p_active):
    1811					if a:
    1812						s['CM'][j,k_active] = CM[i,:]
    1813						i += 1
    1814
    1815			if not weighted_sessions:
    1816				w = self.rmswd()['rmswd']
    1817				for r in self:
    1818						r[f'wD{self._4x}'] *= w
    1819						r[f'wD{self._4x}raw'] *= w
    1820				for session in self.sessions:
    1821					self.sessions[session]['CM'] *= w**2
    1822
    1823			for session in self.sessions:
    1824				s = self.sessions[session]
    1825				s['SE_a'] = s['CM'][0,0]**.5
    1826				s['SE_b'] = s['CM'][1,1]**.5
    1827				s['SE_c'] = s['CM'][2,2]**.5
    1828				s['SE_a2'] = s['CM'][3,3]**.5
    1829				s['SE_b2'] = s['CM'][4,4]**.5
    1830				s['SE_c2'] = s['CM'][5,5]**.5
    1831
    1832			if not weighted_sessions:
    1833				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
    1834			else:
    1835				self.Nf = 0
    1836				for sg in weighted_sessions:
    1837					self.Nf += self.rmswd(sessions = sg)['Nf']
    1838
    1839			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
    1840
    1841			avgD4x = {
    1842				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
    1843				for sample in self.samples
    1844				}
    1845			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
    1846			rD4x = (chi2/self.Nf)**.5
    1847			self.repeatability[f'sigma_{self._4x}'] = rD4x
    1848
    1849			if consolidate:
    1850				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
    1851
    1852
    1853	def standardization_error(self, session, d4x, D4x, t = 0):
    1854		'''
    1855		Compute standardization error for a given session and
    1856		(δ47, Δ47) composition.
    1857		'''
    1858		a = self.sessions[session]['a']
    1859		b = self.sessions[session]['b']
    1860		c = self.sessions[session]['c']
    1861		a2 = self.sessions[session]['a2']
    1862		b2 = self.sessions[session]['b2']
    1863		c2 = self.sessions[session]['c2']
    1864		CM = self.sessions[session]['CM']
    1865
    1866		x, y = D4x, d4x
    1867		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
    1868# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
    1869		dxdy = -(b+b2*t) / (a+a2*t)
    1870		dxdz = 1. / (a+a2*t)
    1871		dxda = -x / (a+a2*t)
    1872		dxdb = -y / (a+a2*t)
    1873		dxdc = -1. / (a+a2*t)
    1874		dxda2 = -x * a2 / (a+a2*t)
    1875		dxdb2 = -y * t / (a+a2*t)
    1876		dxdc2 = -t / (a+a2*t)
    1877		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
    1878		sx = (V @ CM @ V.T) ** .5
    1879		return sx
    1880
    1881
    1882	@make_verbal
    1883	def summary(self,
    1884		dir = 'output',
    1885		filename = None,
    1886		save_to_file = True,
    1887		print_out = True,
    1888		):
    1889		'''
    1890		Print out an/or save to disk a summary of the standardization results.
    1891
    1892		**Parameters**
    1893
    1894		+ `dir`: the directory in which to save the table
    1895		+ `filename`: the name to the csv file to write to
    1896		+ `save_to_file`: whether to save the table to disk
    1897		+ `print_out`: whether to print out the table
    1898		'''
    1899
    1900		out = []
    1901		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
    1902		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])})"]]
    1903		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
    1904		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
    1905		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
    1906		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
    1907		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
    1908		out += [['Model degrees of freedom', f"{self.Nf}"]]
    1909		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
    1910		out += [['Standardization method', self.standardization_method]]
    1911
    1912		if save_to_file:
    1913			if not os.path.exists(dir):
    1914				os.makedirs(dir)
    1915			if filename is None:
    1916				filename = f'D{self._4x}_summary.csv'
    1917			with open(f'{dir}/{filename}', 'w') as fid:
    1918				fid.write(make_csv(out))
    1919		if print_out:
    1920			self.msg('\n' + pretty_table(out, header = 0))
    1921
    1922
    1923	@make_verbal
    1924	def table_of_sessions(self,
    1925		dir = 'output',
    1926		filename = None,
    1927		save_to_file = True,
    1928		print_out = True,
    1929		output = None,
    1930		):
    1931		'''
    1932		Print out an/or save to disk a table of sessions.
    1933
    1934		**Parameters**
    1935
    1936		+ `dir`: the directory in which to save the table
    1937		+ `filename`: the name to the csv file to write to
    1938		+ `save_to_file`: whether to save the table to disk
    1939		+ `print_out`: whether to print out the table
    1940		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    1941		    if set to `'raw'`: return a list of list of strings
    1942		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    1943		'''
    1944		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
    1945		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
    1946		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
    1947
    1948		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']]
    1949		if include_a2:
    1950			out[-1] += ['a2 ± SE']
    1951		if include_b2:
    1952			out[-1] += ['b2 ± SE']
    1953		if include_c2:
    1954			out[-1] += ['c2 ± SE']
    1955		for session in self.sessions:
    1956			out += [[
    1957				session,
    1958				f"{self.sessions[session]['Na']}",
    1959				f"{self.sessions[session]['Nu']}",
    1960				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
    1961				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
    1962				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
    1963				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
    1964				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
    1965				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
    1966				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
    1967				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
    1968				]]
    1969			if include_a2:
    1970				if self.sessions[session]['scrambling_drift']:
    1971					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
    1972				else:
    1973					out[-1] += ['']
    1974			if include_b2:
    1975				if self.sessions[session]['slope_drift']:
    1976					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
    1977				else:
    1978					out[-1] += ['']
    1979			if include_c2:
    1980				if self.sessions[session]['wg_drift']:
    1981					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
    1982				else:
    1983					out[-1] += ['']
    1984
    1985		if save_to_file:
    1986			if not os.path.exists(dir):
    1987				os.makedirs(dir)
    1988			if filename is None:
    1989				filename = f'D{self._4x}_sessions.csv'
    1990			with open(f'{dir}/{filename}', 'w') as fid:
    1991				fid.write(make_csv(out))
    1992		if print_out:
    1993			self.msg('\n' + pretty_table(out))
    1994		if output == 'raw':
    1995			return out
    1996		elif output == 'pretty':
    1997			return pretty_table(out)
    1998
    1999
    2000	@make_verbal
    2001	def table_of_analyses(
    2002		self,
    2003		dir = 'output',
    2004		filename = None,
    2005		save_to_file = True,
    2006		print_out = True,
    2007		output = None,
    2008		):
    2009		'''
    2010		Print out an/or save to disk a table of analyses.
    2011
    2012		**Parameters**
    2013
    2014		+ `dir`: the directory in which to save the table
    2015		+ `filename`: the name to the csv file to write to
    2016		+ `save_to_file`: whether to save the table to disk
    2017		+ `print_out`: whether to print out the table
    2018		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    2019		    if set to `'raw'`: return a list of list of strings
    2020		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    2021		'''
    2022
    2023		out = [['UID','Session','Sample']]
    2024		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}]
    2025		for f in extra_fields:
    2026			out[-1] += [f[0]]
    2027		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
    2028		for r in self:
    2029			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
    2030			for f in extra_fields:
    2031				out[-1] += [f"{r[f[0]]:{f[1]}}"]
    2032			out[-1] += [
    2033				f"{r['d13Cwg_VPDB']:.3f}",
    2034				f"{r['d18Owg_VSMOW']:.3f}",
    2035				f"{r['d45']:.6f}",
    2036				f"{r['d46']:.6f}",
    2037				f"{r['d47']:.6f}",
    2038				f"{r['d48']:.6f}",
    2039				f"{r['d49']:.6f}",
    2040				f"{r['d13C_VPDB']:.6f}",
    2041				f"{r['d18O_VSMOW']:.6f}",
    2042				f"{r['D47raw']:.6f}",
    2043				f"{r['D48raw']:.6f}",
    2044				f"{r['D49raw']:.6f}",
    2045				f"{r[f'D{self._4x}']:.6f}"
    2046				]
    2047		if save_to_file:
    2048			if not os.path.exists(dir):
    2049				os.makedirs(dir)
    2050			if filename is None:
    2051				filename = f'D{self._4x}_analyses.csv'
    2052			with open(f'{dir}/{filename}', 'w') as fid:
    2053				fid.write(make_csv(out))
    2054		if print_out:
    2055			self.msg('\n' + pretty_table(out))
    2056		return out
    2057
    2058	@make_verbal
    2059	def covar_table(
    2060		self,
    2061		correl = False,
    2062		dir = 'output',
    2063		filename = None,
    2064		save_to_file = True,
    2065		print_out = True,
    2066		output = None,
    2067		):
    2068		'''
    2069		Print out, save to disk and/or return the variance-covariance matrix of D4x
    2070		for all unknown samples.
    2071
    2072		**Parameters**
    2073
    2074		+ `dir`: the directory in which to save the csv
    2075		+ `filename`: the name of the csv file to write to
    2076		+ `save_to_file`: whether to save the csv
    2077		+ `print_out`: whether to print out the matrix
    2078		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
    2079		    if set to `'raw'`: return a list of list of strings
    2080		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    2081		'''
    2082		samples = sorted([u for u in self.unknowns])
    2083		out = [[''] + samples]
    2084		for s1 in samples:
    2085			out.append([s1])
    2086			for s2 in samples:
    2087				if correl:
    2088					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
    2089				else:
    2090					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
    2091
    2092		if save_to_file:
    2093			if not os.path.exists(dir):
    2094				os.makedirs(dir)
    2095			if filename is None:
    2096				if correl:
    2097					filename = f'D{self._4x}_correl.csv'
    2098				else:
    2099					filename = f'D{self._4x}_covar.csv'
    2100			with open(f'{dir}/{filename}', 'w') as fid:
    2101				fid.write(make_csv(out))
    2102		if print_out:
    2103			self.msg('\n'+pretty_table(out))
    2104		if output == 'raw':
    2105			return out
    2106		elif output == 'pretty':
    2107			return pretty_table(out)
    2108
    2109	@make_verbal
    2110	def table_of_samples(
    2111		self,
    2112		dir = 'output',
    2113		filename = None,
    2114		save_to_file = True,
    2115		print_out = True,
    2116		output = None,
    2117		):
    2118		'''
    2119		Print out, save to disk and/or return a table of samples.
    2120
    2121		**Parameters**
    2122
    2123		+ `dir`: the directory in which to save the csv
    2124		+ `filename`: the name of the csv file to write to
    2125		+ `save_to_file`: whether to save the csv
    2126		+ `print_out`: whether to print out the table
    2127		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    2128		    if set to `'raw'`: return a list of list of strings
    2129		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    2130		'''
    2131
    2132		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
    2133		for sample in self.anchors:
    2134			out += [[
    2135				f"{sample}",
    2136				f"{self.samples[sample]['N']}",
    2137				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    2138				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    2139				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
    2140				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
    2141				]]
    2142		for sample in self.unknowns:
    2143			out += [[
    2144				f"{sample}",
    2145				f"{self.samples[sample]['N']}",
    2146				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    2147				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    2148				f"{self.samples[sample][f'D{self._4x}']:.4f}",
    2149				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
    2150				f"± {self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
    2151				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
    2152				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
    2153				]]
    2154		if save_to_file:
    2155			if not os.path.exists(dir):
    2156				os.makedirs(dir)
    2157			if filename is None:
    2158				filename = f'D{self._4x}_samples.csv'
    2159			with open(f'{dir}/{filename}', 'w') as fid:
    2160				fid.write(make_csv(out))
    2161		if print_out:
    2162			self.msg('\n'+pretty_table(out))
    2163		if output == 'raw':
    2164			return out
    2165		elif output == 'pretty':
    2166			return pretty_table(out)
    2167
    2168
    2169	def plot_sessions(self, dir = 'output', figsize = (8,8)):
    2170		'''
    2171		Generate session plots and save them to disk.
    2172
    2173		**Parameters**
    2174
    2175		+ `dir`: the directory in which to save the plots
    2176		+ `figsize`: the width and height (in inches) of each plot
    2177		'''
    2178		if not os.path.exists(dir):
    2179			os.makedirs(dir)
    2180
    2181		for session in self.sessions:
    2182			sp = self.plot_single_session(session, xylimits = 'constant')
    2183			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
    2184			ppl.close(sp.fig)
    2185
    2186
    2187	@make_verbal
    2188	def consolidate_samples(self):
    2189		'''
    2190		Compile various statistics for each sample.
    2191
    2192		For each anchor sample:
    2193
    2194		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
    2195		+ `SE_D47` or `SE_D48`: set to zero by definition
    2196
    2197		For each unknown sample:
    2198
    2199		+ `D47` or `D48`: the standardized Δ4x value for this unknown
    2200		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
    2201
    2202		For each anchor and unknown:
    2203
    2204		+ `N`: the total number of analyses of this sample
    2205		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
    2206		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
    2207		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
    2208		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
    2209		variance, indicating whether the Δ4x repeatability this sample differs significantly from
    2210		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
    2211		'''
    2212		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
    2213		for sample in self.samples:
    2214			self.samples[sample]['N'] = len(self.samples[sample]['data'])
    2215			if self.samples[sample]['N'] > 1:
    2216				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
    2217
    2218			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
    2219			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
    2220
    2221			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
    2222			if len(D4x_pop) > 2:
    2223				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
    2224
    2225		if self.standardization_method == 'pooled':
    2226			for sample in self.anchors:
    2227				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    2228				self.samples[sample][f'SE_D{self._4x}'] = 0.
    2229			for sample in self.unknowns:
    2230				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
    2231				try:
    2232					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
    2233				except ValueError:
    2234					# when `sample` is constrained by self.standardize(constraints = {...}),
    2235					# it is no longer listed in self.standardization.var_names.
    2236					# Temporary fix: define SE as zero for now
    2237					self.samples[sample][f'SE_D4{self._4x}'] = 0.
    2238
    2239		elif self.standardization_method == 'indep_sessions':
    2240			for sample in self.anchors:
    2241				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    2242				self.samples[sample][f'SE_D{self._4x}'] = 0.
    2243			for sample in self.unknowns:
    2244				self.msg(f'Consolidating sample {sample}')
    2245				self.unknowns[sample][f'session_D{self._4x}'] = {}
    2246				session_avg = []
    2247				for session in self.sessions:
    2248					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
    2249					if sdata:
    2250						self.msg(f'{sample} found in session {session}')
    2251						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
    2252						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
    2253						# !! TODO: sigma_s below does not account for temporal changes in standardization error
    2254						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
    2255						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
    2256						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
    2257						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
    2258				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
    2259				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
    2260				wsum = sum([weights[s] for s in weights])
    2261				for s in weights:
    2262					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
    2263
    2264
    2265	def consolidate_sessions(self):
    2266		'''
    2267		Compute various statistics for each session.
    2268
    2269		+ `Na`: Number of anchor analyses in the session
    2270		+ `Nu`: Number of unknown analyses in the session
    2271		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
    2272		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
    2273		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
    2274		+ `a`: scrambling factor
    2275		+ `b`: compositional slope
    2276		+ `c`: WG offset
    2277		+ `SE_a`: Model stadard erorr of `a`
    2278		+ `SE_b`: Model stadard erorr of `b`
    2279		+ `SE_c`: Model stadard erorr of `c`
    2280		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
    2281		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
    2282		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
    2283		+ `a2`: scrambling factor drift
    2284		+ `b2`: compositional slope drift
    2285		+ `c2`: WG offset drift
    2286		+ `Np`: Number of standardization parameters to fit
    2287		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
    2288		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
    2289		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
    2290		'''
    2291		for session in self.sessions:
    2292			if 'd13Cwg_VPDB' not in self.sessions[session]:
    2293				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
    2294			if 'd18Owg_VSMOW' not in self.sessions[session]:
    2295				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
    2296			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
    2297			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
    2298
    2299			self.msg(f'Computing repeatabilities for session {session}')
    2300			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
    2301			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
    2302			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
    2303
    2304		if self.standardization_method == 'pooled':
    2305			for session in self.sessions:
    2306
    2307				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
    2308				i = self.standardization.var_names.index(f'a_{pf(session)}')
    2309				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
    2310
    2311				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
    2312				i = self.standardization.var_names.index(f'b_{pf(session)}')
    2313				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
    2314
    2315				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
    2316				i = self.standardization.var_names.index(f'c_{pf(session)}')
    2317				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
    2318
    2319				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
    2320				if self.sessions[session]['scrambling_drift']:
    2321					i = self.standardization.var_names.index(f'a2_{pf(session)}')
    2322					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
    2323				else:
    2324					self.sessions[session]['SE_a2'] = 0.
    2325
    2326				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
    2327				if self.sessions[session]['slope_drift']:
    2328					i = self.standardization.var_names.index(f'b2_{pf(session)}')
    2329					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
    2330				else:
    2331					self.sessions[session]['SE_b2'] = 0.
    2332
    2333				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
    2334				if self.sessions[session]['wg_drift']:
    2335					i = self.standardization.var_names.index(f'c2_{pf(session)}')
    2336					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
    2337				else:
    2338					self.sessions[session]['SE_c2'] = 0.
    2339
    2340				i = self.standardization.var_names.index(f'a_{pf(session)}')
    2341				j = self.standardization.var_names.index(f'b_{pf(session)}')
    2342				k = self.standardization.var_names.index(f'c_{pf(session)}')
    2343				CM = np.zeros((6,6))
    2344				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
    2345				try:
    2346					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
    2347					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
    2348					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
    2349					try:
    2350						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    2351						CM[3,4] = self.standardization.covar[i2,j2]
    2352						CM[4,3] = self.standardization.covar[j2,i2]
    2353					except ValueError:
    2354						pass
    2355					try:
    2356						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2357						CM[3,5] = self.standardization.covar[i2,k2]
    2358						CM[5,3] = self.standardization.covar[k2,i2]
    2359					except ValueError:
    2360						pass
    2361				except ValueError:
    2362					pass
    2363				try:
    2364					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    2365					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
    2366					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
    2367					try:
    2368						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2369						CM[4,5] = self.standardization.covar[j2,k2]
    2370						CM[5,4] = self.standardization.covar[k2,j2]
    2371					except ValueError:
    2372						pass
    2373				except ValueError:
    2374					pass
    2375				try:
    2376					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2377					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
    2378					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
    2379				except ValueError:
    2380					pass
    2381
    2382				self.sessions[session]['CM'] = CM
    2383
    2384		elif self.standardization_method == 'indep_sessions':
    2385			pass # Not implemented yet
    2386
    2387
    2388	@make_verbal
    2389	def repeatabilities(self):
    2390		'''
    2391		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    2392		(for all samples, for anchors, and for unknowns).
    2393		'''
    2394		self.msg('Computing reproducibilities for all sessions')
    2395
    2396		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    2397		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    2398		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    2399		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    2400		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
    2401
    2402
    2403	@make_verbal
    2404	def consolidate(self, tables = True, plots = True):
    2405		'''
    2406		Collect information about samples, sessions and repeatabilities.
    2407		'''
    2408		self.consolidate_samples()
    2409		self.consolidate_sessions()
    2410		self.repeatabilities()
    2411
    2412		if tables:
    2413			self.summary()
    2414			self.table_of_sessions()
    2415			self.table_of_analyses()
    2416			self.table_of_samples()
    2417
    2418		if plots:
    2419			self.plot_sessions()
    2420
    2421
    2422	@make_verbal
    2423	def rmswd(self,
    2424		samples = 'all samples',
    2425		sessions = 'all sessions',
    2426		):
    2427		'''
    2428		Compute the χ2, root mean squared weighted deviation
    2429		(i.e. reduced χ2), and corresponding degrees of freedom of the
    2430		Δ4x values for samples in `samples` and sessions in `sessions`.
    2431
    2432		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
    2433		'''
    2434		if samples == 'all samples':
    2435			mysamples = [k for k in self.samples]
    2436		elif samples == 'anchors':
    2437			mysamples = [k for k in self.anchors]
    2438		elif samples == 'unknowns':
    2439			mysamples = [k for k in self.unknowns]
    2440		else:
    2441			mysamples = samples
    2442
    2443		if sessions == 'all sessions':
    2444			sessions = [k for k in self.sessions]
    2445
    2446		chisq, Nf = 0, 0
    2447		for sample in mysamples :
    2448			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2449			if len(G) > 1 :
    2450				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
    2451				Nf += (len(G) - 1)
    2452				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
    2453		r = (chisq / Nf)**.5 if Nf > 0 else 0
    2454		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
    2455		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
    2456
    2457
    2458	@make_verbal
    2459	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
    2460		'''
    2461		Compute the repeatability of `[r[key] for r in self]`
    2462		'''
    2463		# NB: it's debatable whether rD47 should be computed
    2464		# with Nf = len(self)-len(self.samples) instead of
    2465		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
    2466
    2467		if samples == 'all samples':
    2468			mysamples = [k for k in self.samples]
    2469		elif samples == 'anchors':
    2470			mysamples = [k for k in self.anchors]
    2471		elif samples == 'unknowns':
    2472			mysamples = [k for k in self.unknowns]
    2473		else:
    2474			mysamples = samples
    2475
    2476		if sessions == 'all sessions':
    2477			sessions = [k for k in self.sessions]
    2478
    2479		if key in ['D47', 'D48']:
    2480			chisq, Nf = 0, 0
    2481			for sample in mysamples :
    2482				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2483				if len(X) > 1 :
    2484					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
    2485					if sample in self.unknowns:
    2486						Nf += len(X) - 1
    2487					else:
    2488						Nf += len(X)
    2489			if samples in ['anchors', 'all samples']:
    2490				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
    2491			r = (chisq / Nf)**.5 if Nf > 0 else 0
    2492
    2493		else: # if key not in ['D47', 'D48']
    2494			chisq, Nf = 0, 0
    2495			for sample in mysamples :
    2496				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2497				if len(X) > 1 :
    2498					Nf += len(X) - 1
    2499					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
    2500			r = (chisq / Nf)**.5 if Nf > 0 else 0
    2501
    2502		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
    2503		return r
    2504
    2505	def sample_average(self, samples, weights = 'equal', normalize = True):
    2506		'''
    2507		Weighted average Δ4x value of a group of samples, accounting for covariance.
    2508
    2509		Returns the weighed average Δ4x value and associated SE
    2510		of a group of samples. Weights are equal by default. If `normalize` is
    2511		true, `weights` will be rescaled so that their sum equals 1.
    2512
    2513		**Examples**
    2514
    2515		```python
    2516		self.sample_average(['X','Y'], [1, 2])
    2517		```
    2518
    2519		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
    2520		where Δ4x(X) and Δ4x(Y) are the average Δ4x
    2521		values of samples X and Y, respectively.
    2522
    2523		```python
    2524		self.sample_average(['X','Y'], [1, -1], normalize = False)
    2525		```
    2526
    2527		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
    2528		'''
    2529		if weights == 'equal':
    2530			weights = [1/len(samples)] * len(samples)
    2531
    2532		if normalize:
    2533			s = sum(weights)
    2534			if s:
    2535				weights = [w/s for w in weights]
    2536
    2537		try:
    2538# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
    2539# 			C = self.standardization.covar[indices,:][:,indices]
    2540			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
    2541			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
    2542			return correlated_sum(X, C, weights)
    2543		except ValueError:
    2544			return (0., 0.)
    2545
    2546
    2547	def sample_D4x_covar(self, sample1, sample2 = None):
    2548		'''
    2549		Covariance between Δ4x values of samples
    2550
    2551		Returns the error covariance between the average Δ4x values of two
    2552		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
    2553		returns the Δ4x variance for that sample.
    2554		'''
    2555		if sample2 is None:
    2556			sample2 = sample1
    2557		if self.standardization_method == 'pooled':
    2558			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
    2559			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
    2560			return self.standardization.covar[i, j]
    2561		elif self.standardization_method == 'indep_sessions':
    2562			if sample1 == sample2:
    2563				return self.samples[sample1][f'SE_D{self._4x}']**2
    2564			else:
    2565				c = 0
    2566				for session in self.sessions:
    2567					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
    2568					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
    2569					if sdata1 and sdata2:
    2570						a = self.sessions[session]['a']
    2571						# !! TODO: CM below does not account for temporal changes in standardization parameters
    2572						CM = self.sessions[session]['CM'][:3,:3]
    2573						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
    2574						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
    2575						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
    2576						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
    2577						c += (
    2578							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
    2579							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
    2580							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
    2581							@ CM
    2582							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
    2583							) / a**2
    2584				return float(c)
    2585
    2586	def sample_D4x_correl(self, sample1, sample2 = None):
    2587		'''
    2588		Correlation between Δ4x errors of samples
    2589
    2590		Returns the error correlation between the average Δ4x values of two samples.
    2591		'''
    2592		if sample2 is None or sample2 == sample1:
    2593			return 1.
    2594		return (
    2595			self.sample_D4x_covar(sample1, sample2)
    2596			/ self.unknowns[sample1][f'SE_D{self._4x}']
    2597			/ self.unknowns[sample2][f'SE_D{self._4x}']
    2598			)
    2599
    2600	def plot_single_session(self,
    2601		session,
    2602		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
    2603		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
    2604		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
    2605		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
    2606		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
    2607		xylimits = 'free', # | 'constant'
    2608		x_label = None,
    2609		y_label = None,
    2610		error_contour_interval = 'auto',
    2611		fig = 'new',
    2612		):
    2613		'''
    2614		Generate plot for a single session
    2615		'''
    2616		if x_label is None:
    2617			x_label = f'δ$_{{{self._4x}}}$ (‰)'
    2618		if y_label is None:
    2619			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
    2620
    2621		out = _SessionPlot()
    2622		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
    2623		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
    2624
    2625		if fig == 'new':
    2626			out.fig = ppl.figure(figsize = (6,6))
    2627			ppl.subplots_adjust(.1,.1,.9,.9)
    2628
    2629		out.anchor_analyses, = ppl.plot(
    2630			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    2631			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    2632			**kw_plot_anchors)
    2633		out.unknown_analyses, = ppl.plot(
    2634			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    2635			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    2636			**kw_plot_unknowns)
    2637		out.anchor_avg = ppl.plot(
    2638			np.array([ np.array([
    2639				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    2640				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    2641				]) for sample in anchors]).T,
    2642			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
    2643			**kw_plot_anchor_avg)
    2644		out.unknown_avg = ppl.plot(
    2645			np.array([ np.array([
    2646				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    2647				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    2648				]) for sample in unknowns]).T,
    2649			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
    2650			**kw_plot_unknown_avg)
    2651		if xylimits == 'constant':
    2652			x = [r[f'd{self._4x}'] for r in self]
    2653			y = [r[f'D{self._4x}'] for r in self]
    2654			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
    2655			w, h = x2-x1, y2-y1
    2656			x1 -= w/20
    2657			x2 += w/20
    2658			y1 -= h/20
    2659			y2 += h/20
    2660			ppl.axis([x1, x2, y1, y2])
    2661		elif xylimits == 'free':
    2662			x1, x2, y1, y2 = ppl.axis()
    2663		else:
    2664			x1, x2, y1, y2 = ppl.axis(xylimits)
    2665
    2666		if error_contour_interval != 'none':
    2667			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
    2668			XI,YI = np.meshgrid(xi, yi)
    2669			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
    2670			if error_contour_interval == 'auto':
    2671				rng = np.max(SI) - np.min(SI)
    2672				if rng <= 0.01:
    2673					cinterval = 0.001
    2674				elif rng <= 0.03:
    2675					cinterval = 0.004
    2676				elif rng <= 0.1:
    2677					cinterval = 0.01
    2678				elif rng <= 0.3:
    2679					cinterval = 0.03
    2680				elif rng <= 1.:
    2681					cinterval = 0.1
    2682				else:
    2683					cinterval = 0.5
    2684			else:
    2685				cinterval = error_contour_interval
    2686
    2687			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
    2688			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
    2689			out.clabel = ppl.clabel(out.contour)
    2690
    2691		ppl.xlabel(x_label)
    2692		ppl.ylabel(y_label)
    2693		ppl.title(session, weight = 'bold')
    2694		ppl.grid(alpha = .2)
    2695		out.ax = ppl.gca()
    2696
    2697		return out
    2698
    2699	def plot_residuals(
    2700		self,
    2701		hist = False,
    2702		binwidth = 2/3,
    2703		dir = 'output',
    2704		filename = None,
    2705		highlight = [],
    2706		colors = None,
    2707		figsize = None,
    2708		):
    2709		'''
    2710		Plot residuals of each analysis as a function of time (actually, as a function of
    2711		the order of analyses in the `D4xdata` object)
    2712
    2713		+ `hist`: whether to add a histogram of residuals
    2714		+ `histbins`: specify bin edges for the histogram
    2715		+ `dir`: the directory in which to save the plot
    2716		+ `highlight`: a list of samples to highlight
    2717		+ `colors`: a dict of `{<sample>: <color>}` for all samples
    2718		+ `figsize`: (width, height) of figure
    2719		'''
    2720		# Layout
    2721		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
    2722		if hist:
    2723			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
    2724			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
    2725		else:
    2726			ppl.subplots_adjust(.08,.05,.78,.8)
    2727			ax1 = ppl.subplot(111)
    2728
    2729		# Colors
    2730		N = len(self.anchors)
    2731		if colors is None:
    2732			if len(highlight) > 0:
    2733				Nh = len(highlight)
    2734				if Nh == 1:
    2735					colors = {highlight[0]: (0,0,0)}
    2736				elif Nh == 3:
    2737					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
    2738				elif Nh == 4:
    2739					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    2740				else:
    2741					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
    2742			else:
    2743				if N == 3:
    2744					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
    2745				elif N == 4:
    2746					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    2747				else:
    2748					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
    2749
    2750		ppl.sca(ax1)
    2751
    2752		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
    2753
    2754		session = self[0]['Session']
    2755		x1 = 0
    2756# 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
    2757		x_sessions = {}
    2758		one_or_more_singlets = False
    2759		one_or_more_multiplets = False
    2760		multiplets = set()
    2761		for k,r in enumerate(self):
    2762			if r['Session'] != session:
    2763				x2 = k-1
    2764				x_sessions[session] = (x1+x2)/2
    2765				ppl.axvline(k - 0.5, color = 'k', lw = .5)
    2766				session = r['Session']
    2767				x1 = k
    2768			singlet = len(self.samples[r['Sample']]['data']) == 1
    2769			if not singlet:
    2770				multiplets.add(r['Sample'])
    2771			if r['Sample'] in self.unknowns:
    2772				if singlet:
    2773					one_or_more_singlets = True
    2774				else:
    2775					one_or_more_multiplets = True
    2776			kw = dict(
    2777				marker = 'x' if singlet else '+',
    2778				ms = 4 if singlet else 5,
    2779				ls = 'None',
    2780				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
    2781				mew = 1,
    2782				alpha = 0.2 if singlet else 1,
    2783				)
    2784			if highlight and r['Sample'] not in highlight:
    2785				kw['alpha'] = 0.2
    2786			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
    2787		x2 = k
    2788		x_sessions[session] = (x1+x2)/2
    2789
    2790		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
    2791		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
    2792		if not hist:
    2793			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
    2794			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')
    2795
    2796		xmin, xmax, ymin, ymax = ppl.axis()
    2797		for s in x_sessions:
    2798			ppl.text(
    2799				x_sessions[s],
    2800				ymax +1,
    2801				s,
    2802				va = 'bottom',
    2803				**(
    2804					dict(ha = 'center')
    2805					if len(self.sessions[s]['data']) > (0.15 * len(self))
    2806					else dict(ha = 'left', rotation = 45)
    2807					)
    2808				)
    2809
    2810		if hist:
    2811			ppl.sca(ax2)
    2812
    2813		for s in colors:
    2814			kw['marker'] = '+'
    2815			kw['ms'] = 5
    2816			kw['mec'] = colors[s]
    2817			kw['label'] = s
    2818			kw['alpha'] = 1
    2819			ppl.plot([], [], **kw)
    2820
    2821		kw['mec'] = (0,0,0)
    2822
    2823		if one_or_more_singlets:
    2824			kw['marker'] = 'x'
    2825			kw['ms'] = 4
    2826			kw['alpha'] = .2
    2827			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
    2828			ppl.plot([], [], **kw)
    2829
    2830		if one_or_more_multiplets:
    2831			kw['marker'] = '+'
    2832			kw['ms'] = 4
    2833			kw['alpha'] = 1
    2834			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
    2835			ppl.plot([], [], **kw)
    2836
    2837		if hist:
    2838			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
    2839		else:
    2840			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
    2841		leg.set_zorder(-1000)
    2842
    2843		ppl.sca(ax1)
    2844
    2845		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
    2846		ppl.xticks([])
    2847		ppl.axis([-1, len(self), None, None])
    2848
    2849		if hist:
    2850			ppl.sca(ax2)
    2851			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets]
    2852			ppl.hist(
    2853				X,
    2854				orientation = 'horizontal',
    2855				histtype = 'stepfilled',
    2856				ec = [.4]*3,
    2857				fc = [.25]*3,
    2858				alpha = .25,
    2859				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
    2860				)
    2861			ppl.axis([None, None, ymin, ymax])
    2862			ppl.text(0, 0,
    2863				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
    2864				size = 8,
    2865				alpha = 1,
    2866				va = 'center',
    2867				ha = 'left',
    2868				)
    2869
    2870			ppl.xticks([])
    2871			ppl.yticks([])
    2872# 			ax2.spines['left'].set_visible(False)
    2873			ax2.spines['right'].set_visible(False)
    2874			ax2.spines['top'].set_visible(False)
    2875			ax2.spines['bottom'].set_visible(False)
    2876
    2877
    2878		if not os.path.exists(dir):
    2879			os.makedirs(dir)
    2880		if filename is None:
    2881			return fig
    2882		elif filename == '':
    2883			filename = f'D{self._4x}_residuals.pdf'
    2884		ppl.savefig(f'{dir}/{filename}')
    2885		ppl.close(fig)
    2886
    2887
    2888	def simulate(self, *args, **kwargs):
    2889		'''
    2890		Legacy function with warning message pointing to `virtual_data()`
    2891		'''
    2892		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
    2893
    2894	def plot_distribution_of_analyses(
    2895		self,
    2896		dir = 'output',
    2897		filename = None,
    2898		vs_time = False,
    2899		figsize = (6,4),
    2900		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
    2901		output = None,
    2902		):
    2903		'''
    2904		Plot temporal distribution of all analyses in the data set.
    2905
    2906		**Parameters**
    2907
    2908		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    2909		'''
    2910
    2911		asamples = [s for s in self.anchors]
    2912		usamples = [s for s in self.unknowns]
    2913		if output is None or output == 'fig':
    2914			fig = ppl.figure(figsize = figsize)
    2915			ppl.subplots_adjust(*subplots_adjust)
    2916		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    2917		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    2918		Xmax += (Xmax-Xmin)/40
    2919		Xmin -= (Xmax-Xmin)/41
    2920		for k, s in enumerate(asamples + usamples):
    2921			if vs_time:
    2922				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    2923			else:
    2924				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    2925			Y = [-k for x in X]
    2926			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
    2927			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    2928			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
    2929		ppl.axis([Xmin, Xmax, -k-1, 1])
    2930		ppl.xlabel('\ntime')
    2931		ppl.gca().annotate('',
    2932			xy = (0.6, -0.02),
    2933			xycoords = 'axes fraction',
    2934			xytext = (.4, -0.02),
    2935            arrowprops = dict(arrowstyle = "->", color = 'k'),
    2936            )
    2937
    2938
    2939		x2 = -1
    2940		for session in self.sessions:
    2941			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    2942			if vs_time:
    2943				ppl.axvline(x1, color = 'k', lw = .75)
    2944			if x2 > -1:
    2945				if not vs_time:
    2946					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
    2947			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    2948# 			from xlrd import xldate_as_datetime
    2949# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    2950			if vs_time:
    2951				ppl.axvline(x2, color = 'k', lw = .75)
    2952				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
    2953			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
    2954
    2955		ppl.xticks([])
    2956		ppl.yticks([])
    2957
    2958		if output is None:
    2959			if not os.path.exists(dir):
    2960				os.makedirs(dir)
    2961			if filename == None:
    2962				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    2963			ppl.savefig(f'{dir}/{filename}')
    2964			ppl.close(fig)
    2965		elif output == 'ax':
    2966			return ppl.gca()
    2967		elif output == 'fig':
    2968			return fig
    2969
    2970
    2971class D47data(D4xdata):
    2972	'''
    2973	Store and process data for a large set of Δ47 analyses,
    2974	usually comprising more than one analytical session.
    2975	'''
    2976
    2977	Nominal_D4x = {
    2978		'ETH-1':   0.2052,
    2979		'ETH-2':   0.2085,
    2980		'ETH-3':   0.6132,
    2981		'ETH-4':   0.4511,
    2982		'IAEA-C1': 0.3018,
    2983		'IAEA-C2': 0.6409,
    2984		'MERCK':   0.5135,
    2985		} # I-CDES (Bernasconi et al., 2021)
    2986	'''
    2987	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
    2988	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
    2989	reference frame.
    2990
    2991	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
    2992	```py
    2993	{
    2994		'ETH-1'   : 0.2052,
    2995		'ETH-2'   : 0.2085,
    2996		'ETH-3'   : 0.6132,
    2997		'ETH-4'   : 0.4511,
    2998		'IAEA-C1' : 0.3018,
    2999		'IAEA-C2' : 0.6409,
    3000		'MERCK'   : 0.5135,
    3001	}
    3002	```
    3003	'''
    3004
    3005
    3006	@property
    3007	def Nominal_D47(self):
    3008		return self.Nominal_D4x
    3009
    3010
    3011	@Nominal_D47.setter
    3012	def Nominal_D47(self, new):
    3013		self.Nominal_D4x = dict(**new)
    3014		self.refresh()
    3015
    3016
    3017	def __init__(self, l = [], **kwargs):
    3018		'''
    3019		**Parameters:** same as `D4xdata.__init__()`
    3020		'''
    3021		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    3022
    3023
    3024	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    3025		'''
    3026		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    3027		value for that temperature, and add treat these samples as additional anchors.
    3028
    3029		**Parameters**
    3030
    3031		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    3032		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    3033		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    3034		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    3035		if `new`: keep pre-existing anchors but update them in case of conflict
    3036		between old and new Δ47 values;
    3037		if `old`: keep pre-existing anchors but preserve their original Δ47
    3038		values in case of conflict.
    3039		'''
    3040		f = {
    3041			'petersen': fCO2eqD47_Petersen,
    3042			'wang': fCO2eqD47_Wang,
    3043			}[fCo2eqD47]
    3044		foo = {}
    3045		for r in self:
    3046			if 'Teq' in r:
    3047				if r['Sample'] in foo:
    3048					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    3049				else:
    3050					foo[r['Sample']] = f(r['Teq'])
    3051			else:
    3052					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    3053
    3054		if priority == 'replace':
    3055			self.Nominal_D47 = {}
    3056		for s in foo:
    3057			if priority != 'old' or s not in self.Nominal_D47:
    3058				self.Nominal_D47[s] = foo[s]
    3059
    3060
    3061
    3062
    3063class D48data(D4xdata):
    3064	'''
    3065	Store and process data for a large set of Δ48 analyses,
    3066	usually comprising more than one analytical session.
    3067	'''
    3068
    3069	Nominal_D4x = {
    3070		'ETH-1':  0.138,
    3071		'ETH-2':  0.138,
    3072		'ETH-3':  0.270,
    3073		'ETH-4':  0.223,
    3074		'GU-1':  -0.419,
    3075		} # (Fiebig et al., 2019, 2021)
    3076	'''
    3077	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
    3078	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
    3079	reference frame.
    3080
    3081	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
    3082	Fiebig et al. (in press)):
    3083
    3084	```py
    3085	{
    3086		'ETH-1' :  0.138,
    3087		'ETH-2' :  0.138,
    3088		'ETH-3' :  0.270,
    3089		'ETH-4' :  0.223,
    3090		'GU-1'  : -0.419,
    3091	}
    3092	```
    3093	'''
    3094
    3095
    3096	@property
    3097	def Nominal_D48(self):
    3098		return self.Nominal_D4x
    3099
    3100
    3101	@Nominal_D48.setter
    3102	def Nominal_D48(self, new):
    3103		self.Nominal_D4x = dict(**new)
    3104		self.refresh()
    3105
    3106
    3107	def __init__(self, l = [], **kwargs):
    3108		'''
    3109		**Parameters:** same as `D4xdata.__init__()`
    3110		'''
    3111		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
    3112
    3113
    3114class _SessionPlot():
    3115	'''
    3116	Simple placeholder class
    3117	'''
    3118	def __init__(self):
    3119		pass

def fCO2eqD47_Petersen(T): View Source

    63def fCO2eqD47_Petersen(T):
    64	'''
    65	CO2 equilibrium Δ47 value as a function of T (in degrees C)
    66	according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).
    67
    68	'''
    69	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

    74def fCO2eqD47_Wang(T):
    75	'''
    76	CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
    77	according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
    78	(supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
    79	'''
    80	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

    83def correlated_sum(X, C, w = None):
    84	'''
    85	Compute covariance-aware linear combinations
    86
    87	**Parameters**
    88
    89	+ `X`: list or 1-D array of values to sum
    90	+ `C`: covariance matrix for the elements of `X`
    91	+ `w`: list or 1-D array of weights to apply to the elements of `X`
    92	       (all equal to 1 by default)
    93
    94	Return the sum (and its SE) of the elements of `X`, with optional weights equal
    95	to the elements of `w`, accounting for covariances between the elements of `X`.
    96	'''
    97	if w is None:
    98		w = [1 for x in X]
    99	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

    102def make_csv(x, hsep = ',', vsep = '\n'):
    103	'''
    104	Formats a list of lists of strings as a CSV
    105
    106	**Parameters**
    107
    108	+ `x`: the list of lists of strings to format
    109	+ `hsep`: the field separator (`,` by default)
    110	+ `vsep`: the line-ending convention to use (`\\n` by default)
    111
    112	**Example**
    113
    114	```py
    115	print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))
    116	```
    117
    118	outputs:
    119
    120	```py
    121	a,b,c
    122	d,e,f
    123	```
    124	'''
    125	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

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

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

def smart_type(x): View Source

    135def smart_type(x):
    136	'''
    137	Tries to convert string `x` to a float if it includes a decimal point, or
    138	to an integer if it does not. If both attempts fail, return the original
    139	string unchanged.
    140	'''
    141	try:
    142		y = float(x)
    143	except ValueError:
    144		return x
    145	if '.' not in x:
    146		return int(y)
    147	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

    150def pretty_table(x, header = 1, hsep = '  ', vsep = '–', align = '<'):
    151	'''
    152	Reads a list of lists of strings and outputs an ascii table
    153
    154	**Parameters**
    155
    156	+ `x`: a list of lists of strings
    157	+ `header`: the number of lines to treat as header lines
    158	+ `hsep`: the horizontal separator between columns
    159	+ `vsep`: the character to use as vertical separator
    160	+ `align`: string of left (`<`) or right (`>`) alignment characters.
    161
    162	**Example**
    163
    164	```py
    165	x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']]
    166	print(pretty_table(x))
    167	```
    168	yields:
    169	```
    170	--  ------  ---
    171	A        B    C
    172	--  ------  ---
    173	1   1.9999  foo
    174	10       x  bar
    175	--  ------  ---
    176	```
    177
    178	'''
    179	txt = []
    180	widths = [np.max([len(e) for e in c]) for c in zip(*x)]
    181
    182	if len(widths) > len(align):
    183		align += '>' * (len(widths)-len(align))
    184	sepline = hsep.join([vsep*w for w in widths])
    185	txt += [sepline]
    186	for k,l in enumerate(x):
    187		if k and k == header:
    188			txt += [sepline]
    189		txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])]
    190	txt += [sepline]
    191	txt += ['']
    192	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

    195def transpose_table(x):
    196	'''
    197	Transpose a list if lists
    198
    199	**Parameters**
    200
    201	+ `x`: a list of lists
    202
    203	**Example**
    204
    205	```py
    206	x = [[1, 2], [3, 4]]
    207	print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
    208	```
    209	'''
    210	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

    213def w_avg(X, sX) :
    214	'''
    215	Compute variance-weighted average
    216
    217	Returns the value and SE of the weighted average of the elements of `X`,
    218	with relative weights equal to their inverse variances (`1/sX**2`).
    219
    220	**Parameters**
    221
    222	+ `X`: array-like of elements to average
    223	+ `sX`: array-like of the corresponding SE values
    224
    225	**Tip**
    226
    227	If `X` and `sX` are initially arranged as a list of `(x, sx)` doublets,
    228	they may be rearranged using `zip()`:
    229
    230	```python
    231	foo = [(0, 1), (1, 0.5), (2, 0.5)]
    232	print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)
    233	```
    234	'''
    235	X = [ x for x in X ]
    236	sX = [ sx for sx in sX ]
    237	W = [ sx**-2 for sx in sX ]
    238	W = [ w/sum(W) for w in W ]
    239	Xavg = sum([ w*x for w,x in zip(W,X) ])
    240	sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5
    241	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

    244def read_csv(filename, sep = ''):
    245	'''
    246	Read contents of `filename` in csv format and return a list of dictionaries.
    247
    248	In the csv string, spaces before and after field separators (`','` by default)
    249	are optional.
    250
    251	**Parameters**
    252
    253	+ `filename`: the csv file to read
    254	+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    255	whichever appers most often in the contents of `filename`.
    256	'''
    257	with open(filename) as fid:
    258		txt = fid.read()
    259
    260	if sep == '':
    261		sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    262	txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    263	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

    266def simulate_single_analysis(
    267	sample = 'MYSAMPLE',
    268	d13Cwg_VPDB = -4., d18Owg_VSMOW = 26.,
    269	d13C_VPDB = None, d18O_VPDB = None,
    270	D47 = None, D48 = None, D49 = 0., D17O = 0.,
    271	a47 = 1., b47 = 0., c47 = -0.9,
    272	a48 = 1., b48 = 0., c48 = -0.45,
    273	Nominal_D47 = None,
    274	Nominal_D48 = None,
    275	Nominal_d13C_VPDB = None,
    276	Nominal_d18O_VPDB = None,
    277	ALPHA_18O_ACID_REACTION = None,
    278	R13_VPDB = None,
    279	R17_VSMOW = None,
    280	R18_VSMOW = None,
    281	LAMBDA_17 = None,
    282	R18_VPDB = None,
    283	):
    284	'''
    285	Compute working-gas delta values for a single analysis, assuming a stochastic working
    286	gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values).
    287
    288	**Parameters**
    289
    290	+ `sample`: sample name
    291	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
    292		(respectively –4 and +26 ‰ by default)
    293	+ `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
    294	+ `D47`, `D48`, `D49`, `D17O`: clumped-isotope and oxygen-17 anomalies
    295		of the carbonate sample
    296	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and
    297		Δ48 values if `D47` or `D48` are not specified
    298	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
    299		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
    300	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
    301	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
    302		correction parameters (by default equal to the `D4xdata` default values)
    303
    304	Returns a dictionary with fields
    305	`['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49']`.
    306	'''
    307
    308	if Nominal_d13C_VPDB is None:
    309		Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB
    310
    311	if Nominal_d18O_VPDB is None:
    312		Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB
    313
    314	if ALPHA_18O_ACID_REACTION is None:
    315		ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION
    316
    317	if R13_VPDB is None:
    318		R13_VPDB = D4xdata().R13_VPDB
    319
    320	if R17_VSMOW is None:
    321		R17_VSMOW = D4xdata().R17_VSMOW
    322
    323	if R18_VSMOW is None:
    324		R18_VSMOW = D4xdata().R18_VSMOW
    325
    326	if LAMBDA_17 is None:
    327		LAMBDA_17 = D4xdata().LAMBDA_17
    328
    329	if R18_VPDB is None:
    330		R18_VPDB = D4xdata().R18_VPDB
    331
    332	R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17
    333
    334	if Nominal_D47 is None:
    335		Nominal_D47 = D47data().Nominal_D47
    336
    337	if Nominal_D48 is None:
    338		Nominal_D48 = D48data().Nominal_D48
    339
    340	if d13C_VPDB is None:
    341		if sample in Nominal_d13C_VPDB:
    342			d13C_VPDB = Nominal_d13C_VPDB[sample]
    343		else:
    344			raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.")
    345
    346	if d18O_VPDB is None:
    347		if sample in Nominal_d18O_VPDB:
    348			d18O_VPDB = Nominal_d18O_VPDB[sample]
    349		else:
    350			raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.")
    351
    352	if D47 is None:
    353		if sample in Nominal_D47:
    354			D47 = Nominal_D47[sample]
    355		else:
    356			raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.")
    357
    358	if D48 is None:
    359		if sample in Nominal_D48:
    360			D48 = Nominal_D48[sample]
    361		else:
    362			raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.")
    363
    364	X = D4xdata()
    365	X.R13_VPDB = R13_VPDB
    366	X.R17_VSMOW = R17_VSMOW
    367	X.R18_VSMOW = R18_VSMOW
    368	X.LAMBDA_17 = LAMBDA_17
    369	X.R18_VPDB = R18_VPDB
    370	X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17
    371
    372	R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios(
    373		R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000),
    374		R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000),
    375		)
    376	R45, R46, R47, R48, R49 = X.compute_isobar_ratios(
    377		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
    378		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
    379		D17O=D17O, D47=D47, D48=D48, D49=D49,
    380		)
    381	R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios(
    382		R13 = R13_VPDB * (1 + d13C_VPDB/1000),
    383		R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
    384		D17O=D17O,
    385		)
    386
    387	d45 = 1000 * (R45/R45wg - 1)
    388	d46 = 1000 * (R46/R46wg - 1)
    389	d47 = 1000 * (R47/R47wg - 1)
    390	d48 = 1000 * (R48/R48wg - 1)
    391	d49 = 1000 * (R49/R49wg - 1)
    392
    393	for k in range(3): # dumb iteration to adjust for small changes in d47
    394		R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch
    395		R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch
    396		d47 = 1000 * (R47raw/R47wg - 1)
    397		d48 = 1000 * (R48raw/R48wg - 1)
    398
    399	return dict(
    400		Sample = sample,
    401		D17O = D17O,
    402		d13Cwg_VPDB = d13Cwg_VPDB,
    403		d18Owg_VSMOW = d18Owg_VSMOW,
    404		d45 = d45,
    405		d46 = d46,
    406		d47 = d47,
    407		d48 = d48,
    408		d49 = d49,
    409		)

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

    412def virtual_data(
    413	samples = [],
    414	a47 = 1., b47 = 0., c47 = -0.9,
    415	a48 = 1., b48 = 0., c48 = -0.45,
    416	rD47 = 0.015, rD48 = 0.045,
    417	d13Cwg_VPDB = None, d18Owg_VSMOW = None,
    418	session = None,
    419	Nominal_D47 = None, Nominal_D48 = None,
    420	Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
    421	ALPHA_18O_ACID_REACTION = None,
    422	R13_VPDB = None,
    423	R17_VSMOW = None,
    424	R18_VSMOW = None,
    425	LAMBDA_17 = None,
    426	R18_VPDB = None,
    427	seed = 0,
    428	):
    429	'''
    430	Return list with simulated analyses from a single session.
    431
    432	**Parameters**
    433
    434	+ `samples`: a list of entries; each entry is a dictionary with the following fields:
    435	    * `Sample`: the name of the sample
    436	    * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
    437	    * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
    438	    * `N`: how many analyses to generate for this sample
    439	+ `a47`: scrambling factor for Δ47
    440	+ `b47`: compositional nonlinearity for Δ47
    441	+ `c47`: working gas offset for Δ47
    442	+ `a48`: scrambling factor for Δ48
    443	+ `b48`: compositional nonlinearity for Δ48
    444	+ `c48`: working gas offset for Δ48
    445	+ `rD47`: analytical repeatability of Δ47
    446	+ `rD48`: analytical repeatability of Δ48
    447	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
    448		(by default equal to the `simulate_single_analysis` default values)
    449	+ `session`: name of the session (no name by default)
    450	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
    451		if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
    452	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
    453		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
    454		(by default equal to the `simulate_single_analysis` defaults)
    455	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
    456		(by default equal to the `simulate_single_analysis` defaults)
    457	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
    458		correction parameters (by default equal to the `simulate_single_analysis` default)
    459	+ `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations
    460
    461
    462	Here is an example of using this method to generate an arbitrary combination of
    463	anchors and unknowns for a bunch of sessions:
    464
    465	```py
    466	args = dict(
    467		samples = [
    468			dict(Sample = 'ETH-1', N = 4),
    469			dict(Sample = 'ETH-2', N = 5),
    470			dict(Sample = 'ETH-3', N = 6),
    471			dict(Sample = 'FOO', N = 2,
    472				d13C_VPDB = -5., d18O_VPDB = -10.,
    473				D47 = 0.3, D48 = 0.15),
    474			], rD47 = 0.010, rD48 = 0.030)
    475
    476	session1 = virtual_data(session = 'Session_01', **args, seed = 123)
    477	session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
    478	session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
    479	session4 = virtual_data(session = 'Session_04', **args, seed = 123456)
    480
    481	D = D47data(session1 + session2 + session3 + session4)
    482
    483	D.crunch()
    484	D.standardize()
    485
    486	D.table_of_sessions(verbose = True, save_to_file = False)
    487	D.table_of_samples(verbose = True, save_to_file = False)
    488	D.table_of_analyses(verbose = True, save_to_file = False)
    489	```
    490
    491	This should output something like:
    492
    493	```
    494	[table_of_sessions]
    495	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    496	Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
    497	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    498	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
    499	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
    500	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
    501	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
    502	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    503
    504	[table_of_samples]
    505	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    506	Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
    507	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    508	ETH-1   16       2.02       37.02  0.2052                    0.0079
    509	ETH-2   20     -10.17       19.88  0.2085                    0.0100
    510	ETH-3   24       1.71       37.45  0.6132                    0.0105
    511	FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
    512	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    513
    514	[table_of_analyses]
    515	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    516	UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
    517	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    518	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
    519	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
    520	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
    521	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
    522	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
    523	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
    524	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
    525	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
    526	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
    527	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
    528	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
    529	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
    530	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
    531	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
    532	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
    533	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
    534	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
    535	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
    536	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
    537	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
    538	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
    539	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
    540	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
    541	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
    542	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
    543	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
    544	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
    545	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
    546	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
    547	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
    548	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
    549	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
    550	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
    551	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
    552	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
    553	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
    554	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
    555	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
    556	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
    557	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
    558	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
    559	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
    560	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
    561	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
    562	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
    563	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
    564	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
    565	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
    566	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
    567	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
    568	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
    569	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
    570	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
    571	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
    572	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
    573	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
    574	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
    575	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
    576	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
    577	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
    578	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
    579	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
    580	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
    581	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
    582	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
    583	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
    584	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
    585	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
    586	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    587	```
    588	'''
    589
    590	kwargs = locals().copy()
    591
    592	from numpy import random as nprandom
    593	if seed:
    594		rng = nprandom.default_rng(seed)
    595	else:
    596		rng = nprandom.default_rng()
    597
    598	N = sum([s['N'] for s in samples])
    599	errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    600	errors47 *= rD47 / stdev(errors47) # scale errors to rD47
    601	errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    602	errors48 *= rD48 / stdev(errors48) # scale errors to rD48
    603
    604	k = 0
    605	out = []
    606	for s in samples:
    607		kw = {}
    608		kw['sample'] = s['Sample']
    609		kw = {
    610			**kw,
    611			**{var: kwargs[var]
    612				for var in [
    613					'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
    614					'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
    615					'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
    616					'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
    617					]
    618				if kwargs[var] is not None},
    619			**{var: s[var]
    620				for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
    621				if var in s},
    622			}
    623
    624		sN = s['N']
    625		while sN:
    626			out.append(simulate_single_analysis(**kw))
    627			out[-1]['d47'] += errors47[k] * a47
    628			out[-1]['d48'] += errors48[k] * a48
    629			sN -= 1
    630			k += 1
    631
    632		if session is not None:
    633			for r in out:
    634				r['Session'] = session
    635	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

    637def table_of_samples(
    638	data47 = None,
    639	data48 = None,
    640	dir = 'output',
    641	filename = None,
    642	save_to_file = True,
    643	print_out = True,
    644	output = None,
    645	):
    646	'''
    647	Print out, save to disk and/or return a combined table of samples
    648	for a pair of `D47data` and `D48data` objects.
    649
    650	**Parameters**
    651
    652	+ `data47`: `D47data` instance
    653	+ `data48`: `D48data` instance
    654	+ `dir`: the directory in which to save the table
    655	+ `filename`: the name to the csv file to write to
    656	+ `save_to_file`: whether to save the table to disk
    657	+ `print_out`: whether to print out the table
    658	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    659		if set to `'raw'`: return a list of list of strings
    660		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    661	'''
    662	if data47 is None:
    663		if data48 is None:
    664			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
    665		else:
    666			return data48.table_of_samples(
    667				dir = dir,
    668				filename = filename,
    669				save_to_file = save_to_file,
    670				print_out = print_out,
    671				output = output
    672				)
    673	else:
    674		if data48 is None:
    675			return data47.table_of_samples(
    676				dir = dir,
    677				filename = filename,
    678				save_to_file = save_to_file,
    679				print_out = print_out,
    680				output = output
    681				)
    682		else:
    683			out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
    684			out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
    685			out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:])
    686
    687			if save_to_file:
    688				if not os.path.exists(dir):
    689					os.makedirs(dir)
    690				if filename is None:
    691					filename = f'D47D48_samples.csv'
    692				with open(f'{dir}/{filename}', 'w') as fid:
    693					fid.write(make_csv(out))
    694			if print_out:
    695				print('\n'+pretty_table(out))
    696			if output == 'raw':
    697				return out
    698			elif output == 'pretty':
    699				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

    702def table_of_sessions(
    703	data47 = None,
    704	data48 = None,
    705	dir = 'output',
    706	filename = None,
    707	save_to_file = True,
    708	print_out = True,
    709	output = None,
    710	):
    711	'''
    712	Print out, save to disk and/or return a combined table of sessions
    713	for a pair of `D47data` and `D48data` objects.
    714	***Only applicable if the sessions in `data47` and those in `data48`
    715	consist of the exact same sets of analyses.***
    716
    717	**Parameters**
    718
    719	+ `data47`: `D47data` instance
    720	+ `data48`: `D48data` instance
    721	+ `dir`: the directory in which to save the table
    722	+ `filename`: the name to the csv file to write to
    723	+ `save_to_file`: whether to save the table to disk
    724	+ `print_out`: whether to print out the table
    725	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    726		if set to `'raw'`: return a list of list of strings
    727		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    728	'''
    729	if data47 is None:
    730		if data48 is None:
    731			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
    732		else:
    733			return data48.table_of_sessions(
    734				dir = dir,
    735				filename = filename,
    736				save_to_file = save_to_file,
    737				print_out = print_out,
    738				output = output
    739				)
    740	else:
    741		if data48 is None:
    742			return data47.table_of_sessions(
    743				dir = dir,
    744				filename = filename,
    745				save_to_file = save_to_file,
    746				print_out = print_out,
    747				output = output
    748				)
    749		else:
    750			out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
    751			out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
    752			for k,x in enumerate(out47[0]):
    753				if k>7:
    754					out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47')
    755					out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48')
    756			out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:])
    757
    758			if save_to_file:
    759				if not os.path.exists(dir):
    760					os.makedirs(dir)
    761				if filename is None:
    762					filename = f'D47D48_sessions.csv'
    763				with open(f'{dir}/{filename}', 'w') as fid:
    764					fid.write(make_csv(out))
    765			if print_out:
    766				print('\n'+pretty_table(out))
    767			if output == 'raw':
    768				return out
    769			elif output == 'pretty':
    770				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

    773def table_of_analyses(
    774	data47 = None,
    775	data48 = None,
    776	dir = 'output',
    777	filename = None,
    778	save_to_file = True,
    779	print_out = True,
    780	output = None,
    781	):
    782	'''
    783	Print out, save to disk and/or return a combined table of analyses
    784	for a pair of `D47data` and `D48data` objects.
    785
    786	If the sessions in `data47` and those in `data48` do not consist of
    787	the exact same sets of analyses, the table will have two columns
    788	`Session_47` and `Session_48` instead of a single `Session` column.
    789
    790	**Parameters**
    791
    792	+ `data47`: `D47data` instance
    793	+ `data48`: `D48data` instance
    794	+ `dir`: the directory in which to save the table
    795	+ `filename`: the name to the csv file to write to
    796	+ `save_to_file`: whether to save the table to disk
    797	+ `print_out`: whether to print out the table
    798	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    799		if set to `'raw'`: return a list of list of strings
    800		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    801	'''
    802	if data47 is None:
    803		if data48 is None:
    804			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
    805		else:
    806			return data48.table_of_analyses(
    807				dir = dir,
    808				filename = filename,
    809				save_to_file = save_to_file,
    810				print_out = print_out,
    811				output = output
    812				)
    813	else:
    814		if data48 is None:
    815			return data47.table_of_analyses(
    816				dir = dir,
    817				filename = filename,
    818				save_to_file = save_to_file,
    819				print_out = print_out,
    820				output = output
    821				)
    822		else:
    823			out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
    824			out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
    825
    826			if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical
    827				out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:])
    828			else:
    829				out47[0][1] = 'Session_47'
    830				out48[0][1] = 'Session_48'
    831				out47 = transpose_table(out47)
    832				out48 = transpose_table(out48)
    833				out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:])
    834
    835			if save_to_file:
    836				if not os.path.exists(dir):
    837					os.makedirs(dir)
    838				if filename is None:
    839					filename = f'D47D48_sessions.csv'
    840				with open(f'{dir}/{filename}', 'w') as fid:
    841					fid.write(make_csv(out))
    842			if print_out:
    843				print('\n'+pretty_table(out))
    844			if output == 'raw':
    845				return out
    846			elif output == 'pretty':
    847				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

     895class D4xdata(list):
     896	'''
     897	Store and process data for a large set of Δ47 and/or Δ48
     898	analyses, usually comprising more than one analytical session.
     899	'''
     900
     901	### 17O CORRECTION PARAMETERS
     902	R13_VPDB = 0.01118  # (Chang & Li, 1990)
     903	'''
     904	Absolute (13C/12C) ratio of VPDB.
     905	By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm))
     906	'''
     907
     908	R18_VSMOW = 0.0020052  # (Baertschi, 1976)
     909	'''
     910	Absolute (18O/16C) ratio of VSMOW.
     911	By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1))
     912	'''
     913
     914	LAMBDA_17 = 0.528  # (Barkan & Luz, 2005)
     915	'''
     916	Mass-dependent exponent for triple oxygen isotopes.
     917	By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250))
     918	'''
     919
     920	R17_VSMOW = 0.00038475  # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB)
     921	'''
     922	Absolute (17O/16C) ratio of VSMOW.
     923	By default equal to 0.00038475
     924	([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011),
     925	rescaled to `R13_VPDB`)
     926	'''
     927
     928	R18_VPDB = R18_VSMOW * 1.03092
     929	'''
     930	Absolute (18O/16C) ratio of VPDB.
     931	By definition equal to `R18_VSMOW * 1.03092`.
     932	'''
     933
     934	R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17
     935	'''
     936	Absolute (17O/16C) ratio of VPDB.
     937	By definition equal to `R17_VSMOW * 1.03092 ** LAMBDA_17`.
     938	'''
     939
     940	LEVENE_REF_SAMPLE = 'ETH-3'
     941	'''
     942	After the Δ4x standardization step, each sample is tested to
     943	assess whether the Δ4x variance within all analyses for that
     944	sample differs significantly from that observed for a given reference
     945	sample (using [Levene's test](https://en.wikipedia.org/wiki/Levene%27s_test),
     946	which yields a p-value corresponding to the null hypothesis that the
     947	underlying variances are equal).
     948
     949	`LEVENE_REF_SAMPLE` (by default equal to `'ETH-3'`) specifies which
     950	sample should be used as a reference for this test.
     951	'''
     952
     953	ALPHA_18O_ACID_REACTION = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6)  # (Kim et al., 2007, calcite)
     954	'''
     955	Specifies the 18O/16O fractionation factor generally applicable
     956	to acid reactions in the dataset. Currently used by `D4xdata.wg()`,
     957	`D4xdata.standardize_d13C`, and `D4xdata.standardize_d18O`.
     958
     959	By default equal to 1.008129 (calcite reacted at 90 °C,
     960	[Kim et al., 2007](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)).
     961	'''
     962
     963	Nominal_d13C_VPDB = {
     964		'ETH-1': 2.02,
     965		'ETH-2': -10.17,
     966		'ETH-3': 1.71,
     967		}	# (Bernasconi et al., 2018)
     968	'''
     969	Nominal δ13C_VPDB values assigned to carbonate standards, used by
     970	`D4xdata.standardize_d13C()`.
     971
     972	By default equal to `{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}` after
     973	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
     974	'''
     975
     976	Nominal_d18O_VPDB = {
     977		'ETH-1': -2.19,
     978		'ETH-2': -18.69,
     979		'ETH-3': -1.78,
     980		}	# (Bernasconi et al., 2018)
     981	'''
     982	Nominal δ18O_VPDB values assigned to carbonate standards, used by
     983	`D4xdata.standardize_d18O()`.
     984
     985	By default equal to `{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}` after
     986	[Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
     987	'''
     988
     989	d13C_STANDARDIZATION_METHOD = '2pt'
     990	'''
     991	Method by which to standardize δ13C values:
     992
     993	+ `none`: do not apply any δ13C standardization.
     994	+ `'1pt'`: within each session, offset all initial δ13C values so as to
     995	minimize the difference between final δ13C_VPDB values and
     996	`Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined).
     997	+ `'2pt'`: within each session, apply a affine trasformation to all δ13C
     998	values so as to minimize the difference between final δ13C_VPDB
     999	values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB`
    1000	is defined).
    1001	'''
    1002
    1003	d18O_STANDARDIZATION_METHOD = '2pt'
    1004	'''
    1005	Method by which to standardize δ18O values:
    1006
    1007	+ `none`: do not apply any δ18O standardization.
    1008	+ `'1pt'`: within each session, offset all initial δ18O values so as to
    1009	minimize the difference between final δ18O_VPDB values and
    1010	`Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined).
    1011	+ `'2pt'`: within each session, apply a affine trasformation to all δ18O
    1012	values so as to minimize the difference between final δ18O_VPDB
    1013	values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB`
    1014	is defined).
    1015	'''
    1016
    1017	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
    1018		'''
    1019		**Parameters**
    1020
    1021		+ `l`: a list of dictionaries, with each dictionary including at least the keys
    1022		`Sample`, `d45`, `d46`, and `d47` or `d48`.
    1023		+ `mass`: `'47'` or `'48'`
    1024		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
    1025		+ `session`: define session name for analyses without a `Session` key
    1026		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
    1027
    1028		Returns a `D4xdata` object derived from `list`.
    1029		'''
    1030		self._4x = mass
    1031		self.verbose = verbose
    1032		self.prefix = 'D4xdata'
    1033		self.logfile = logfile
    1034		list.__init__(self, l)
    1035		self.Nf = None
    1036		self.repeatability = {}
    1037		self.refresh(session = session)
    1038
    1039
    1040	def make_verbal(oldfun):
    1041		'''
    1042		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
    1043		'''
    1044		@wraps(oldfun)
    1045		def newfun(*args, verbose = '', **kwargs):
    1046			myself = args[0]
    1047			oldprefix = myself.prefix
    1048			myself.prefix = oldfun.__name__
    1049			if verbose != '':
    1050				oldverbose = myself.verbose
    1051				myself.verbose = verbose
    1052			out = oldfun(*args, **kwargs)
    1053			myself.prefix = oldprefix
    1054			if verbose != '':
    1055				myself.verbose = oldverbose
    1056			return out
    1057		return newfun
    1058
    1059
    1060	def msg(self, txt):
    1061		'''
    1062		Log a message to `self.logfile`, and print it out if `verbose = True`
    1063		'''
    1064		self.log(txt)
    1065		if self.verbose:
    1066			print(f'{f"[{self.prefix}]":<16} {txt}')
    1067
    1068
    1069	def vmsg(self, txt):
    1070		'''
    1071		Log a message to `self.logfile` and print it out
    1072		'''
    1073		self.log(txt)
    1074		print(txt)
    1075
    1076
    1077	def log(self, *txts):
    1078		'''
    1079		Log a message to `self.logfile`
    1080		'''
    1081		if self.logfile:
    1082			with open(self.logfile, 'a') as fid:
    1083				for txt in txts:
    1084					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
    1085
    1086
    1087	def refresh(self, session = 'mySession'):
    1088		'''
    1089		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    1090		'''
    1091		self.fill_in_missing_info(session = session)
    1092		self.refresh_sessions()
    1093		self.refresh_samples()
    1094
    1095
    1096	def refresh_sessions(self):
    1097		'''
    1098		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    1099		to `False` for all sessions.
    1100		'''
    1101		self.sessions = {
    1102			s: {'data': [r for r in self if r['Session'] == s]}
    1103			for s in sorted({r['Session'] for r in self})
    1104			}
    1105		for s in self.sessions:
    1106			self.sessions[s]['scrambling_drift'] = False
    1107			self.sessions[s]['slope_drift'] = False
    1108			self.sessions[s]['wg_drift'] = False
    1109			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    1110			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
    1111
    1112
    1113	def refresh_samples(self):
    1114		'''
    1115		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    1116		'''
    1117		self.samples = {
    1118			s: {'data': [r for r in self if r['Sample'] == s]}
    1119			for s in sorted({r['Sample'] for r in self})
    1120			}
    1121		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    1122		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
    1123
    1124
    1125	def read(self, filename, sep = '', session = ''):
    1126		'''
    1127		Read file in csv format to load data into a `D47data` object.
    1128
    1129		In the csv file, spaces before and after field separators (`','` by default)
    1130		are optional. Each line corresponds to a single analysis.
    1131
    1132		The required fields are:
    1133
    1134		+ `UID`: a unique identifier
    1135		+ `Session`: an identifier for the analytical session
    1136		+ `Sample`: a sample identifier
    1137		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    1138
    1139		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    1140		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    1141		and `d49` are optional, and set to NaN by default.
    1142
    1143		**Parameters**
    1144
    1145		+ `fileneme`: the path of the file to read
    1146		+ `sep`: csv separator delimiting the fields
    1147		+ `session`: set `Session` field to this string for all analyses
    1148		'''
    1149		with open(filename) as fid:
    1150			self.input(fid.read(), sep = sep, session = session)
    1151
    1152
    1153	def input(self, txt, sep = '', session = ''):
    1154		'''
    1155		Read `txt` string in csv format to load analysis data into a `D47data` object.
    1156
    1157		In the csv string, spaces before and after field separators (`','` by default)
    1158		are optional. Each line corresponds to a single analysis.
    1159
    1160		The required fields are:
    1161
    1162		+ `UID`: a unique identifier
    1163		+ `Session`: an identifier for the analytical session
    1164		+ `Sample`: a sample identifier
    1165		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    1166
    1167		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    1168		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    1169		and `d49` are optional, and set to NaN by default.
    1170
    1171		**Parameters**
    1172
    1173		+ `txt`: the csv string to read
    1174		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    1175		whichever appers most often in `txt`.
    1176		+ `session`: set `Session` field to this string for all analyses
    1177		'''
    1178		if sep == '':
    1179			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    1180		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    1181		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:]]
    1182
    1183		if session != '':
    1184			for r in data:
    1185				r['Session'] = session
    1186
    1187		self += data
    1188		self.refresh()
    1189
    1190
    1191	@make_verbal
    1192	def wg(self, samples = None, a18_acid = None):
    1193		'''
    1194		Compute bulk composition of the working gas for each session based on
    1195		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
    1196		`self.Nominal_d18O_VPDB`.
    1197		'''
    1198
    1199		self.msg('Computing WG composition:')
    1200
    1201		if a18_acid is None:
    1202			a18_acid = self.ALPHA_18O_ACID_REACTION
    1203		if samples is None:
    1204			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
    1205
    1206		assert a18_acid, f'Acid fractionation factor should not be zero.'
    1207
    1208		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
    1209		R45R46_standards = {}
    1210		for sample in samples:
    1211			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
    1212			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
    1213			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
    1214			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
    1215			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
    1216
    1217			C12_s = 1 / (1 + R13_s)
    1218			C13_s = R13_s / (1 + R13_s)
    1219			C16_s = 1 / (1 + R17_s + R18_s)
    1220			C17_s = R17_s / (1 + R17_s + R18_s)
    1221			C18_s = R18_s / (1 + R17_s + R18_s)
    1222
    1223			C626_s = C12_s * C16_s ** 2
    1224			C627_s = 2 * C12_s * C16_s * C17_s
    1225			C628_s = 2 * C12_s * C16_s * C18_s
    1226			C636_s = C13_s * C16_s ** 2
    1227			C637_s = 2 * C13_s * C16_s * C17_s
    1228			C727_s = C12_s * C17_s ** 2
    1229
    1230			R45_s = (C627_s + C636_s) / C626_s
    1231			R46_s = (C628_s + C637_s + C727_s) / C626_s
    1232			R45R46_standards[sample] = (R45_s, R46_s)
    1233
    1234		for s in self.sessions:
    1235			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
    1236			assert db, f'No sample from {samples} found in session "{s}".'
    1237# 			dbsamples = sorted({r['Sample'] for r in db})
    1238
    1239			X = [r['d45'] for r in db]
    1240			Y = [R45R46_standards[r['Sample']][0] for r in db]
    1241			x1, x2 = np.min(X), np.max(X)
    1242
    1243			if x1 < x2:
    1244				wgcoord = x1/(x1-x2)
    1245			else:
    1246				wgcoord = 999
    1247
    1248			if wgcoord < -.5 or wgcoord > 1.5:
    1249				# unreasonable to extrapolate to d45 = 0
    1250				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    1251			else :
    1252				# d45 = 0 is reasonably well bracketed
    1253				R45_wg = np.polyfit(X, Y, 1)[1]
    1254
    1255			X = [r['d46'] for r in db]
    1256			Y = [R45R46_standards[r['Sample']][1] for r in db]
    1257			x1, x2 = np.min(X), np.max(X)
    1258
    1259			if x1 < x2:
    1260				wgcoord = x1/(x1-x2)
    1261			else:
    1262				wgcoord = 999
    1263
    1264			if wgcoord < -.5 or wgcoord > 1.5:
    1265				# unreasonable to extrapolate to d46 = 0
    1266				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    1267			else :
    1268				# d46 = 0 is reasonably well bracketed
    1269				R46_wg = np.polyfit(X, Y, 1)[1]
    1270
    1271			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
    1272
    1273			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
    1274
    1275			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
    1276			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
    1277			for r in self.sessions[s]['data']:
    1278				r['d13Cwg_VPDB'] = d13Cwg_VPDB
    1279				r['d18Owg_VSMOW'] = d18Owg_VSMOW
    1280
    1281
    1282	def compute_bulk_delta(self, R45, R46, D17O = 0):
    1283		'''
    1284		Compute δ13C_VPDB and δ18O_VSMOW,
    1285		by solving the generalized form of equation (17) from
    1286		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
    1287		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
    1288		solving the corresponding second-order Taylor polynomial.
    1289		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
    1290		'''
    1291
    1292		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
    1293
    1294		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
    1295		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
    1296		C = 2 * self.R18_VSMOW
    1297		D = -R46
    1298
    1299		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
    1300		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
    1301		cc = A + B + C + D
    1302
    1303		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
    1304
    1305		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
    1306		R17 = K * R18 ** self.LAMBDA_17
    1307		R13 = R45 - 2 * R17
    1308
    1309		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
    1310
    1311		return d13C_VPDB, d18O_VSMOW
    1312
    1313
    1314	@make_verbal
    1315	def crunch(self, verbose = ''):
    1316		'''
    1317		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    1318		'''
    1319		for r in self:
    1320			self.compute_bulk_and_clumping_deltas(r)
    1321		self.standardize_d13C()
    1322		self.standardize_d18O()
    1323		self.msg(f"Crunched {len(self)} analyses.")
    1324
    1325
    1326	def fill_in_missing_info(self, session = 'mySession'):
    1327		'''
    1328		Fill in optional fields with default values
    1329		'''
    1330		for i,r in enumerate(self):
    1331			if 'D17O' not in r:
    1332				r['D17O'] = 0.
    1333			if 'UID' not in r:
    1334				r['UID'] = f'{i+1}'
    1335			if 'Session' not in r:
    1336				r['Session'] = session
    1337			for k in ['d47', 'd48', 'd49']:
    1338				if k not in r:
    1339					r[k] = np.nan
    1340
    1341
    1342	def standardize_d13C(self):
    1343		'''
    1344		Perform δ13C standadization within each session `s` according to
    1345		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
    1346		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
    1347		may be redefined abitrarily at a later stage.
    1348		'''
    1349		for s in self.sessions:
    1350			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
    1351				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]
    1352				X,Y = zip(*XY)
    1353				if self.sessions[s]['d13C_standardization_method'] == '1pt':
    1354					offset = np.mean(Y) - np.mean(X)
    1355					for r in self.sessions[s]['data']:
    1356						r['d13C_VPDB'] += offset
    1357				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
    1358					a,b = np.polyfit(X,Y,1)
    1359					for r in self.sessions[s]['data']:
    1360						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
    1361
    1362	def standardize_d18O(self):
    1363		'''
    1364		Perform δ18O standadization within each session `s` according to
    1365		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
    1366		which is defined by default by `D47data.refresh_sessions()`as equal to
    1367		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
    1368		'''
    1369		for s in self.sessions:
    1370			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
    1371				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]
    1372				X,Y = zip(*XY)
    1373				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
    1374				if self.sessions[s]['d18O_standardization_method'] == '1pt':
    1375					offset = np.mean(Y) - np.mean(X)
    1376					for r in self.sessions[s]['data']:
    1377						r['d18O_VSMOW'] += offset
    1378				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
    1379					a,b = np.polyfit(X,Y,1)
    1380					for r in self.sessions[s]['data']:
    1381						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
    1382
    1383
    1384	def compute_bulk_and_clumping_deltas(self, r):
    1385		'''
    1386		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
    1387		'''
    1388
    1389		# Compute working gas R13, R18, and isobar ratios
    1390		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
    1391		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
    1392		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
    1393
    1394		# Compute analyte isobar ratios
    1395		R45 = (1 + r['d45'] / 1000) * R45_wg
    1396		R46 = (1 + r['d46'] / 1000) * R46_wg
    1397		R47 = (1 + r['d47'] / 1000) * R47_wg
    1398		R48 = (1 + r['d48'] / 1000) * R48_wg
    1399		R49 = (1 + r['d49'] / 1000) * R49_wg
    1400
    1401		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
    1402		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
    1403		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
    1404
    1405		# Compute stochastic isobar ratios of the analyte
    1406		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
    1407			R13, R18, D17O = r['D17O']
    1408		)
    1409
    1410		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
    1411		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
    1412		if (R45 / R45stoch - 1) > 5e-8:
    1413			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
    1414		if (R46 / R46stoch - 1) > 5e-8:
    1415			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
    1416
    1417		# Compute raw clumped isotope anomalies
    1418		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
    1419		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
    1420		r['D49raw'] = 1000 * (R49 / R49stoch - 1)
    1421
    1422
    1423	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
    1424		'''
    1425		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
    1426		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
    1427		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
    1428		'''
    1429
    1430		# Compute R17
    1431		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
    1432
    1433		# Compute isotope concentrations
    1434		C12 = (1 + R13) ** -1
    1435		C13 = C12 * R13
    1436		C16 = (1 + R17 + R18) ** -1
    1437		C17 = C16 * R17
    1438		C18 = C16 * R18
    1439
    1440		# Compute stochastic isotopologue concentrations
    1441		C626 = C16 * C12 * C16
    1442		C627 = C16 * C12 * C17 * 2
    1443		C628 = C16 * C12 * C18 * 2
    1444		C636 = C16 * C13 * C16
    1445		C637 = C16 * C13 * C17 * 2
    1446		C638 = C16 * C13 * C18 * 2
    1447		C727 = C17 * C12 * C17
    1448		C728 = C17 * C12 * C18 * 2
    1449		C737 = C17 * C13 * C17
    1450		C738 = C17 * C13 * C18 * 2
    1451		C828 = C18 * C12 * C18
    1452		C838 = C18 * C13 * C18
    1453
    1454		# Compute stochastic isobar ratios
    1455		R45 = (C636 + C627) / C626
    1456		R46 = (C628 + C637 + C727) / C626
    1457		R47 = (C638 + C728 + C737) / C626
    1458		R48 = (C738 + C828) / C626
    1459		R49 = C838 / C626
    1460
    1461		# Account for stochastic anomalies
    1462		R47 *= 1 + D47 / 1000
    1463		R48 *= 1 + D48 / 1000
    1464		R49 *= 1 + D49 / 1000
    1465
    1466		# Return isobar ratios
    1467		return R45, R46, R47, R48, R49
    1468
    1469
    1470	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
    1471		'''
    1472		Split unknown samples by UID (treat all analyses as different samples)
    1473		or by session (treat analyses of a given sample in different sessions as
    1474		different samples).
    1475
    1476		**Parameters**
    1477
    1478		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
    1479		+ `grouping`: `by_uid` | `by_session`
    1480		'''
    1481		if samples_to_split == 'all':
    1482			samples_to_split = [s for s in self.unknowns]
    1483		gkeys = {'by_uid':'UID', 'by_session':'Session'}
    1484		self.grouping = grouping.lower()
    1485		if self.grouping in gkeys:
    1486			gkey = gkeys[self.grouping]
    1487		for r in self:
    1488			if r['Sample'] in samples_to_split:
    1489				r['Sample_original'] = r['Sample']
    1490				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
    1491			elif r['Sample'] in self.unknowns:
    1492				r['Sample_original'] = r['Sample']
    1493		self.refresh_samples()
    1494
    1495
    1496	def unsplit_samples(self, tables = False):
    1497		'''
    1498		Reverse the effects of `D47data.split_samples()`.
    1499
    1500		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
    1501
    1502		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
    1503		probably use `D4xdata.combine_samples()` instead to reverse the effects of
    1504		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
    1505		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
    1506		that case session-averaged Δ4x values are statistically independent).
    1507		'''
    1508		unknowns_old = sorted({s for s in self.unknowns})
    1509		CM_old = self.standardization.covar[:,:]
    1510		VD_old = self.standardization.params.valuesdict().copy()
    1511		vars_old = self.standardization.var_names
    1512
    1513		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
    1514
    1515		Ns = len(vars_old) - len(unknowns_old)
    1516		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
    1517		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
    1518
    1519		W = np.zeros((len(vars_new), len(vars_old)))
    1520		W[:Ns,:Ns] = np.eye(Ns)
    1521		for u in unknowns_new:
    1522			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
    1523			if self.grouping == 'by_session':
    1524				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
    1525			elif self.grouping == 'by_uid':
    1526				weights = [1 for s in splits]
    1527			sw = sum(weights)
    1528			weights = [w/sw for w in weights]
    1529			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
    1530
    1531		CM_new = W @ CM_old @ W.T
    1532		V = W @ np.array([[VD_old[k]] for k in vars_old])
    1533		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
    1534
    1535		self.standardization.covar = CM_new
    1536		self.standardization.params.valuesdict = lambda : VD_new
    1537		self.standardization.var_names = vars_new
    1538
    1539		for r in self:
    1540			if r['Sample'] in self.unknowns:
    1541				r['Sample_split'] = r['Sample']
    1542				r['Sample'] = r['Sample_original']
    1543
    1544		self.refresh_samples()
    1545		self.consolidate_samples()
    1546		self.repeatabilities()
    1547
    1548		if tables:
    1549			self.table_of_analyses()
    1550			self.table_of_samples()
    1551
    1552	def assign_timestamps(self):
    1553		'''
    1554		Assign a time field `t` of type `float` to each analysis.
    1555
    1556		If `TimeTag` is one of the data fields, `t` is equal within a given session
    1557		to `TimeTag` minus the mean value of `TimeTag` for that session.
    1558		Otherwise, `TimeTag` is by default equal to the index of each analysis
    1559		in the dataset and `t` is defined as above.
    1560		'''
    1561		for session in self.sessions:
    1562			sdata = self.sessions[session]['data']
    1563			try:
    1564				t0 = np.mean([r['TimeTag'] for r in sdata])
    1565				for r in sdata:
    1566					r['t'] = r['TimeTag'] - t0
    1567			except KeyError:
    1568				t0 = (len(sdata)-1)/2
    1569				for t,r in enumerate(sdata):
    1570					r['t'] = t - t0
    1571
    1572
    1573	def report(self):
    1574		'''
    1575		Prints a report on the standardization fit.
    1576		Only applicable after `D4xdata.standardize(method='pooled')`.
    1577		'''
    1578		report_fit(self.standardization)
    1579
    1580
    1581	def combine_samples(self, sample_groups):
    1582		'''
    1583		Combine analyses of different samples to compute weighted average Δ4x
    1584		and new error (co)variances corresponding to the groups defined by the `sample_groups`
    1585		dictionary.
    1586
    1587		Caution: samples are weighted by number of replicate analyses, which is a
    1588		reasonable default behavior but is not always optimal (e.g., in the case of strongly
    1589		correlated analytical errors for one or more samples).
    1590
    1591		Returns a tuplet of:
    1592
    1593		+ the list of group names
    1594		+ an array of the corresponding Δ4x values
    1595		+ the corresponding (co)variance matrix
    1596
    1597		**Parameters**
    1598
    1599		+ `sample_groups`: a dictionary of the form:
    1600		```py
    1601		{'group1': ['sample_1', 'sample_2'],
    1602		 'group2': ['sample_3', 'sample_4', 'sample_5']}
    1603		```
    1604		'''
    1605
    1606		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
    1607		groups = sorted(sample_groups.keys())
    1608		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
    1609		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
    1610		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
    1611		W = np.array([
    1612			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
    1613			for j in groups])
    1614		D4x_new = W @ D4x_old
    1615		CM_new = W @ CM_old @ W.T
    1616
    1617		return groups, D4x_new[:,0], CM_new
    1618
    1619
    1620	@make_verbal
    1621	def standardize(self,
    1622		method = 'pooled',
    1623		weighted_sessions = [],
    1624		consolidate = True,
    1625		consolidate_tables = False,
    1626		consolidate_plots = False,
    1627		constraints = {},
    1628		):
    1629		'''
    1630		Compute absolute Δ4x values for all replicate analyses and for sample averages.
    1631		If `method` argument is set to `'pooled'`, the standardization processes all sessions
    1632		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
    1633		i.e. that their true Δ4x value does not change between sessions,
    1634		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
    1635		`'indep_sessions'`, the standardization processes each session independently, based only
    1636		on anchors analyses.
    1637		'''
    1638
    1639		self.standardization_method = method
    1640		self.assign_timestamps()
    1641
    1642		if method == 'pooled':
    1643			if weighted_sessions:
    1644				for session_group in weighted_sessions:
    1645					if self._4x == '47':
    1646						X = D47data([r for r in self if r['Session'] in session_group])
    1647					elif self._4x == '48':
    1648						X = D48data([r for r in self if r['Session'] in session_group])
    1649					X.Nominal_D4x = self.Nominal_D4x.copy()
    1650					X.refresh()
    1651					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
    1652					w = np.sqrt(result.redchi)
    1653					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
    1654					for r in X:
    1655						r[f'wD{self._4x}raw'] *= w
    1656			else:
    1657				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
    1658				for r in self:
    1659					r[f'wD{self._4x}raw'] = 1.
    1660
    1661			params = Parameters()
    1662			for k,session in enumerate(self.sessions):
    1663				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
    1664				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
    1665				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
    1666				s = pf(session)
    1667				params.add(f'a_{s}', value = 0.9)
    1668				params.add(f'b_{s}', value = 0.)
    1669				params.add(f'c_{s}', value = -0.9)
    1670				params.add(f'a2_{s}', value = 0.,
    1671# 					vary = self.sessions[session]['scrambling_drift'],
    1672					)
    1673				params.add(f'b2_{s}', value = 0.,
    1674# 					vary = self.sessions[session]['slope_drift'],
    1675					)
    1676				params.add(f'c2_{s}', value = 0.,
    1677# 					vary = self.sessions[session]['wg_drift'],
    1678					)
    1679				if not self.sessions[session]['scrambling_drift']:
    1680					params[f'a2_{s}'].expr = '0'
    1681				if not self.sessions[session]['slope_drift']:
    1682					params[f'b2_{s}'].expr = '0'
    1683				if not self.sessions[session]['wg_drift']:
    1684					params[f'c2_{s}'].expr = '0'
    1685
    1686			for sample in self.unknowns:
    1687				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
    1688
    1689			for k in constraints:
    1690				params[k].expr = constraints[k]
    1691
    1692			def residuals(p):
    1693				R = []
    1694				for r in self:
    1695					session = pf(r['Session'])
    1696					sample = pf(r['Sample'])
    1697					if r['Sample'] in self.Nominal_D4x:
    1698						R += [ (
    1699							r[f'D{self._4x}raw'] - (
    1700								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
    1701								+ p[f'b_{session}'] * r[f'd{self._4x}']
    1702								+	p[f'c_{session}']
    1703								+ r['t'] * (
    1704									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
    1705									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    1706									+	p[f'c2_{session}']
    1707									)
    1708								)
    1709							) / r[f'wD{self._4x}raw'] ]
    1710					else:
    1711						R += [ (
    1712							r[f'D{self._4x}raw'] - (
    1713								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
    1714								+ p[f'b_{session}'] * r[f'd{self._4x}']
    1715								+	p[f'c_{session}']
    1716								+ r['t'] * (
    1717									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
    1718									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    1719									+	p[f'c2_{session}']
    1720									)
    1721								)
    1722							) / r[f'wD{self._4x}raw'] ]
    1723				return R
    1724
    1725			M = Minimizer(residuals, params)
    1726			result = M.least_squares()
    1727			self.Nf = result.nfree
    1728			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
    1729			new_names, new_covar, new_se = _fullcovar(result)[:3]
    1730			result.var_names = new_names
    1731			result.covar = new_covar
    1732
    1733			for r in self:
    1734				s = pf(r["Session"])
    1735				a = result.params.valuesdict()[f'a_{s}']
    1736				b = result.params.valuesdict()[f'b_{s}']
    1737				c = result.params.valuesdict()[f'c_{s}']
    1738				a2 = result.params.valuesdict()[f'a2_{s}']
    1739				b2 = result.params.valuesdict()[f'b2_{s}']
    1740				c2 = result.params.valuesdict()[f'c2_{s}']
    1741				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'])
    1742
    1743			self.standardization = result
    1744
    1745			for session in self.sessions:
    1746				self.sessions[session]['Np'] = 3
    1747				for k in ['scrambling', 'slope', 'wg']:
    1748					if self.sessions[session][f'{k}_drift']:
    1749						self.sessions[session]['Np'] += 1
    1750
    1751			if consolidate:
    1752				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
    1753			return result
    1754
    1755
    1756		elif method == 'indep_sessions':
    1757
    1758			if weighted_sessions:
    1759				for session_group in weighted_sessions:
    1760					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
    1761					X.Nominal_D4x = self.Nominal_D4x.copy()
    1762					X.refresh()
    1763					# This is only done to assign r['wD47raw'] for r in X:
    1764					X.standardize(method = method, weighted_sessions = [], consolidate = False)
    1765					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}')
    1766			else:
    1767				self.msg('All weights set to 1 ‰')
    1768				for r in self:
    1769					r[f'wD{self._4x}raw'] = 1
    1770
    1771			for session in self.sessions:
    1772				s = self.sessions[session]
    1773				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
    1774				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
    1775				s['Np'] = sum(p_active)
    1776				sdata = s['data']
    1777
    1778				A = np.array([
    1779					[
    1780						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
    1781						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
    1782						1 / r[f'wD{self._4x}raw'],
    1783						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
    1784						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
    1785						r['t'] / r[f'wD{self._4x}raw']
    1786						]
    1787					for r in sdata if r['Sample'] in self.anchors
    1788					])[:,p_active] # only keep columns for the active parameters
    1789				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])
    1790				s['Na'] = Y.size
    1791				CM = linalg.inv(A.T @ A)
    1792				bf = (CM @ A.T @ Y).T[0,:]
    1793				k = 0
    1794				for n,a in zip(p_names, p_active):
    1795					if a:
    1796						s[n] = bf[k]
    1797# 						self.msg(f'{n} = {bf[k]}')
    1798						k += 1
    1799					else:
    1800						s[n] = 0.
    1801# 						self.msg(f'{n} = 0.0')
    1802
    1803				for r in sdata :
    1804					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
    1805					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'])
    1806					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
    1807
    1808				s['CM'] = np.zeros((6,6))
    1809				i = 0
    1810				k_active = [j for j,a in enumerate(p_active) if a]
    1811				for j,a in enumerate(p_active):
    1812					if a:
    1813						s['CM'][j,k_active] = CM[i,:]
    1814						i += 1
    1815
    1816			if not weighted_sessions:
    1817				w = self.rmswd()['rmswd']
    1818				for r in self:
    1819						r[f'wD{self._4x}'] *= w
    1820						r[f'wD{self._4x}raw'] *= w
    1821				for session in self.sessions:
    1822					self.sessions[session]['CM'] *= w**2
    1823
    1824			for session in self.sessions:
    1825				s = self.sessions[session]
    1826				s['SE_a'] = s['CM'][0,0]**.5
    1827				s['SE_b'] = s['CM'][1,1]**.5
    1828				s['SE_c'] = s['CM'][2,2]**.5
    1829				s['SE_a2'] = s['CM'][3,3]**.5
    1830				s['SE_b2'] = s['CM'][4,4]**.5
    1831				s['SE_c2'] = s['CM'][5,5]**.5
    1832
    1833			if not weighted_sessions:
    1834				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
    1835			else:
    1836				self.Nf = 0
    1837				for sg in weighted_sessions:
    1838					self.Nf += self.rmswd(sessions = sg)['Nf']
    1839
    1840			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
    1841
    1842			avgD4x = {
    1843				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
    1844				for sample in self.samples
    1845				}
    1846			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
    1847			rD4x = (chi2/self.Nf)**.5
    1848			self.repeatability[f'sigma_{self._4x}'] = rD4x
    1849
    1850			if consolidate:
    1851				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
    1852
    1853
    1854	def standardization_error(self, session, d4x, D4x, t = 0):
    1855		'''
    1856		Compute standardization error for a given session and
    1857		(δ47, Δ47) composition.
    1858		'''
    1859		a = self.sessions[session]['a']
    1860		b = self.sessions[session]['b']
    1861		c = self.sessions[session]['c']
    1862		a2 = self.sessions[session]['a2']
    1863		b2 = self.sessions[session]['b2']
    1864		c2 = self.sessions[session]['c2']
    1865		CM = self.sessions[session]['CM']
    1866
    1867		x, y = D4x, d4x
    1868		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
    1869# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
    1870		dxdy = -(b+b2*t) / (a+a2*t)
    1871		dxdz = 1. / (a+a2*t)
    1872		dxda = -x / (a+a2*t)
    1873		dxdb = -y / (a+a2*t)
    1874		dxdc = -1. / (a+a2*t)
    1875		dxda2 = -x * a2 / (a+a2*t)
    1876		dxdb2 = -y * t / (a+a2*t)
    1877		dxdc2 = -t / (a+a2*t)
    1878		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
    1879		sx = (V @ CM @ V.T) ** .5
    1880		return sx
    1881
    1882
    1883	@make_verbal
    1884	def summary(self,
    1885		dir = 'output',
    1886		filename = None,
    1887		save_to_file = True,
    1888		print_out = True,
    1889		):
    1890		'''
    1891		Print out an/or save to disk a summary of the standardization results.
    1892
    1893		**Parameters**
    1894
    1895		+ `dir`: the directory in which to save the table
    1896		+ `filename`: the name to the csv file to write to
    1897		+ `save_to_file`: whether to save the table to disk
    1898		+ `print_out`: whether to print out the table
    1899		'''
    1900
    1901		out = []
    1902		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
    1903		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])})"]]
    1904		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
    1905		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
    1906		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
    1907		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
    1908		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
    1909		out += [['Model degrees of freedom', f"{self.Nf}"]]
    1910		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
    1911		out += [['Standardization method', self.standardization_method]]
    1912
    1913		if save_to_file:
    1914			if not os.path.exists(dir):
    1915				os.makedirs(dir)
    1916			if filename is None:
    1917				filename = f'D{self._4x}_summary.csv'
    1918			with open(f'{dir}/{filename}', 'w') as fid:
    1919				fid.write(make_csv(out))
    1920		if print_out:
    1921			self.msg('\n' + pretty_table(out, header = 0))
    1922
    1923
    1924	@make_verbal
    1925	def table_of_sessions(self,
    1926		dir = 'output',
    1927		filename = None,
    1928		save_to_file = True,
    1929		print_out = True,
    1930		output = None,
    1931		):
    1932		'''
    1933		Print out an/or save to disk a table of sessions.
    1934
    1935		**Parameters**
    1936
    1937		+ `dir`: the directory in which to save the table
    1938		+ `filename`: the name to the csv file to write to
    1939		+ `save_to_file`: whether to save the table to disk
    1940		+ `print_out`: whether to print out the table
    1941		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    1942		    if set to `'raw'`: return a list of list of strings
    1943		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    1944		'''
    1945		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
    1946		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
    1947		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
    1948
    1949		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']]
    1950		if include_a2:
    1951			out[-1] += ['a2 ± SE']
    1952		if include_b2:
    1953			out[-1] += ['b2 ± SE']
    1954		if include_c2:
    1955			out[-1] += ['c2 ± SE']
    1956		for session in self.sessions:
    1957			out += [[
    1958				session,
    1959				f"{self.sessions[session]['Na']}",
    1960				f"{self.sessions[session]['Nu']}",
    1961				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
    1962				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
    1963				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
    1964				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
    1965				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
    1966				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
    1967				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
    1968				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
    1969				]]
    1970			if include_a2:
    1971				if self.sessions[session]['scrambling_drift']:
    1972					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
    1973				else:
    1974					out[-1] += ['']
    1975			if include_b2:
    1976				if self.sessions[session]['slope_drift']:
    1977					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
    1978				else:
    1979					out[-1] += ['']
    1980			if include_c2:
    1981				if self.sessions[session]['wg_drift']:
    1982					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
    1983				else:
    1984					out[-1] += ['']
    1985
    1986		if save_to_file:
    1987			if not os.path.exists(dir):
    1988				os.makedirs(dir)
    1989			if filename is None:
    1990				filename = f'D{self._4x}_sessions.csv'
    1991			with open(f'{dir}/{filename}', 'w') as fid:
    1992				fid.write(make_csv(out))
    1993		if print_out:
    1994			self.msg('\n' + pretty_table(out))
    1995		if output == 'raw':
    1996			return out
    1997		elif output == 'pretty':
    1998			return pretty_table(out)
    1999
    2000
    2001	@make_verbal
    2002	def table_of_analyses(
    2003		self,
    2004		dir = 'output',
    2005		filename = None,
    2006		save_to_file = True,
    2007		print_out = True,
    2008		output = None,
    2009		):
    2010		'''
    2011		Print out an/or save to disk a table of analyses.
    2012
    2013		**Parameters**
    2014
    2015		+ `dir`: the directory in which to save the table
    2016		+ `filename`: the name to the csv file to write to
    2017		+ `save_to_file`: whether to save the table to disk
    2018		+ `print_out`: whether to print out the table
    2019		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    2020		    if set to `'raw'`: return a list of list of strings
    2021		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    2022		'''
    2023
    2024		out = [['UID','Session','Sample']]
    2025		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}]
    2026		for f in extra_fields:
    2027			out[-1] += [f[0]]
    2028		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
    2029		for r in self:
    2030			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
    2031			for f in extra_fields:
    2032				out[-1] += [f"{r[f[0]]:{f[1]}}"]
    2033			out[-1] += [
    2034				f"{r['d13Cwg_VPDB']:.3f}",
    2035				f"{r['d18Owg_VSMOW']:.3f}",
    2036				f"{r['d45']:.6f}",
    2037				f"{r['d46']:.6f}",
    2038				f"{r['d47']:.6f}",
    2039				f"{r['d48']:.6f}",
    2040				f"{r['d49']:.6f}",
    2041				f"{r['d13C_VPDB']:.6f}",
    2042				f"{r['d18O_VSMOW']:.6f}",
    2043				f"{r['D47raw']:.6f}",
    2044				f"{r['D48raw']:.6f}",
    2045				f"{r['D49raw']:.6f}",
    2046				f"{r[f'D{self._4x}']:.6f}"
    2047				]
    2048		if save_to_file:
    2049			if not os.path.exists(dir):
    2050				os.makedirs(dir)
    2051			if filename is None:
    2052				filename = f'D{self._4x}_analyses.csv'
    2053			with open(f'{dir}/{filename}', 'w') as fid:
    2054				fid.write(make_csv(out))
    2055		if print_out:
    2056			self.msg('\n' + pretty_table(out))
    2057		return out
    2058
    2059	@make_verbal
    2060	def covar_table(
    2061		self,
    2062		correl = False,
    2063		dir = 'output',
    2064		filename = None,
    2065		save_to_file = True,
    2066		print_out = True,
    2067		output = None,
    2068		):
    2069		'''
    2070		Print out, save to disk and/or return the variance-covariance matrix of D4x
    2071		for all unknown samples.
    2072
    2073		**Parameters**
    2074
    2075		+ `dir`: the directory in which to save the csv
    2076		+ `filename`: the name of the csv file to write to
    2077		+ `save_to_file`: whether to save the csv
    2078		+ `print_out`: whether to print out the matrix
    2079		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
    2080		    if set to `'raw'`: return a list of list of strings
    2081		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    2082		'''
    2083		samples = sorted([u for u in self.unknowns])
    2084		out = [[''] + samples]
    2085		for s1 in samples:
    2086			out.append([s1])
    2087			for s2 in samples:
    2088				if correl:
    2089					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
    2090				else:
    2091					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
    2092
    2093		if save_to_file:
    2094			if not os.path.exists(dir):
    2095				os.makedirs(dir)
    2096			if filename is None:
    2097				if correl:
    2098					filename = f'D{self._4x}_correl.csv'
    2099				else:
    2100					filename = f'D{self._4x}_covar.csv'
    2101			with open(f'{dir}/{filename}', 'w') as fid:
    2102				fid.write(make_csv(out))
    2103		if print_out:
    2104			self.msg('\n'+pretty_table(out))
    2105		if output == 'raw':
    2106			return out
    2107		elif output == 'pretty':
    2108			return pretty_table(out)
    2109
    2110	@make_verbal
    2111	def table_of_samples(
    2112		self,
    2113		dir = 'output',
    2114		filename = None,
    2115		save_to_file = True,
    2116		print_out = True,
    2117		output = None,
    2118		):
    2119		'''
    2120		Print out, save to disk and/or return a table of samples.
    2121
    2122		**Parameters**
    2123
    2124		+ `dir`: the directory in which to save the csv
    2125		+ `filename`: the name of the csv file to write to
    2126		+ `save_to_file`: whether to save the csv
    2127		+ `print_out`: whether to print out the table
    2128		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    2129		    if set to `'raw'`: return a list of list of strings
    2130		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    2131		'''
    2132
    2133		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
    2134		for sample in self.anchors:
    2135			out += [[
    2136				f"{sample}",
    2137				f"{self.samples[sample]['N']}",
    2138				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    2139				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    2140				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
    2141				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
    2142				]]
    2143		for sample in self.unknowns:
    2144			out += [[
    2145				f"{sample}",
    2146				f"{self.samples[sample]['N']}",
    2147				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    2148				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    2149				f"{self.samples[sample][f'D{self._4x}']:.4f}",
    2150				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
    2151				f"± {self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
    2152				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
    2153				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
    2154				]]
    2155		if save_to_file:
    2156			if not os.path.exists(dir):
    2157				os.makedirs(dir)
    2158			if filename is None:
    2159				filename = f'D{self._4x}_samples.csv'
    2160			with open(f'{dir}/{filename}', 'w') as fid:
    2161				fid.write(make_csv(out))
    2162		if print_out:
    2163			self.msg('\n'+pretty_table(out))
    2164		if output == 'raw':
    2165			return out
    2166		elif output == 'pretty':
    2167			return pretty_table(out)
    2168
    2169
    2170	def plot_sessions(self, dir = 'output', figsize = (8,8)):
    2171		'''
    2172		Generate session plots and save them to disk.
    2173
    2174		**Parameters**
    2175
    2176		+ `dir`: the directory in which to save the plots
    2177		+ `figsize`: the width and height (in inches) of each plot
    2178		'''
    2179		if not os.path.exists(dir):
    2180			os.makedirs(dir)
    2181
    2182		for session in self.sessions:
    2183			sp = self.plot_single_session(session, xylimits = 'constant')
    2184			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
    2185			ppl.close(sp.fig)
    2186
    2187
    2188	@make_verbal
    2189	def consolidate_samples(self):
    2190		'''
    2191		Compile various statistics for each sample.
    2192
    2193		For each anchor sample:
    2194
    2195		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
    2196		+ `SE_D47` or `SE_D48`: set to zero by definition
    2197
    2198		For each unknown sample:
    2199
    2200		+ `D47` or `D48`: the standardized Δ4x value for this unknown
    2201		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
    2202
    2203		For each anchor and unknown:
    2204
    2205		+ `N`: the total number of analyses of this sample
    2206		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
    2207		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
    2208		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
    2209		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
    2210		variance, indicating whether the Δ4x repeatability this sample differs significantly from
    2211		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
    2212		'''
    2213		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
    2214		for sample in self.samples:
    2215			self.samples[sample]['N'] = len(self.samples[sample]['data'])
    2216			if self.samples[sample]['N'] > 1:
    2217				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
    2218
    2219			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
    2220			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
    2221
    2222			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
    2223			if len(D4x_pop) > 2:
    2224				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
    2225
    2226		if self.standardization_method == 'pooled':
    2227			for sample in self.anchors:
    2228				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    2229				self.samples[sample][f'SE_D{self._4x}'] = 0.
    2230			for sample in self.unknowns:
    2231				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
    2232				try:
    2233					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
    2234				except ValueError:
    2235					# when `sample` is constrained by self.standardize(constraints = {...}),
    2236					# it is no longer listed in self.standardization.var_names.
    2237					# Temporary fix: define SE as zero for now
    2238					self.samples[sample][f'SE_D4{self._4x}'] = 0.
    2239
    2240		elif self.standardization_method == 'indep_sessions':
    2241			for sample in self.anchors:
    2242				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    2243				self.samples[sample][f'SE_D{self._4x}'] = 0.
    2244			for sample in self.unknowns:
    2245				self.msg(f'Consolidating sample {sample}')
    2246				self.unknowns[sample][f'session_D{self._4x}'] = {}
    2247				session_avg = []
    2248				for session in self.sessions:
    2249					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
    2250					if sdata:
    2251						self.msg(f'{sample} found in session {session}')
    2252						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
    2253						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
    2254						# !! TODO: sigma_s below does not account for temporal changes in standardization error
    2255						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
    2256						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
    2257						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
    2258						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
    2259				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
    2260				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
    2261				wsum = sum([weights[s] for s in weights])
    2262				for s in weights:
    2263					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
    2264
    2265
    2266	def consolidate_sessions(self):
    2267		'''
    2268		Compute various statistics for each session.
    2269
    2270		+ `Na`: Number of anchor analyses in the session
    2271		+ `Nu`: Number of unknown analyses in the session
    2272		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
    2273		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
    2274		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
    2275		+ `a`: scrambling factor
    2276		+ `b`: compositional slope
    2277		+ `c`: WG offset
    2278		+ `SE_a`: Model stadard erorr of `a`
    2279		+ `SE_b`: Model stadard erorr of `b`
    2280		+ `SE_c`: Model stadard erorr of `c`
    2281		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
    2282		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
    2283		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
    2284		+ `a2`: scrambling factor drift
    2285		+ `b2`: compositional slope drift
    2286		+ `c2`: WG offset drift
    2287		+ `Np`: Number of standardization parameters to fit
    2288		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
    2289		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
    2290		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
    2291		'''
    2292		for session in self.sessions:
    2293			if 'd13Cwg_VPDB' not in self.sessions[session]:
    2294				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
    2295			if 'd18Owg_VSMOW' not in self.sessions[session]:
    2296				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
    2297			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
    2298			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
    2299
    2300			self.msg(f'Computing repeatabilities for session {session}')
    2301			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
    2302			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
    2303			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
    2304
    2305		if self.standardization_method == 'pooled':
    2306			for session in self.sessions:
    2307
    2308				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
    2309				i = self.standardization.var_names.index(f'a_{pf(session)}')
    2310				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
    2311
    2312				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
    2313				i = self.standardization.var_names.index(f'b_{pf(session)}')
    2314				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
    2315
    2316				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
    2317				i = self.standardization.var_names.index(f'c_{pf(session)}')
    2318				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
    2319
    2320				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
    2321				if self.sessions[session]['scrambling_drift']:
    2322					i = self.standardization.var_names.index(f'a2_{pf(session)}')
    2323					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
    2324				else:
    2325					self.sessions[session]['SE_a2'] = 0.
    2326
    2327				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
    2328				if self.sessions[session]['slope_drift']:
    2329					i = self.standardization.var_names.index(f'b2_{pf(session)}')
    2330					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
    2331				else:
    2332					self.sessions[session]['SE_b2'] = 0.
    2333
    2334				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
    2335				if self.sessions[session]['wg_drift']:
    2336					i = self.standardization.var_names.index(f'c2_{pf(session)}')
    2337					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
    2338				else:
    2339					self.sessions[session]['SE_c2'] = 0.
    2340
    2341				i = self.standardization.var_names.index(f'a_{pf(session)}')
    2342				j = self.standardization.var_names.index(f'b_{pf(session)}')
    2343				k = self.standardization.var_names.index(f'c_{pf(session)}')
    2344				CM = np.zeros((6,6))
    2345				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
    2346				try:
    2347					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
    2348					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
    2349					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
    2350					try:
    2351						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    2352						CM[3,4] = self.standardization.covar[i2,j2]
    2353						CM[4,3] = self.standardization.covar[j2,i2]
    2354					except ValueError:
    2355						pass
    2356					try:
    2357						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2358						CM[3,5] = self.standardization.covar[i2,k2]
    2359						CM[5,3] = self.standardization.covar[k2,i2]
    2360					except ValueError:
    2361						pass
    2362				except ValueError:
    2363					pass
    2364				try:
    2365					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    2366					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
    2367					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
    2368					try:
    2369						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2370						CM[4,5] = self.standardization.covar[j2,k2]
    2371						CM[5,4] = self.standardization.covar[k2,j2]
    2372					except ValueError:
    2373						pass
    2374				except ValueError:
    2375					pass
    2376				try:
    2377					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2378					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
    2379					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
    2380				except ValueError:
    2381					pass
    2382
    2383				self.sessions[session]['CM'] = CM
    2384
    2385		elif self.standardization_method == 'indep_sessions':
    2386			pass # Not implemented yet
    2387
    2388
    2389	@make_verbal
    2390	def repeatabilities(self):
    2391		'''
    2392		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    2393		(for all samples, for anchors, and for unknowns).
    2394		'''
    2395		self.msg('Computing reproducibilities for all sessions')
    2396
    2397		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    2398		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    2399		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    2400		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    2401		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
    2402
    2403
    2404	@make_verbal
    2405	def consolidate(self, tables = True, plots = True):
    2406		'''
    2407		Collect information about samples, sessions and repeatabilities.
    2408		'''
    2409		self.consolidate_samples()
    2410		self.consolidate_sessions()
    2411		self.repeatabilities()
    2412
    2413		if tables:
    2414			self.summary()
    2415			self.table_of_sessions()
    2416			self.table_of_analyses()
    2417			self.table_of_samples()
    2418
    2419		if plots:
    2420			self.plot_sessions()
    2421
    2422
    2423	@make_verbal
    2424	def rmswd(self,
    2425		samples = 'all samples',
    2426		sessions = 'all sessions',
    2427		):
    2428		'''
    2429		Compute the χ2, root mean squared weighted deviation
    2430		(i.e. reduced χ2), and corresponding degrees of freedom of the
    2431		Δ4x values for samples in `samples` and sessions in `sessions`.
    2432
    2433		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
    2434		'''
    2435		if samples == 'all samples':
    2436			mysamples = [k for k in self.samples]
    2437		elif samples == 'anchors':
    2438			mysamples = [k for k in self.anchors]
    2439		elif samples == 'unknowns':
    2440			mysamples = [k for k in self.unknowns]
    2441		else:
    2442			mysamples = samples
    2443
    2444		if sessions == 'all sessions':
    2445			sessions = [k for k in self.sessions]
    2446
    2447		chisq, Nf = 0, 0
    2448		for sample in mysamples :
    2449			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2450			if len(G) > 1 :
    2451				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
    2452				Nf += (len(G) - 1)
    2453				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
    2454		r = (chisq / Nf)**.5 if Nf > 0 else 0
    2455		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
    2456		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
    2457
    2458
    2459	@make_verbal
    2460	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
    2461		'''
    2462		Compute the repeatability of `[r[key] for r in self]`
    2463		'''
    2464		# NB: it's debatable whether rD47 should be computed
    2465		# with Nf = len(self)-len(self.samples) instead of
    2466		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
    2467
    2468		if samples == 'all samples':
    2469			mysamples = [k for k in self.samples]
    2470		elif samples == 'anchors':
    2471			mysamples = [k for k in self.anchors]
    2472		elif samples == 'unknowns':
    2473			mysamples = [k for k in self.unknowns]
    2474		else:
    2475			mysamples = samples
    2476
    2477		if sessions == 'all sessions':
    2478			sessions = [k for k in self.sessions]
    2479
    2480		if key in ['D47', 'D48']:
    2481			chisq, Nf = 0, 0
    2482			for sample in mysamples :
    2483				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2484				if len(X) > 1 :
    2485					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
    2486					if sample in self.unknowns:
    2487						Nf += len(X) - 1
    2488					else:
    2489						Nf += len(X)
    2490			if samples in ['anchors', 'all samples']:
    2491				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
    2492			r = (chisq / Nf)**.5 if Nf > 0 else 0
    2493
    2494		else: # if key not in ['D47', 'D48']
    2495			chisq, Nf = 0, 0
    2496			for sample in mysamples :
    2497				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2498				if len(X) > 1 :
    2499					Nf += len(X) - 1
    2500					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
    2501			r = (chisq / Nf)**.5 if Nf > 0 else 0
    2502
    2503		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
    2504		return r
    2505
    2506	def sample_average(self, samples, weights = 'equal', normalize = True):
    2507		'''
    2508		Weighted average Δ4x value of a group of samples, accounting for covariance.
    2509
    2510		Returns the weighed average Δ4x value and associated SE
    2511		of a group of samples. Weights are equal by default. If `normalize` is
    2512		true, `weights` will be rescaled so that their sum equals 1.
    2513
    2514		**Examples**
    2515
    2516		```python
    2517		self.sample_average(['X','Y'], [1, 2])
    2518		```
    2519
    2520		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
    2521		where Δ4x(X) and Δ4x(Y) are the average Δ4x
    2522		values of samples X and Y, respectively.
    2523
    2524		```python
    2525		self.sample_average(['X','Y'], [1, -1], normalize = False)
    2526		```
    2527
    2528		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
    2529		'''
    2530		if weights == 'equal':
    2531			weights = [1/len(samples)] * len(samples)
    2532
    2533		if normalize:
    2534			s = sum(weights)
    2535			if s:
    2536				weights = [w/s for w in weights]
    2537
    2538		try:
    2539# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
    2540# 			C = self.standardization.covar[indices,:][:,indices]
    2541			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
    2542			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
    2543			return correlated_sum(X, C, weights)
    2544		except ValueError:
    2545			return (0., 0.)
    2546
    2547
    2548	def sample_D4x_covar(self, sample1, sample2 = None):
    2549		'''
    2550		Covariance between Δ4x values of samples
    2551
    2552		Returns the error covariance between the average Δ4x values of two
    2553		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
    2554		returns the Δ4x variance for that sample.
    2555		'''
    2556		if sample2 is None:
    2557			sample2 = sample1
    2558		if self.standardization_method == 'pooled':
    2559			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
    2560			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
    2561			return self.standardization.covar[i, j]
    2562		elif self.standardization_method == 'indep_sessions':
    2563			if sample1 == sample2:
    2564				return self.samples[sample1][f'SE_D{self._4x}']**2
    2565			else:
    2566				c = 0
    2567				for session in self.sessions:
    2568					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
    2569					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
    2570					if sdata1 and sdata2:
    2571						a = self.sessions[session]['a']
    2572						# !! TODO: CM below does not account for temporal changes in standardization parameters
    2573						CM = self.sessions[session]['CM'][:3,:3]
    2574						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
    2575						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
    2576						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
    2577						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
    2578						c += (
    2579							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
    2580							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
    2581							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
    2582							@ CM
    2583							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
    2584							) / a**2
    2585				return float(c)
    2586
    2587	def sample_D4x_correl(self, sample1, sample2 = None):
    2588		'''
    2589		Correlation between Δ4x errors of samples
    2590
    2591		Returns the error correlation between the average Δ4x values of two samples.
    2592		'''
    2593		if sample2 is None or sample2 == sample1:
    2594			return 1.
    2595		return (
    2596			self.sample_D4x_covar(sample1, sample2)
    2597			/ self.unknowns[sample1][f'SE_D{self._4x}']
    2598			/ self.unknowns[sample2][f'SE_D{self._4x}']
    2599			)
    2600
    2601	def plot_single_session(self,
    2602		session,
    2603		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
    2604		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
    2605		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
    2606		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
    2607		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
    2608		xylimits = 'free', # | 'constant'
    2609		x_label = None,
    2610		y_label = None,
    2611		error_contour_interval = 'auto',
    2612		fig = 'new',
    2613		):
    2614		'''
    2615		Generate plot for a single session
    2616		'''
    2617		if x_label is None:
    2618			x_label = f'δ$_{{{self._4x}}}$ (‰)'
    2619		if y_label is None:
    2620			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
    2621
    2622		out = _SessionPlot()
    2623		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
    2624		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
    2625
    2626		if fig == 'new':
    2627			out.fig = ppl.figure(figsize = (6,6))
    2628			ppl.subplots_adjust(.1,.1,.9,.9)
    2629
    2630		out.anchor_analyses, = ppl.plot(
    2631			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    2632			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    2633			**kw_plot_anchors)
    2634		out.unknown_analyses, = ppl.plot(
    2635			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    2636			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    2637			**kw_plot_unknowns)
    2638		out.anchor_avg = ppl.plot(
    2639			np.array([ np.array([
    2640				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    2641				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    2642				]) for sample in anchors]).T,
    2643			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
    2644			**kw_plot_anchor_avg)
    2645		out.unknown_avg = ppl.plot(
    2646			np.array([ np.array([
    2647				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    2648				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    2649				]) for sample in unknowns]).T,
    2650			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
    2651			**kw_plot_unknown_avg)
    2652		if xylimits == 'constant':
    2653			x = [r[f'd{self._4x}'] for r in self]
    2654			y = [r[f'D{self._4x}'] for r in self]
    2655			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
    2656			w, h = x2-x1, y2-y1
    2657			x1 -= w/20
    2658			x2 += w/20
    2659			y1 -= h/20
    2660			y2 += h/20
    2661			ppl.axis([x1, x2, y1, y2])
    2662		elif xylimits == 'free':
    2663			x1, x2, y1, y2 = ppl.axis()
    2664		else:
    2665			x1, x2, y1, y2 = ppl.axis(xylimits)
    2666
    2667		if error_contour_interval != 'none':
    2668			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
    2669			XI,YI = np.meshgrid(xi, yi)
    2670			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
    2671			if error_contour_interval == 'auto':
    2672				rng = np.max(SI) - np.min(SI)
    2673				if rng <= 0.01:
    2674					cinterval = 0.001
    2675				elif rng <= 0.03:
    2676					cinterval = 0.004
    2677				elif rng <= 0.1:
    2678					cinterval = 0.01
    2679				elif rng <= 0.3:
    2680					cinterval = 0.03
    2681				elif rng <= 1.:
    2682					cinterval = 0.1
    2683				else:
    2684					cinterval = 0.5
    2685			else:
    2686				cinterval = error_contour_interval
    2687
    2688			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
    2689			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
    2690			out.clabel = ppl.clabel(out.contour)
    2691
    2692		ppl.xlabel(x_label)
    2693		ppl.ylabel(y_label)
    2694		ppl.title(session, weight = 'bold')
    2695		ppl.grid(alpha = .2)
    2696		out.ax = ppl.gca()
    2697
    2698		return out
    2699
    2700	def plot_residuals(
    2701		self,
    2702		hist = False,
    2703		binwidth = 2/3,
    2704		dir = 'output',
    2705		filename = None,
    2706		highlight = [],
    2707		colors = None,
    2708		figsize = None,
    2709		):
    2710		'''
    2711		Plot residuals of each analysis as a function of time (actually, as a function of
    2712		the order of analyses in the `D4xdata` object)
    2713
    2714		+ `hist`: whether to add a histogram of residuals
    2715		+ `histbins`: specify bin edges for the histogram
    2716		+ `dir`: the directory in which to save the plot
    2717		+ `highlight`: a list of samples to highlight
    2718		+ `colors`: a dict of `{<sample>: <color>}` for all samples
    2719		+ `figsize`: (width, height) of figure
    2720		'''
    2721		# Layout
    2722		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
    2723		if hist:
    2724			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
    2725			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
    2726		else:
    2727			ppl.subplots_adjust(.08,.05,.78,.8)
    2728			ax1 = ppl.subplot(111)
    2729
    2730		# Colors
    2731		N = len(self.anchors)
    2732		if colors is None:
    2733			if len(highlight) > 0:
    2734				Nh = len(highlight)
    2735				if Nh == 1:
    2736					colors = {highlight[0]: (0,0,0)}
    2737				elif Nh == 3:
    2738					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
    2739				elif Nh == 4:
    2740					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    2741				else:
    2742					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
    2743			else:
    2744				if N == 3:
    2745					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
    2746				elif N == 4:
    2747					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    2748				else:
    2749					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
    2750
    2751		ppl.sca(ax1)
    2752
    2753		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
    2754
    2755		session = self[0]['Session']
    2756		x1 = 0
    2757# 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
    2758		x_sessions = {}
    2759		one_or_more_singlets = False
    2760		one_or_more_multiplets = False
    2761		multiplets = set()
    2762		for k,r in enumerate(self):
    2763			if r['Session'] != session:
    2764				x2 = k-1
    2765				x_sessions[session] = (x1+x2)/2
    2766				ppl.axvline(k - 0.5, color = 'k', lw = .5)
    2767				session = r['Session']
    2768				x1 = k
    2769			singlet = len(self.samples[r['Sample']]['data']) == 1
    2770			if not singlet:
    2771				multiplets.add(r['Sample'])
    2772			if r['Sample'] in self.unknowns:
    2773				if singlet:
    2774					one_or_more_singlets = True
    2775				else:
    2776					one_or_more_multiplets = True
    2777			kw = dict(
    2778				marker = 'x' if singlet else '+',
    2779				ms = 4 if singlet else 5,
    2780				ls = 'None',
    2781				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
    2782				mew = 1,
    2783				alpha = 0.2 if singlet else 1,
    2784				)
    2785			if highlight and r['Sample'] not in highlight:
    2786				kw['alpha'] = 0.2
    2787			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
    2788		x2 = k
    2789		x_sessions[session] = (x1+x2)/2
    2790
    2791		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
    2792		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
    2793		if not hist:
    2794			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
    2795			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')
    2796
    2797		xmin, xmax, ymin, ymax = ppl.axis()
    2798		for s in x_sessions:
    2799			ppl.text(
    2800				x_sessions[s],
    2801				ymax +1,
    2802				s,
    2803				va = 'bottom',
    2804				**(
    2805					dict(ha = 'center')
    2806					if len(self.sessions[s]['data']) > (0.15 * len(self))
    2807					else dict(ha = 'left', rotation = 45)
    2808					)
    2809				)
    2810
    2811		if hist:
    2812			ppl.sca(ax2)
    2813
    2814		for s in colors:
    2815			kw['marker'] = '+'
    2816			kw['ms'] = 5
    2817			kw['mec'] = colors[s]
    2818			kw['label'] = s
    2819			kw['alpha'] = 1
    2820			ppl.plot([], [], **kw)
    2821
    2822		kw['mec'] = (0,0,0)
    2823
    2824		if one_or_more_singlets:
    2825			kw['marker'] = 'x'
    2826			kw['ms'] = 4
    2827			kw['alpha'] = .2
    2828			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
    2829			ppl.plot([], [], **kw)
    2830
    2831		if one_or_more_multiplets:
    2832			kw['marker'] = '+'
    2833			kw['ms'] = 4
    2834			kw['alpha'] = 1
    2835			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
    2836			ppl.plot([], [], **kw)
    2837
    2838		if hist:
    2839			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
    2840		else:
    2841			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
    2842		leg.set_zorder(-1000)
    2843
    2844		ppl.sca(ax1)
    2845
    2846		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
    2847		ppl.xticks([])
    2848		ppl.axis([-1, len(self), None, None])
    2849
    2850		if hist:
    2851			ppl.sca(ax2)
    2852			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets]
    2853			ppl.hist(
    2854				X,
    2855				orientation = 'horizontal',
    2856				histtype = 'stepfilled',
    2857				ec = [.4]*3,
    2858				fc = [.25]*3,
    2859				alpha = .25,
    2860				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
    2861				)
    2862			ppl.axis([None, None, ymin, ymax])
    2863			ppl.text(0, 0,
    2864				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
    2865				size = 8,
    2866				alpha = 1,
    2867				va = 'center',
    2868				ha = 'left',
    2869				)
    2870
    2871			ppl.xticks([])
    2872			ppl.yticks([])
    2873# 			ax2.spines['left'].set_visible(False)
    2874			ax2.spines['right'].set_visible(False)
    2875			ax2.spines['top'].set_visible(False)
    2876			ax2.spines['bottom'].set_visible(False)
    2877
    2878
    2879		if not os.path.exists(dir):
    2880			os.makedirs(dir)
    2881		if filename is None:
    2882			return fig
    2883		elif filename == '':
    2884			filename = f'D{self._4x}_residuals.pdf'
    2885		ppl.savefig(f'{dir}/{filename}')
    2886		ppl.close(fig)
    2887
    2888
    2889	def simulate(self, *args, **kwargs):
    2890		'''
    2891		Legacy function with warning message pointing to `virtual_data()`
    2892		'''
    2893		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
    2894
    2895	def plot_distribution_of_analyses(
    2896		self,
    2897		dir = 'output',
    2898		filename = None,
    2899		vs_time = False,
    2900		figsize = (6,4),
    2901		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
    2902		output = None,
    2903		):
    2904		'''
    2905		Plot temporal distribution of all analyses in the data set.
    2906
    2907		**Parameters**
    2908
    2909		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    2910		'''
    2911
    2912		asamples = [s for s in self.anchors]
    2913		usamples = [s for s in self.unknowns]
    2914		if output is None or output == 'fig':
    2915			fig = ppl.figure(figsize = figsize)
    2916			ppl.subplots_adjust(*subplots_adjust)
    2917		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    2918		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    2919		Xmax += (Xmax-Xmin)/40
    2920		Xmin -= (Xmax-Xmin)/41
    2921		for k, s in enumerate(asamples + usamples):
    2922			if vs_time:
    2923				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    2924			else:
    2925				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    2926			Y = [-k for x in X]
    2927			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
    2928			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    2929			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
    2930		ppl.axis([Xmin, Xmax, -k-1, 1])
    2931		ppl.xlabel('\ntime')
    2932		ppl.gca().annotate('',
    2933			xy = (0.6, -0.02),
    2934			xycoords = 'axes fraction',
    2935			xytext = (.4, -0.02),
    2936            arrowprops = dict(arrowstyle = "->", color = 'k'),
    2937            )
    2938
    2939
    2940		x2 = -1
    2941		for session in self.sessions:
    2942			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    2943			if vs_time:
    2944				ppl.axvline(x1, color = 'k', lw = .75)
    2945			if x2 > -1:
    2946				if not vs_time:
    2947					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
    2948			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    2949# 			from xlrd import xldate_as_datetime
    2950# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    2951			if vs_time:
    2952				ppl.axvline(x2, color = 'k', lw = .75)
    2953				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
    2954			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
    2955
    2956		ppl.xticks([])
    2957		ppl.yticks([])
    2958
    2959		if output is None:
    2960			if not os.path.exists(dir):
    2961				os.makedirs(dir)
    2962			if filename == None:
    2963				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    2964			ppl.savefig(f'{dir}/{filename}')
    2965			ppl.close(fig)
    2966		elif output == 'ax':
    2967			return ppl.gca()
    2968		elif output == 'fig':
    2969			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

    1017	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
    1018		'''
    1019		**Parameters**
    1020
    1021		+ `l`: a list of dictionaries, with each dictionary including at least the keys
    1022		`Sample`, `d45`, `d46`, and `d47` or `d48`.
    1023		+ `mass`: `'47'` or `'48'`
    1024		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
    1025		+ `session`: define session name for analyses without a `Session` key
    1026		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
    1027
    1028		Returns a `D4xdata` object derived from `list`.
    1029		'''
    1030		self._4x = mass
    1031		self.verbose = verbose
    1032		self.prefix = 'D4xdata'
    1033		self.logfile = logfile
    1034		list.__init__(self, l)
    1035		self.Nf = None
    1036		self.repeatability = {}
    1037		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`.
- `mass`: `'47'` or `'48'`
- `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

    1040	def make_verbal(oldfun):
    1041		'''
    1042		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
    1043		'''
    1044		@wraps(oldfun)
    1045		def newfun(*args, verbose = '', **kwargs):
    1046			myself = args[0]
    1047			oldprefix = myself.prefix
    1048			myself.prefix = oldfun.__name__
    1049			if verbose != '':
    1050				oldverbose = myself.verbose
    1051				myself.verbose = verbose
    1052			out = oldfun(*args, **kwargs)
    1053			myself.prefix = oldprefix
    1054			if verbose != '':
    1055				myself.verbose = oldverbose
    1056			return out
    1057		return newfun

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

def msg(self, txt): View Source

    1060	def msg(self, txt):
    1061		'''
    1062		Log a message to `self.logfile`, and print it out if `verbose = True`
    1063		'''
    1064		self.log(txt)
    1065		if self.verbose:
    1066			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

    1069	def vmsg(self, txt):
    1070		'''
    1071		Log a message to `self.logfile` and print it out
    1072		'''
    1073		self.log(txt)
    1074		print(txt)

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

def log(self, *txts): View Source

    1077	def log(self, *txts):
    1078		'''
    1079		Log a message to `self.logfile`
    1080		'''
    1081		if self.logfile:
    1082			with open(self.logfile, 'a') as fid:
    1083				for txt in txts:
    1084					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

    1087	def refresh(self, session = 'mySession'):
    1088		'''
    1089		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    1090		'''
    1091		self.fill_in_missing_info(session = session)
    1092		self.refresh_sessions()
    1093		self.refresh_samples()

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

def refresh_sessions(self): View Source

    1096	def refresh_sessions(self):
    1097		'''
    1098		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    1099		to `False` for all sessions.
    1100		'''
    1101		self.sessions = {
    1102			s: {'data': [r for r in self if r['Session'] == s]}
    1103			for s in sorted({r['Session'] for r in self})
    1104			}
    1105		for s in self.sessions:
    1106			self.sessions[s]['scrambling_drift'] = False
    1107			self.sessions[s]['slope_drift'] = False
    1108			self.sessions[s]['wg_drift'] = False
    1109			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    1110			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

    1113	def refresh_samples(self):
    1114		'''
    1115		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    1116		'''
    1117		self.samples = {
    1118			s: {'data': [r for r in self if r['Sample'] == s]}
    1119			for s in sorted({r['Sample'] for r in self})
    1120			}
    1121		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    1122		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

    1125	def read(self, filename, sep = '', session = ''):
    1126		'''
    1127		Read file in csv format to load data into a `D47data` object.
    1128
    1129		In the csv file, spaces before and after field separators (`','` by default)
    1130		are optional. Each line corresponds to a single analysis.
    1131
    1132		The required fields are:
    1133
    1134		+ `UID`: a unique identifier
    1135		+ `Session`: an identifier for the analytical session
    1136		+ `Sample`: a sample identifier
    1137		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    1138
    1139		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    1140		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    1141		and `d49` are optional, and set to NaN by default.
    1142
    1143		**Parameters**
    1144
    1145		+ `fileneme`: the path of the file to read
    1146		+ `sep`: csv separator delimiting the fields
    1147		+ `session`: set `Session` field to this string for all analyses
    1148		'''
    1149		with open(filename) as fid:
    1150			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

    1153	def input(self, txt, sep = '', session = ''):
    1154		'''
    1155		Read `txt` string in csv format to load analysis data into a `D47data` object.
    1156
    1157		In the csv string, spaces before and after field separators (`','` by default)
    1158		are optional. Each line corresponds to a single analysis.
    1159
    1160		The required fields are:
    1161
    1162		+ `UID`: a unique identifier
    1163		+ `Session`: an identifier for the analytical session
    1164		+ `Sample`: a sample identifier
    1165		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    1166
    1167		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    1168		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    1169		and `d49` are optional, and set to NaN by default.
    1170
    1171		**Parameters**
    1172
    1173		+ `txt`: the csv string to read
    1174		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    1175		whichever appers most often in `txt`.
    1176		+ `session`: set `Session` field to this string for all analyses
    1177		'''
    1178		if sep == '':
    1179			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    1180		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    1181		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:]]
    1182
    1183		if session != '':
    1184			for r in data:
    1185				r['Session'] = session
    1186
    1187		self += data
    1188		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

    1191	@make_verbal
    1192	def wg(self, samples = None, a18_acid = None):
    1193		'''
    1194		Compute bulk composition of the working gas for each session based on
    1195		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
    1196		`self.Nominal_d18O_VPDB`.
    1197		'''
    1198
    1199		self.msg('Computing WG composition:')
    1200
    1201		if a18_acid is None:
    1202			a18_acid = self.ALPHA_18O_ACID_REACTION
    1203		if samples is None:
    1204			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
    1205
    1206		assert a18_acid, f'Acid fractionation factor should not be zero.'
    1207
    1208		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
    1209		R45R46_standards = {}
    1210		for sample in samples:
    1211			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
    1212			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
    1213			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
    1214			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
    1215			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
    1216
    1217			C12_s = 1 / (1 + R13_s)
    1218			C13_s = R13_s / (1 + R13_s)
    1219			C16_s = 1 / (1 + R17_s + R18_s)
    1220			C17_s = R17_s / (1 + R17_s + R18_s)
    1221			C18_s = R18_s / (1 + R17_s + R18_s)
    1222
    1223			C626_s = C12_s * C16_s ** 2
    1224			C627_s = 2 * C12_s * C16_s * C17_s
    1225			C628_s = 2 * C12_s * C16_s * C18_s
    1226			C636_s = C13_s * C16_s ** 2
    1227			C637_s = 2 * C13_s * C16_s * C17_s
    1228			C727_s = C12_s * C17_s ** 2
    1229
    1230			R45_s = (C627_s + C636_s) / C626_s
    1231			R46_s = (C628_s + C637_s + C727_s) / C626_s
    1232			R45R46_standards[sample] = (R45_s, R46_s)
    1233
    1234		for s in self.sessions:
    1235			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
    1236			assert db, f'No sample from {samples} found in session "{s}".'
    1237# 			dbsamples = sorted({r['Sample'] for r in db})
    1238
    1239			X = [r['d45'] for r in db]
    1240			Y = [R45R46_standards[r['Sample']][0] for r in db]
    1241			x1, x2 = np.min(X), np.max(X)
    1242
    1243			if x1 < x2:
    1244				wgcoord = x1/(x1-x2)
    1245			else:
    1246				wgcoord = 999
    1247
    1248			if wgcoord < -.5 or wgcoord > 1.5:
    1249				# unreasonable to extrapolate to d45 = 0
    1250				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    1251			else :
    1252				# d45 = 0 is reasonably well bracketed
    1253				R45_wg = np.polyfit(X, Y, 1)[1]
    1254
    1255			X = [r['d46'] for r in db]
    1256			Y = [R45R46_standards[r['Sample']][1] for r in db]
    1257			x1, x2 = np.min(X), np.max(X)
    1258
    1259			if x1 < x2:
    1260				wgcoord = x1/(x1-x2)
    1261			else:
    1262				wgcoord = 999
    1263
    1264			if wgcoord < -.5 or wgcoord > 1.5:
    1265				# unreasonable to extrapolate to d46 = 0
    1266				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    1267			else :
    1268				# d46 = 0 is reasonably well bracketed
    1269				R46_wg = np.polyfit(X, Y, 1)[1]
    1270
    1271			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
    1272
    1273			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
    1274
    1275			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
    1276			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
    1277			for r in self.sessions[s]['data']:
    1278				r['d13Cwg_VPDB'] = d13Cwg_VPDB
    1279				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

    1282	def compute_bulk_delta(self, R45, R46, D17O = 0):
    1283		'''
    1284		Compute δ13C_VPDB and δ18O_VSMOW,
    1285		by solving the generalized form of equation (17) from
    1286		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
    1287		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
    1288		solving the corresponding second-order Taylor polynomial.
    1289		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
    1290		'''
    1291
    1292		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
    1293
    1294		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
    1295		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
    1296		C = 2 * self.R18_VSMOW
    1297		D = -R46
    1298
    1299		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
    1300		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
    1301		cc = A + B + C + D
    1302
    1303		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
    1304
    1305		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
    1306		R17 = K * R18 ** self.LAMBDA_17
    1307		R13 = R45 - 2 * R17
    1308
    1309		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
    1310
    1311		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

    1314	@make_verbal
    1315	def crunch(self, verbose = ''):
    1316		'''
    1317		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    1318		'''
    1319		for r in self:
    1320			self.compute_bulk_and_clumping_deltas(r)
    1321		self.standardize_d13C()
    1322		self.standardize_d18O()
    1323		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

    1326	def fill_in_missing_info(self, session = 'mySession'):
    1327		'''
    1328		Fill in optional fields with default values
    1329		'''
    1330		for i,r in enumerate(self):
    1331			if 'D17O' not in r:
    1332				r['D17O'] = 0.
    1333			if 'UID' not in r:
    1334				r['UID'] = f'{i+1}'
    1335			if 'Session' not in r:
    1336				r['Session'] = session
    1337			for k in ['d47', 'd48', 'd49']:
    1338				if k not in r:
    1339					r[k] = np.nan

Fill in optional fields with default values

def standardize_d13C(self): View Source

    1342	def standardize_d13C(self):
    1343		'''
    1344		Perform δ13C standadization within each session `s` according to
    1345		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
    1346		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
    1347		may be redefined abitrarily at a later stage.
    1348		'''
    1349		for s in self.sessions:
    1350			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
    1351				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]
    1352				X,Y = zip(*XY)
    1353				if self.sessions[s]['d13C_standardization_method'] == '1pt':
    1354					offset = np.mean(Y) - np.mean(X)
    1355					for r in self.sessions[s]['data']:
    1356						r['d13C_VPDB'] += offset
    1357				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
    1358					a,b = np.polyfit(X,Y,1)
    1359					for r in self.sessions[s]['data']:
    1360						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

    1362	def standardize_d18O(self):
    1363		'''
    1364		Perform δ18O standadization within each session `s` according to
    1365		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
    1366		which is defined by default by `D47data.refresh_sessions()`as equal to
    1367		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
    1368		'''
    1369		for s in self.sessions:
    1370			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
    1371				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]
    1372				X,Y = zip(*XY)
    1373				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
    1374				if self.sessions[s]['d18O_standardization_method'] == '1pt':
    1375					offset = np.mean(Y) - np.mean(X)
    1376					for r in self.sessions[s]['data']:
    1377						r['d18O_VSMOW'] += offset
    1378				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
    1379					a,b = np.polyfit(X,Y,1)
    1380					for r in self.sessions[s]['data']:
    1381						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

    1384	def compute_bulk_and_clumping_deltas(self, r):
    1385		'''
    1386		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
    1387		'''
    1388
    1389		# Compute working gas R13, R18, and isobar ratios
    1390		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
    1391		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
    1392		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
    1393
    1394		# Compute analyte isobar ratios
    1395		R45 = (1 + r['d45'] / 1000) * R45_wg
    1396		R46 = (1 + r['d46'] / 1000) * R46_wg
    1397		R47 = (1 + r['d47'] / 1000) * R47_wg
    1398		R48 = (1 + r['d48'] / 1000) * R48_wg
    1399		R49 = (1 + r['d49'] / 1000) * R49_wg
    1400
    1401		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
    1402		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
    1403		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
    1404
    1405		# Compute stochastic isobar ratios of the analyte
    1406		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
    1407			R13, R18, D17O = r['D17O']
    1408		)
    1409
    1410		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
    1411		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
    1412		if (R45 / R45stoch - 1) > 5e-8:
    1413			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
    1414		if (R46 / R46stoch - 1) > 5e-8:
    1415			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
    1416
    1417		# Compute raw clumped isotope anomalies
    1418		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
    1419		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
    1420		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

    1423	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
    1424		'''
    1425		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
    1426		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
    1427		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
    1428		'''
    1429
    1430		# Compute R17
    1431		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
    1432
    1433		# Compute isotope concentrations
    1434		C12 = (1 + R13) ** -1
    1435		C13 = C12 * R13
    1436		C16 = (1 + R17 + R18) ** -1
    1437		C17 = C16 * R17
    1438		C18 = C16 * R18
    1439
    1440		# Compute stochastic isotopologue concentrations
    1441		C626 = C16 * C12 * C16
    1442		C627 = C16 * C12 * C17 * 2
    1443		C628 = C16 * C12 * C18 * 2
    1444		C636 = C16 * C13 * C16
    1445		C637 = C16 * C13 * C17 * 2
    1446		C638 = C16 * C13 * C18 * 2
    1447		C727 = C17 * C12 * C17
    1448		C728 = C17 * C12 * C18 * 2
    1449		C737 = C17 * C13 * C17
    1450		C738 = C17 * C13 * C18 * 2
    1451		C828 = C18 * C12 * C18
    1452		C838 = C18 * C13 * C18
    1453
    1454		# Compute stochastic isobar ratios
    1455		R45 = (C636 + C627) / C626
    1456		R46 = (C628 + C637 + C727) / C626
    1457		R47 = (C638 + C728 + C737) / C626
    1458		R48 = (C738 + C828) / C626
    1459		R49 = C838 / C626
    1460
    1461		# Account for stochastic anomalies
    1462		R47 *= 1 + D47 / 1000
    1463		R48 *= 1 + D48 / 1000
    1464		R49 *= 1 + D49 / 1000
    1465
    1466		# Return isobar ratios
    1467		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

    1470	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
    1471		'''
    1472		Split unknown samples by UID (treat all analyses as different samples)
    1473		or by session (treat analyses of a given sample in different sessions as
    1474		different samples).
    1475
    1476		**Parameters**
    1477
    1478		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
    1479		+ `grouping`: `by_uid` | `by_session`
    1480		'''
    1481		if samples_to_split == 'all':
    1482			samples_to_split = [s for s in self.unknowns]
    1483		gkeys = {'by_uid':'UID', 'by_session':'Session'}
    1484		self.grouping = grouping.lower()
    1485		if self.grouping in gkeys:
    1486			gkey = gkeys[self.grouping]
    1487		for r in self:
    1488			if r['Sample'] in samples_to_split:
    1489				r['Sample_original'] = r['Sample']
    1490				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
    1491			elif r['Sample'] in self.unknowns:
    1492				r['Sample_original'] = r['Sample']
    1493		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

    1496	def unsplit_samples(self, tables = False):
    1497		'''
    1498		Reverse the effects of `D47data.split_samples()`.
    1499
    1500		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
    1501
    1502		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
    1503		probably use `D4xdata.combine_samples()` instead to reverse the effects of
    1504		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
    1505		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
    1506		that case session-averaged Δ4x values are statistically independent).
    1507		'''
    1508		unknowns_old = sorted({s for s in self.unknowns})
    1509		CM_old = self.standardization.covar[:,:]
    1510		VD_old = self.standardization.params.valuesdict().copy()
    1511		vars_old = self.standardization.var_names
    1512
    1513		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
    1514
    1515		Ns = len(vars_old) - len(unknowns_old)
    1516		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
    1517		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
    1518
    1519		W = np.zeros((len(vars_new), len(vars_old)))
    1520		W[:Ns,:Ns] = np.eye(Ns)
    1521		for u in unknowns_new:
    1522			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
    1523			if self.grouping == 'by_session':
    1524				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
    1525			elif self.grouping == 'by_uid':
    1526				weights = [1 for s in splits]
    1527			sw = sum(weights)
    1528			weights = [w/sw for w in weights]
    1529			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
    1530
    1531		CM_new = W @ CM_old @ W.T
    1532		V = W @ np.array([[VD_old[k]] for k in vars_old])
    1533		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
    1534
    1535		self.standardization.covar = CM_new
    1536		self.standardization.params.valuesdict = lambda : VD_new
    1537		self.standardization.var_names = vars_new
    1538
    1539		for r in self:
    1540			if r['Sample'] in self.unknowns:
    1541				r['Sample_split'] = r['Sample']
    1542				r['Sample'] = r['Sample_original']
    1543
    1544		self.refresh_samples()
    1545		self.consolidate_samples()
    1546		self.repeatabilities()
    1547
    1548		if tables:
    1549			self.table_of_analyses()
    1550			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

    1552	def assign_timestamps(self):
    1553		'''
    1554		Assign a time field `t` of type `float` to each analysis.
    1555
    1556		If `TimeTag` is one of the data fields, `t` is equal within a given session
    1557		to `TimeTag` minus the mean value of `TimeTag` for that session.
    1558		Otherwise, `TimeTag` is by default equal to the index of each analysis
    1559		in the dataset and `t` is defined as above.
    1560		'''
    1561		for session in self.sessions:
    1562			sdata = self.sessions[session]['data']
    1563			try:
    1564				t0 = np.mean([r['TimeTag'] for r in sdata])
    1565				for r in sdata:
    1566					r['t'] = r['TimeTag'] - t0
    1567			except KeyError:
    1568				t0 = (len(sdata)-1)/2
    1569				for t,r in enumerate(sdata):
    1570					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

    1573	def report(self):
    1574		'''
    1575		Prints a report on the standardization fit.
    1576		Only applicable after `D4xdata.standardize(method='pooled')`.
    1577		'''
    1578		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

    1581	def combine_samples(self, sample_groups):
    1582		'''
    1583		Combine analyses of different samples to compute weighted average Δ4x
    1584		and new error (co)variances corresponding to the groups defined by the `sample_groups`
    1585		dictionary.
    1586
    1587		Caution: samples are weighted by number of replicate analyses, which is a
    1588		reasonable default behavior but is not always optimal (e.g., in the case of strongly
    1589		correlated analytical errors for one or more samples).
    1590
    1591		Returns a tuplet of:
    1592
    1593		+ the list of group names
    1594		+ an array of the corresponding Δ4x values
    1595		+ the corresponding (co)variance matrix
    1596
    1597		**Parameters**
    1598
    1599		+ `sample_groups`: a dictionary of the form:
    1600		```py
    1601		{'group1': ['sample_1', 'sample_2'],
    1602		 'group2': ['sample_3', 'sample_4', 'sample_5']}
    1603		```
    1604		'''
    1605
    1606		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
    1607		groups = sorted(sample_groups.keys())
    1608		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
    1609		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
    1610		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
    1611		W = np.array([
    1612			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
    1613			for j in groups])
    1614		D4x_new = W @ D4x_old
    1615		CM_new = W @ CM_old @ W.T
    1616
    1617		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

    1620	@make_verbal
    1621	def standardize(self,
    1622		method = 'pooled',
    1623		weighted_sessions = [],
    1624		consolidate = True,
    1625		consolidate_tables = False,
    1626		consolidate_plots = False,
    1627		constraints = {},
    1628		):
    1629		'''
    1630		Compute absolute Δ4x values for all replicate analyses and for sample averages.
    1631		If `method` argument is set to `'pooled'`, the standardization processes all sessions
    1632		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
    1633		i.e. that their true Δ4x value does not change between sessions,
    1634		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
    1635		`'indep_sessions'`, the standardization processes each session independently, based only
    1636		on anchors analyses.
    1637		'''
    1638
    1639		self.standardization_method = method
    1640		self.assign_timestamps()
    1641
    1642		if method == 'pooled':
    1643			if weighted_sessions:
    1644				for session_group in weighted_sessions:
    1645					if self._4x == '47':
    1646						X = D47data([r for r in self if r['Session'] in session_group])
    1647					elif self._4x == '48':
    1648						X = D48data([r for r in self if r['Session'] in session_group])
    1649					X.Nominal_D4x = self.Nominal_D4x.copy()
    1650					X.refresh()
    1651					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
    1652					w = np.sqrt(result.redchi)
    1653					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
    1654					for r in X:
    1655						r[f'wD{self._4x}raw'] *= w
    1656			else:
    1657				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
    1658				for r in self:
    1659					r[f'wD{self._4x}raw'] = 1.
    1660
    1661			params = Parameters()
    1662			for k,session in enumerate(self.sessions):
    1663				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
    1664				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
    1665				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
    1666				s = pf(session)
    1667				params.add(f'a_{s}', value = 0.9)
    1668				params.add(f'b_{s}', value = 0.)
    1669				params.add(f'c_{s}', value = -0.9)
    1670				params.add(f'a2_{s}', value = 0.,
    1671# 					vary = self.sessions[session]['scrambling_drift'],
    1672					)
    1673				params.add(f'b2_{s}', value = 0.,
    1674# 					vary = self.sessions[session]['slope_drift'],
    1675					)
    1676				params.add(f'c2_{s}', value = 0.,
    1677# 					vary = self.sessions[session]['wg_drift'],
    1678					)
    1679				if not self.sessions[session]['scrambling_drift']:
    1680					params[f'a2_{s}'].expr = '0'
    1681				if not self.sessions[session]['slope_drift']:
    1682					params[f'b2_{s}'].expr = '0'
    1683				if not self.sessions[session]['wg_drift']:
    1684					params[f'c2_{s}'].expr = '0'
    1685
    1686			for sample in self.unknowns:
    1687				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
    1688
    1689			for k in constraints:
    1690				params[k].expr = constraints[k]
    1691
    1692			def residuals(p):
    1693				R = []
    1694				for r in self:
    1695					session = pf(r['Session'])
    1696					sample = pf(r['Sample'])
    1697					if r['Sample'] in self.Nominal_D4x:
    1698						R += [ (
    1699							r[f'D{self._4x}raw'] - (
    1700								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
    1701								+ p[f'b_{session}'] * r[f'd{self._4x}']
    1702								+	p[f'c_{session}']
    1703								+ r['t'] * (
    1704									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
    1705									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    1706									+	p[f'c2_{session}']
    1707									)
    1708								)
    1709							) / r[f'wD{self._4x}raw'] ]
    1710					else:
    1711						R += [ (
    1712							r[f'D{self._4x}raw'] - (
    1713								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
    1714								+ p[f'b_{session}'] * r[f'd{self._4x}']
    1715								+	p[f'c_{session}']
    1716								+ r['t'] * (
    1717									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
    1718									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    1719									+	p[f'c2_{session}']
    1720									)
    1721								)
    1722							) / r[f'wD{self._4x}raw'] ]
    1723				return R
    1724
    1725			M = Minimizer(residuals, params)
    1726			result = M.least_squares()
    1727			self.Nf = result.nfree
    1728			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
    1729			new_names, new_covar, new_se = _fullcovar(result)[:3]
    1730			result.var_names = new_names
    1731			result.covar = new_covar
    1732
    1733			for r in self:
    1734				s = pf(r["Session"])
    1735				a = result.params.valuesdict()[f'a_{s}']
    1736				b = result.params.valuesdict()[f'b_{s}']
    1737				c = result.params.valuesdict()[f'c_{s}']
    1738				a2 = result.params.valuesdict()[f'a2_{s}']
    1739				b2 = result.params.valuesdict()[f'b2_{s}']
    1740				c2 = result.params.valuesdict()[f'c2_{s}']
    1741				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'])
    1742
    1743			self.standardization = result
    1744
    1745			for session in self.sessions:
    1746				self.sessions[session]['Np'] = 3
    1747				for k in ['scrambling', 'slope', 'wg']:
    1748					if self.sessions[session][f'{k}_drift']:
    1749						self.sessions[session]['Np'] += 1
    1750
    1751			if consolidate:
    1752				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
    1753			return result
    1754
    1755
    1756		elif method == 'indep_sessions':
    1757
    1758			if weighted_sessions:
    1759				for session_group in weighted_sessions:
    1760					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
    1761					X.Nominal_D4x = self.Nominal_D4x.copy()
    1762					X.refresh()
    1763					# This is only done to assign r['wD47raw'] for r in X:
    1764					X.standardize(method = method, weighted_sessions = [], consolidate = False)
    1765					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}')
    1766			else:
    1767				self.msg('All weights set to 1 ‰')
    1768				for r in self:
    1769					r[f'wD{self._4x}raw'] = 1
    1770
    1771			for session in self.sessions:
    1772				s = self.sessions[session]
    1773				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
    1774				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
    1775				s['Np'] = sum(p_active)
    1776				sdata = s['data']
    1777
    1778				A = np.array([
    1779					[
    1780						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
    1781						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
    1782						1 / r[f'wD{self._4x}raw'],
    1783						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
    1784						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
    1785						r['t'] / r[f'wD{self._4x}raw']
    1786						]
    1787					for r in sdata if r['Sample'] in self.anchors
    1788					])[:,p_active] # only keep columns for the active parameters
    1789				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])
    1790				s['Na'] = Y.size
    1791				CM = linalg.inv(A.T @ A)
    1792				bf = (CM @ A.T @ Y).T[0,:]
    1793				k = 0
    1794				for n,a in zip(p_names, p_active):
    1795					if a:
    1796						s[n] = bf[k]
    1797# 						self.msg(f'{n} = {bf[k]}')
    1798						k += 1
    1799					else:
    1800						s[n] = 0.
    1801# 						self.msg(f'{n} = 0.0')
    1802
    1803				for r in sdata :
    1804					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
    1805					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'])
    1806					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
    1807
    1808				s['CM'] = np.zeros((6,6))
    1809				i = 0
    1810				k_active = [j for j,a in enumerate(p_active) if a]
    1811				for j,a in enumerate(p_active):
    1812					if a:
    1813						s['CM'][j,k_active] = CM[i,:]
    1814						i += 1
    1815
    1816			if not weighted_sessions:
    1817				w = self.rmswd()['rmswd']
    1818				for r in self:
    1819						r[f'wD{self._4x}'] *= w
    1820						r[f'wD{self._4x}raw'] *= w
    1821				for session in self.sessions:
    1822					self.sessions[session]['CM'] *= w**2
    1823
    1824			for session in self.sessions:
    1825				s = self.sessions[session]
    1826				s['SE_a'] = s['CM'][0,0]**.5
    1827				s['SE_b'] = s['CM'][1,1]**.5
    1828				s['SE_c'] = s['CM'][2,2]**.5
    1829				s['SE_a2'] = s['CM'][3,3]**.5
    1830				s['SE_b2'] = s['CM'][4,4]**.5
    1831				s['SE_c2'] = s['CM'][5,5]**.5
    1832
    1833			if not weighted_sessions:
    1834				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
    1835			else:
    1836				self.Nf = 0
    1837				for sg in weighted_sessions:
    1838					self.Nf += self.rmswd(sessions = sg)['Nf']
    1839
    1840			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
    1841
    1842			avgD4x = {
    1843				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
    1844				for sample in self.samples
    1845				}
    1846			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
    1847			rD4x = (chi2/self.Nf)**.5
    1848			self.repeatability[f'sigma_{self._4x}'] = rD4x
    1849
    1850			if consolidate:
    1851				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

    1854	def standardization_error(self, session, d4x, D4x, t = 0):
    1855		'''
    1856		Compute standardization error for a given session and
    1857		(δ47, Δ47) composition.
    1858		'''
    1859		a = self.sessions[session]['a']
    1860		b = self.sessions[session]['b']
    1861		c = self.sessions[session]['c']
    1862		a2 = self.sessions[session]['a2']
    1863		b2 = self.sessions[session]['b2']
    1864		c2 = self.sessions[session]['c2']
    1865		CM = self.sessions[session]['CM']
    1866
    1867		x, y = D4x, d4x
    1868		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
    1869# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
    1870		dxdy = -(b+b2*t) / (a+a2*t)
    1871		dxdz = 1. / (a+a2*t)
    1872		dxda = -x / (a+a2*t)
    1873		dxdb = -y / (a+a2*t)
    1874		dxdc = -1. / (a+a2*t)
    1875		dxda2 = -x * a2 / (a+a2*t)
    1876		dxdb2 = -y * t / (a+a2*t)
    1877		dxdc2 = -t / (a+a2*t)
    1878		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
    1879		sx = (V @ CM @ V.T) ** .5
    1880		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

    1883	@make_verbal
    1884	def summary(self,
    1885		dir = 'output',
    1886		filename = None,
    1887		save_to_file = True,
    1888		print_out = True,
    1889		):
    1890		'''
    1891		Print out an/or save to disk a summary of the standardization results.
    1892
    1893		**Parameters**
    1894
    1895		+ `dir`: the directory in which to save the table
    1896		+ `filename`: the name to the csv file to write to
    1897		+ `save_to_file`: whether to save the table to disk
    1898		+ `print_out`: whether to print out the table
    1899		'''
    1900
    1901		out = []
    1902		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
    1903		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])})"]]
    1904		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
    1905		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
    1906		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
    1907		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
    1908		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
    1909		out += [['Model degrees of freedom', f"{self.Nf}"]]
    1910		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
    1911		out += [['Standardization method', self.standardization_method]]
    1912
    1913		if save_to_file:
    1914			if not os.path.exists(dir):
    1915				os.makedirs(dir)
    1916			if filename is None:
    1917				filename = f'D{self._4x}_summary.csv'
    1918			with open(f'{dir}/{filename}', 'w') as fid:
    1919				fid.write(make_csv(out))
    1920		if print_out:
    1921			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

    1924	@make_verbal
    1925	def table_of_sessions(self,
    1926		dir = 'output',
    1927		filename = None,
    1928		save_to_file = True,
    1929		print_out = True,
    1930		output = None,
    1931		):
    1932		'''
    1933		Print out an/or save to disk a table of sessions.
    1934
    1935		**Parameters**
    1936
    1937		+ `dir`: the directory in which to save the table
    1938		+ `filename`: the name to the csv file to write to
    1939		+ `save_to_file`: whether to save the table to disk
    1940		+ `print_out`: whether to print out the table
    1941		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    1942		    if set to `'raw'`: return a list of list of strings
    1943		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    1944		'''
    1945		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
    1946		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
    1947		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
    1948
    1949		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']]
    1950		if include_a2:
    1951			out[-1] += ['a2 ± SE']
    1952		if include_b2:
    1953			out[-1] += ['b2 ± SE']
    1954		if include_c2:
    1955			out[-1] += ['c2 ± SE']
    1956		for session in self.sessions:
    1957			out += [[
    1958				session,
    1959				f"{self.sessions[session]['Na']}",
    1960				f"{self.sessions[session]['Nu']}",
    1961				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
    1962				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
    1963				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
    1964				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
    1965				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
    1966				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
    1967				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
    1968				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
    1969				]]
    1970			if include_a2:
    1971				if self.sessions[session]['scrambling_drift']:
    1972					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
    1973				else:
    1974					out[-1] += ['']
    1975			if include_b2:
    1976				if self.sessions[session]['slope_drift']:
    1977					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
    1978				else:
    1979					out[-1] += ['']
    1980			if include_c2:
    1981				if self.sessions[session]['wg_drift']:
    1982					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
    1983				else:
    1984					out[-1] += ['']
    1985
    1986		if save_to_file:
    1987			if not os.path.exists(dir):
    1988				os.makedirs(dir)
    1989			if filename is None:
    1990				filename = f'D{self._4x}_sessions.csv'
    1991			with open(f'{dir}/{filename}', 'w') as fid:
    1992				fid.write(make_csv(out))
    1993		if print_out:
    1994			self.msg('\n' + pretty_table(out))
    1995		if output == 'raw':
    1996			return out
    1997		elif output == 'pretty':
    1998			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

    2001	@make_verbal
    2002	def table_of_analyses(
    2003		self,
    2004		dir = 'output',
    2005		filename = None,
    2006		save_to_file = True,
    2007		print_out = True,
    2008		output = None,
    2009		):
    2010		'''
    2011		Print out an/or save to disk a table of analyses.
    2012
    2013		**Parameters**
    2014
    2015		+ `dir`: the directory in which to save the table
    2016		+ `filename`: the name to the csv file to write to
    2017		+ `save_to_file`: whether to save the table to disk
    2018		+ `print_out`: whether to print out the table
    2019		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    2020		    if set to `'raw'`: return a list of list of strings
    2021		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    2022		'''
    2023
    2024		out = [['UID','Session','Sample']]
    2025		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}]
    2026		for f in extra_fields:
    2027			out[-1] += [f[0]]
    2028		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
    2029		for r in self:
    2030			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
    2031			for f in extra_fields:
    2032				out[-1] += [f"{r[f[0]]:{f[1]}}"]
    2033			out[-1] += [
    2034				f"{r['d13Cwg_VPDB']:.3f}",
    2035				f"{r['d18Owg_VSMOW']:.3f}",
    2036				f"{r['d45']:.6f}",
    2037				f"{r['d46']:.6f}",
    2038				f"{r['d47']:.6f}",
    2039				f"{r['d48']:.6f}",
    2040				f"{r['d49']:.6f}",
    2041				f"{r['d13C_VPDB']:.6f}",
    2042				f"{r['d18O_VSMOW']:.6f}",
    2043				f"{r['D47raw']:.6f}",
    2044				f"{r['D48raw']:.6f}",
    2045				f"{r['D49raw']:.6f}",
    2046				f"{r[f'D{self._4x}']:.6f}"
    2047				]
    2048		if save_to_file:
    2049			if not os.path.exists(dir):
    2050				os.makedirs(dir)
    2051			if filename is None:
    2052				filename = f'D{self._4x}_analyses.csv'
    2053			with open(f'{dir}/{filename}', 'w') as fid:
    2054				fid.write(make_csv(out))
    2055		if print_out:
    2056			self.msg('\n' + pretty_table(out))
    2057		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

    2059	@make_verbal
    2060	def covar_table(
    2061		self,
    2062		correl = False,
    2063		dir = 'output',
    2064		filename = None,
    2065		save_to_file = True,
    2066		print_out = True,
    2067		output = None,
    2068		):
    2069		'''
    2070		Print out, save to disk and/or return the variance-covariance matrix of D4x
    2071		for all unknown samples.
    2072
    2073		**Parameters**
    2074
    2075		+ `dir`: the directory in which to save the csv
    2076		+ `filename`: the name of the csv file to write to
    2077		+ `save_to_file`: whether to save the csv
    2078		+ `print_out`: whether to print out the matrix
    2079		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
    2080		    if set to `'raw'`: return a list of list of strings
    2081		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    2082		'''
    2083		samples = sorted([u for u in self.unknowns])
    2084		out = [[''] + samples]
    2085		for s1 in samples:
    2086			out.append([s1])
    2087			for s2 in samples:
    2088				if correl:
    2089					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
    2090				else:
    2091					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
    2092
    2093		if save_to_file:
    2094			if not os.path.exists(dir):
    2095				os.makedirs(dir)
    2096			if filename is None:
    2097				if correl:
    2098					filename = f'D{self._4x}_correl.csv'
    2099				else:
    2100					filename = f'D{self._4x}_covar.csv'
    2101			with open(f'{dir}/{filename}', 'w') as fid:
    2102				fid.write(make_csv(out))
    2103		if print_out:
    2104			self.msg('\n'+pretty_table(out))
    2105		if output == 'raw':
    2106			return out
    2107		elif output == 'pretty':
    2108			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

    2110	@make_verbal
    2111	def table_of_samples(
    2112		self,
    2113		dir = 'output',
    2114		filename = None,
    2115		save_to_file = True,
    2116		print_out = True,
    2117		output = None,
    2118		):
    2119		'''
    2120		Print out, save to disk and/or return a table of samples.
    2121
    2122		**Parameters**
    2123
    2124		+ `dir`: the directory in which to save the csv
    2125		+ `filename`: the name of the csv file to write to
    2126		+ `save_to_file`: whether to save the csv
    2127		+ `print_out`: whether to print out the table
    2128		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    2129		    if set to `'raw'`: return a list of list of strings
    2130		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    2131		'''
    2132
    2133		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
    2134		for sample in self.anchors:
    2135			out += [[
    2136				f"{sample}",
    2137				f"{self.samples[sample]['N']}",
    2138				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    2139				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    2140				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
    2141				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
    2142				]]
    2143		for sample in self.unknowns:
    2144			out += [[
    2145				f"{sample}",
    2146				f"{self.samples[sample]['N']}",
    2147				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    2148				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    2149				f"{self.samples[sample][f'D{self._4x}']:.4f}",
    2150				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
    2151				f"± {self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
    2152				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
    2153				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
    2154				]]
    2155		if save_to_file:
    2156			if not os.path.exists(dir):
    2157				os.makedirs(dir)
    2158			if filename is None:
    2159				filename = f'D{self._4x}_samples.csv'
    2160			with open(f'{dir}/{filename}', 'w') as fid:
    2161				fid.write(make_csv(out))
    2162		if print_out:
    2163			self.msg('\n'+pretty_table(out))
    2164		if output == 'raw':
    2165			return out
    2166		elif output == 'pretty':
    2167			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

    2170	def plot_sessions(self, dir = 'output', figsize = (8,8)):
    2171		'''
    2172		Generate session plots and save them to disk.
    2173
    2174		**Parameters**
    2175
    2176		+ `dir`: the directory in which to save the plots
    2177		+ `figsize`: the width and height (in inches) of each plot
    2178		'''
    2179		if not os.path.exists(dir):
    2180			os.makedirs(dir)
    2181
    2182		for session in self.sessions:
    2183			sp = self.plot_single_session(session, xylimits = 'constant')
    2184			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
    2185			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

    2188	@make_verbal
    2189	def consolidate_samples(self):
    2190		'''
    2191		Compile various statistics for each sample.
    2192
    2193		For each anchor sample:
    2194
    2195		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
    2196		+ `SE_D47` or `SE_D48`: set to zero by definition
    2197
    2198		For each unknown sample:
    2199
    2200		+ `D47` or `D48`: the standardized Δ4x value for this unknown
    2201		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
    2202
    2203		For each anchor and unknown:
    2204
    2205		+ `N`: the total number of analyses of this sample
    2206		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
    2207		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
    2208		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
    2209		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
    2210		variance, indicating whether the Δ4x repeatability this sample differs significantly from
    2211		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
    2212		'''
    2213		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
    2214		for sample in self.samples:
    2215			self.samples[sample]['N'] = len(self.samples[sample]['data'])
    2216			if self.samples[sample]['N'] > 1:
    2217				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
    2218
    2219			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
    2220			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
    2221
    2222			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
    2223			if len(D4x_pop) > 2:
    2224				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
    2225
    2226		if self.standardization_method == 'pooled':
    2227			for sample in self.anchors:
    2228				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    2229				self.samples[sample][f'SE_D{self._4x}'] = 0.
    2230			for sample in self.unknowns:
    2231				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
    2232				try:
    2233					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
    2234				except ValueError:
    2235					# when `sample` is constrained by self.standardize(constraints = {...}),
    2236					# it is no longer listed in self.standardization.var_names.
    2237					# Temporary fix: define SE as zero for now
    2238					self.samples[sample][f'SE_D4{self._4x}'] = 0.
    2239
    2240		elif self.standardization_method == 'indep_sessions':
    2241			for sample in self.anchors:
    2242				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    2243				self.samples[sample][f'SE_D{self._4x}'] = 0.
    2244			for sample in self.unknowns:
    2245				self.msg(f'Consolidating sample {sample}')
    2246				self.unknowns[sample][f'session_D{self._4x}'] = {}
    2247				session_avg = []
    2248				for session in self.sessions:
    2249					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
    2250					if sdata:
    2251						self.msg(f'{sample} found in session {session}')
    2252						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
    2253						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
    2254						# !! TODO: sigma_s below does not account for temporal changes in standardization error
    2255						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
    2256						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
    2257						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
    2258						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
    2259				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
    2260				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
    2261				wsum = sum([weights[s] for s in weights])
    2262				for s in weights:
    2263					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

    2266	def consolidate_sessions(self):
    2267		'''
    2268		Compute various statistics for each session.
    2269
    2270		+ `Na`: Number of anchor analyses in the session
    2271		+ `Nu`: Number of unknown analyses in the session
    2272		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
    2273		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
    2274		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
    2275		+ `a`: scrambling factor
    2276		+ `b`: compositional slope
    2277		+ `c`: WG offset
    2278		+ `SE_a`: Model stadard erorr of `a`
    2279		+ `SE_b`: Model stadard erorr of `b`
    2280		+ `SE_c`: Model stadard erorr of `c`
    2281		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
    2282		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
    2283		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
    2284		+ `a2`: scrambling factor drift
    2285		+ `b2`: compositional slope drift
    2286		+ `c2`: WG offset drift
    2287		+ `Np`: Number of standardization parameters to fit
    2288		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
    2289		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
    2290		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
    2291		'''
    2292		for session in self.sessions:
    2293			if 'd13Cwg_VPDB' not in self.sessions[session]:
    2294				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
    2295			if 'd18Owg_VSMOW' not in self.sessions[session]:
    2296				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
    2297			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
    2298			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
    2299
    2300			self.msg(f'Computing repeatabilities for session {session}')
    2301			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
    2302			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
    2303			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
    2304
    2305		if self.standardization_method == 'pooled':
    2306			for session in self.sessions:
    2307
    2308				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
    2309				i = self.standardization.var_names.index(f'a_{pf(session)}')
    2310				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
    2311
    2312				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
    2313				i = self.standardization.var_names.index(f'b_{pf(session)}')
    2314				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
    2315
    2316				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
    2317				i = self.standardization.var_names.index(f'c_{pf(session)}')
    2318				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
    2319
    2320				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
    2321				if self.sessions[session]['scrambling_drift']:
    2322					i = self.standardization.var_names.index(f'a2_{pf(session)}')
    2323					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
    2324				else:
    2325					self.sessions[session]['SE_a2'] = 0.
    2326
    2327				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
    2328				if self.sessions[session]['slope_drift']:
    2329					i = self.standardization.var_names.index(f'b2_{pf(session)}')
    2330					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
    2331				else:
    2332					self.sessions[session]['SE_b2'] = 0.
    2333
    2334				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
    2335				if self.sessions[session]['wg_drift']:
    2336					i = self.standardization.var_names.index(f'c2_{pf(session)}')
    2337					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
    2338				else:
    2339					self.sessions[session]['SE_c2'] = 0.
    2340
    2341				i = self.standardization.var_names.index(f'a_{pf(session)}')
    2342				j = self.standardization.var_names.index(f'b_{pf(session)}')
    2343				k = self.standardization.var_names.index(f'c_{pf(session)}')
    2344				CM = np.zeros((6,6))
    2345				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
    2346				try:
    2347					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
    2348					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
    2349					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
    2350					try:
    2351						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    2352						CM[3,4] = self.standardization.covar[i2,j2]
    2353						CM[4,3] = self.standardization.covar[j2,i2]
    2354					except ValueError:
    2355						pass
    2356					try:
    2357						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2358						CM[3,5] = self.standardization.covar[i2,k2]
    2359						CM[5,3] = self.standardization.covar[k2,i2]
    2360					except ValueError:
    2361						pass
    2362				except ValueError:
    2363					pass
    2364				try:
    2365					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    2366					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
    2367					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
    2368					try:
    2369						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2370						CM[4,5] = self.standardization.covar[j2,k2]
    2371						CM[5,4] = self.standardization.covar[k2,j2]
    2372					except ValueError:
    2373						pass
    2374				except ValueError:
    2375					pass
    2376				try:
    2377					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2378					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
    2379					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
    2380				except ValueError:
    2381					pass
    2382
    2383				self.sessions[session]['CM'] = CM
    2384
    2385		elif self.standardization_method == 'indep_sessions':
    2386			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

    2389	@make_verbal
    2390	def repeatabilities(self):
    2391		'''
    2392		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    2393		(for all samples, for anchors, and for unknowns).
    2394		'''
    2395		self.msg('Computing reproducibilities for all sessions')
    2396
    2397		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    2398		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    2399		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    2400		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    2401		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

    2404	@make_verbal
    2405	def consolidate(self, tables = True, plots = True):
    2406		'''
    2407		Collect information about samples, sessions and repeatabilities.
    2408		'''
    2409		self.consolidate_samples()
    2410		self.consolidate_sessions()
    2411		self.repeatabilities()
    2412
    2413		if tables:
    2414			self.summary()
    2415			self.table_of_sessions()
    2416			self.table_of_analyses()
    2417			self.table_of_samples()
    2418
    2419		if plots:
    2420			self.plot_sessions()

Collect information about samples, sessions and repeatabilities.

@make_verbal
def rmswd(self, samples='all samples', sessions='all sessions'): View Source

    2423	@make_verbal
    2424	def rmswd(self,
    2425		samples = 'all samples',
    2426		sessions = 'all sessions',
    2427		):
    2428		'''
    2429		Compute the χ2, root mean squared weighted deviation
    2430		(i.e. reduced χ2), and corresponding degrees of freedom of the
    2431		Δ4x values for samples in `samples` and sessions in `sessions`.
    2432
    2433		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
    2434		'''
    2435		if samples == 'all samples':
    2436			mysamples = [k for k in self.samples]
    2437		elif samples == 'anchors':
    2438			mysamples = [k for k in self.anchors]
    2439		elif samples == 'unknowns':
    2440			mysamples = [k for k in self.unknowns]
    2441		else:
    2442			mysamples = samples
    2443
    2444		if sessions == 'all sessions':
    2445			sessions = [k for k in self.sessions]
    2446
    2447		chisq, Nf = 0, 0
    2448		for sample in mysamples :
    2449			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2450			if len(G) > 1 :
    2451				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
    2452				Nf += (len(G) - 1)
    2453				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
    2454		r = (chisq / Nf)**.5 if Nf > 0 else 0
    2455		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
    2456		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

    2459	@make_verbal
    2460	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
    2461		'''
    2462		Compute the repeatability of `[r[key] for r in self]`
    2463		'''
    2464		# NB: it's debatable whether rD47 should be computed
    2465		# with Nf = len(self)-len(self.samples) instead of
    2466		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
    2467
    2468		if samples == 'all samples':
    2469			mysamples = [k for k in self.samples]
    2470		elif samples == 'anchors':
    2471			mysamples = [k for k in self.anchors]
    2472		elif samples == 'unknowns':
    2473			mysamples = [k for k in self.unknowns]
    2474		else:
    2475			mysamples = samples
    2476
    2477		if sessions == 'all sessions':
    2478			sessions = [k for k in self.sessions]
    2479
    2480		if key in ['D47', 'D48']:
    2481			chisq, Nf = 0, 0
    2482			for sample in mysamples :
    2483				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2484				if len(X) > 1 :
    2485					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
    2486					if sample in self.unknowns:
    2487						Nf += len(X) - 1
    2488					else:
    2489						Nf += len(X)
    2490			if samples in ['anchors', 'all samples']:
    2491				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
    2492			r = (chisq / Nf)**.5 if Nf > 0 else 0
    2493
    2494		else: # if key not in ['D47', 'D48']
    2495			chisq, Nf = 0, 0
    2496			for sample in mysamples :
    2497				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2498				if len(X) > 1 :
    2499					Nf += len(X) - 1
    2500					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
    2501			r = (chisq / Nf)**.5 if Nf > 0 else 0
    2502
    2503		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
    2504		return r

Compute the repeatability of `[r[key] for r in self]`

def sample_average(self, samples, weights='equal', normalize=True): View Source

    2506	def sample_average(self, samples, weights = 'equal', normalize = True):
    2507		'''
    2508		Weighted average Δ4x value of a group of samples, accounting for covariance.
    2509
    2510		Returns the weighed average Δ4x value and associated SE
    2511		of a group of samples. Weights are equal by default. If `normalize` is
    2512		true, `weights` will be rescaled so that their sum equals 1.
    2513
    2514		**Examples**
    2515
    2516		```python
    2517		self.sample_average(['X','Y'], [1, 2])
    2518		```
    2519
    2520		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
    2521		where Δ4x(X) and Δ4x(Y) are the average Δ4x
    2522		values of samples X and Y, respectively.
    2523
    2524		```python
    2525		self.sample_average(['X','Y'], [1, -1], normalize = False)
    2526		```
    2527
    2528		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
    2529		'''
    2530		if weights == 'equal':
    2531			weights = [1/len(samples)] * len(samples)
    2532
    2533		if normalize:
    2534			s = sum(weights)
    2535			if s:
    2536				weights = [w/s for w in weights]
    2537
    2538		try:
    2539# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
    2540# 			C = self.standardization.covar[indices,:][:,indices]
    2541			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
    2542			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
    2543			return correlated_sum(X, C, weights)
    2544		except ValueError:
    2545			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

    2548	def sample_D4x_covar(self, sample1, sample2 = None):
    2549		'''
    2550		Covariance between Δ4x values of samples
    2551
    2552		Returns the error covariance between the average Δ4x values of two
    2553		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
    2554		returns the Δ4x variance for that sample.
    2555		'''
    2556		if sample2 is None:
    2557			sample2 = sample1
    2558		if self.standardization_method == 'pooled':
    2559			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
    2560			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
    2561			return self.standardization.covar[i, j]
    2562		elif self.standardization_method == 'indep_sessions':
    2563			if sample1 == sample2:
    2564				return self.samples[sample1][f'SE_D{self._4x}']**2
    2565			else:
    2566				c = 0
    2567				for session in self.sessions:
    2568					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
    2569					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
    2570					if sdata1 and sdata2:
    2571						a = self.sessions[session]['a']
    2572						# !! TODO: CM below does not account for temporal changes in standardization parameters
    2573						CM = self.sessions[session]['CM'][:3,:3]
    2574						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
    2575						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
    2576						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
    2577						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
    2578						c += (
    2579							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
    2580							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
    2581							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
    2582							@ CM
    2583							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
    2584							) / a**2
    2585				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

    2587	def sample_D4x_correl(self, sample1, sample2 = None):
    2588		'''
    2589		Correlation between Δ4x errors of samples
    2590
    2591		Returns the error correlation between the average Δ4x values of two samples.
    2592		'''
    2593		if sample2 is None or sample2 == sample1:
    2594			return 1.
    2595		return (
    2596			self.sample_D4x_covar(sample1, sample2)
    2597			/ self.unknowns[sample1][f'SE_D{self._4x}']
    2598			/ self.unknowns[sample2][f'SE_D{self._4x}']
    2599			)

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

    2601	def plot_single_session(self,
    2602		session,
    2603		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
    2604		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
    2605		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
    2606		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
    2607		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
    2608		xylimits = 'free', # | 'constant'
    2609		x_label = None,
    2610		y_label = None,
    2611		error_contour_interval = 'auto',
    2612		fig = 'new',
    2613		):
    2614		'''
    2615		Generate plot for a single session
    2616		'''
    2617		if x_label is None:
    2618			x_label = f'δ$_{{{self._4x}}}$ (‰)'
    2619		if y_label is None:
    2620			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
    2621
    2622		out = _SessionPlot()
    2623		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
    2624		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
    2625
    2626		if fig == 'new':
    2627			out.fig = ppl.figure(figsize = (6,6))
    2628			ppl.subplots_adjust(.1,.1,.9,.9)
    2629
    2630		out.anchor_analyses, = ppl.plot(
    2631			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    2632			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    2633			**kw_plot_anchors)
    2634		out.unknown_analyses, = ppl.plot(
    2635			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    2636			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    2637			**kw_plot_unknowns)
    2638		out.anchor_avg = ppl.plot(
    2639			np.array([ np.array([
    2640				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    2641				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    2642				]) for sample in anchors]).T,
    2643			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
    2644			**kw_plot_anchor_avg)
    2645		out.unknown_avg = ppl.plot(
    2646			np.array([ np.array([
    2647				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    2648				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    2649				]) for sample in unknowns]).T,
    2650			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
    2651			**kw_plot_unknown_avg)
    2652		if xylimits == 'constant':
    2653			x = [r[f'd{self._4x}'] for r in self]
    2654			y = [r[f'D{self._4x}'] for r in self]
    2655			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
    2656			w, h = x2-x1, y2-y1
    2657			x1 -= w/20
    2658			x2 += w/20
    2659			y1 -= h/20
    2660			y2 += h/20
    2661			ppl.axis([x1, x2, y1, y2])
    2662		elif xylimits == 'free':
    2663			x1, x2, y1, y2 = ppl.axis()
    2664		else:
    2665			x1, x2, y1, y2 = ppl.axis(xylimits)
    2666
    2667		if error_contour_interval != 'none':
    2668			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
    2669			XI,YI = np.meshgrid(xi, yi)
    2670			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
    2671			if error_contour_interval == 'auto':
    2672				rng = np.max(SI) - np.min(SI)
    2673				if rng <= 0.01:
    2674					cinterval = 0.001
    2675				elif rng <= 0.03:
    2676					cinterval = 0.004
    2677				elif rng <= 0.1:
    2678					cinterval = 0.01
    2679				elif rng <= 0.3:
    2680					cinterval = 0.03
    2681				elif rng <= 1.:
    2682					cinterval = 0.1
    2683				else:
    2684					cinterval = 0.5
    2685			else:
    2686				cinterval = error_contour_interval
    2687
    2688			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
    2689			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
    2690			out.clabel = ppl.clabel(out.contour)
    2691
    2692		ppl.xlabel(x_label)
    2693		ppl.ylabel(y_label)
    2694		ppl.title(session, weight = 'bold')
    2695		ppl.grid(alpha = .2)
    2696		out.ax = ppl.gca()
    2697
    2698		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

    2700	def plot_residuals(
    2701		self,
    2702		hist = False,
    2703		binwidth = 2/3,
    2704		dir = 'output',
    2705		filename = None,
    2706		highlight = [],
    2707		colors = None,
    2708		figsize = None,
    2709		):
    2710		'''
    2711		Plot residuals of each analysis as a function of time (actually, as a function of
    2712		the order of analyses in the `D4xdata` object)
    2713
    2714		+ `hist`: whether to add a histogram of residuals
    2715		+ `histbins`: specify bin edges for the histogram
    2716		+ `dir`: the directory in which to save the plot
    2717		+ `highlight`: a list of samples to highlight
    2718		+ `colors`: a dict of `{<sample>: <color>}` for all samples
    2719		+ `figsize`: (width, height) of figure
    2720		'''
    2721		# Layout
    2722		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
    2723		if hist:
    2724			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
    2725			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
    2726		else:
    2727			ppl.subplots_adjust(.08,.05,.78,.8)
    2728			ax1 = ppl.subplot(111)
    2729
    2730		# Colors
    2731		N = len(self.anchors)
    2732		if colors is None:
    2733			if len(highlight) > 0:
    2734				Nh = len(highlight)
    2735				if Nh == 1:
    2736					colors = {highlight[0]: (0,0,0)}
    2737				elif Nh == 3:
    2738					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
    2739				elif Nh == 4:
    2740					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    2741				else:
    2742					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
    2743			else:
    2744				if N == 3:
    2745					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
    2746				elif N == 4:
    2747					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    2748				else:
    2749					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
    2750
    2751		ppl.sca(ax1)
    2752
    2753		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
    2754
    2755		session = self[0]['Session']
    2756		x1 = 0
    2757# 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
    2758		x_sessions = {}
    2759		one_or_more_singlets = False
    2760		one_or_more_multiplets = False
    2761		multiplets = set()
    2762		for k,r in enumerate(self):
    2763			if r['Session'] != session:
    2764				x2 = k-1
    2765				x_sessions[session] = (x1+x2)/2
    2766				ppl.axvline(k - 0.5, color = 'k', lw = .5)
    2767				session = r['Session']
    2768				x1 = k
    2769			singlet = len(self.samples[r['Sample']]['data']) == 1
    2770			if not singlet:
    2771				multiplets.add(r['Sample'])
    2772			if r['Sample'] in self.unknowns:
    2773				if singlet:
    2774					one_or_more_singlets = True
    2775				else:
    2776					one_or_more_multiplets = True
    2777			kw = dict(
    2778				marker = 'x' if singlet else '+',
    2779				ms = 4 if singlet else 5,
    2780				ls = 'None',
    2781				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
    2782				mew = 1,
    2783				alpha = 0.2 if singlet else 1,
    2784				)
    2785			if highlight and r['Sample'] not in highlight:
    2786				kw['alpha'] = 0.2
    2787			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
    2788		x2 = k
    2789		x_sessions[session] = (x1+x2)/2
    2790
    2791		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
    2792		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
    2793		if not hist:
    2794			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
    2795			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')
    2796
    2797		xmin, xmax, ymin, ymax = ppl.axis()
    2798		for s in x_sessions:
    2799			ppl.text(
    2800				x_sessions[s],
    2801				ymax +1,
    2802				s,
    2803				va = 'bottom',
    2804				**(
    2805					dict(ha = 'center')
    2806					if len(self.sessions[s]['data']) > (0.15 * len(self))
    2807					else dict(ha = 'left', rotation = 45)
    2808					)
    2809				)
    2810
    2811		if hist:
    2812			ppl.sca(ax2)
    2813
    2814		for s in colors:
    2815			kw['marker'] = '+'
    2816			kw['ms'] = 5
    2817			kw['mec'] = colors[s]
    2818			kw['label'] = s
    2819			kw['alpha'] = 1
    2820			ppl.plot([], [], **kw)
    2821
    2822		kw['mec'] = (0,0,0)
    2823
    2824		if one_or_more_singlets:
    2825			kw['marker'] = 'x'
    2826			kw['ms'] = 4
    2827			kw['alpha'] = .2
    2828			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
    2829			ppl.plot([], [], **kw)
    2830
    2831		if one_or_more_multiplets:
    2832			kw['marker'] = '+'
    2833			kw['ms'] = 4
    2834			kw['alpha'] = 1
    2835			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
    2836			ppl.plot([], [], **kw)
    2837
    2838		if hist:
    2839			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
    2840		else:
    2841			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
    2842		leg.set_zorder(-1000)
    2843
    2844		ppl.sca(ax1)
    2845
    2846		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
    2847		ppl.xticks([])
    2848		ppl.axis([-1, len(self), None, None])
    2849
    2850		if hist:
    2851			ppl.sca(ax2)
    2852			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets]
    2853			ppl.hist(
    2854				X,
    2855				orientation = 'horizontal',
    2856				histtype = 'stepfilled',
    2857				ec = [.4]*3,
    2858				fc = [.25]*3,
    2859				alpha = .25,
    2860				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
    2861				)
    2862			ppl.axis([None, None, ymin, ymax])
    2863			ppl.text(0, 0,
    2864				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
    2865				size = 8,
    2866				alpha = 1,
    2867				va = 'center',
    2868				ha = 'left',
    2869				)
    2870
    2871			ppl.xticks([])
    2872			ppl.yticks([])
    2873# 			ax2.spines['left'].set_visible(False)
    2874			ax2.spines['right'].set_visible(False)
    2875			ax2.spines['top'].set_visible(False)
    2876			ax2.spines['bottom'].set_visible(False)
    2877
    2878
    2879		if not os.path.exists(dir):
    2880			os.makedirs(dir)
    2881		if filename is None:
    2882			return fig
    2883		elif filename == '':
    2884			filename = f'D{self._4x}_residuals.pdf'
    2885		ppl.savefig(f'{dir}/{filename}')
    2886		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

    2889	def simulate(self, *args, **kwargs):
    2890		'''
    2891		Legacy function with warning message pointing to `virtual_data()`
    2892		'''
    2893		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, figsize=(6, 4), subplots_adjust=(0.02, 0.13, 0.85, 0.8), output=None): View Source

    2895	def plot_distribution_of_analyses(
    2896		self,
    2897		dir = 'output',
    2898		filename = None,
    2899		vs_time = False,
    2900		figsize = (6,4),
    2901		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
    2902		output = None,
    2903		):
    2904		'''
    2905		Plot temporal distribution of all analyses in the data set.
    2906
    2907		**Parameters**
    2908
    2909		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    2910		'''
    2911
    2912		asamples = [s for s in self.anchors]
    2913		usamples = [s for s in self.unknowns]
    2914		if output is None or output == 'fig':
    2915			fig = ppl.figure(figsize = figsize)
    2916			ppl.subplots_adjust(*subplots_adjust)
    2917		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    2918		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    2919		Xmax += (Xmax-Xmin)/40
    2920		Xmin -= (Xmax-Xmin)/41
    2921		for k, s in enumerate(asamples + usamples):
    2922			if vs_time:
    2923				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    2924			else:
    2925				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    2926			Y = [-k for x in X]
    2927			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
    2928			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    2929			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
    2930		ppl.axis([Xmin, Xmax, -k-1, 1])
    2931		ppl.xlabel('\ntime')
    2932		ppl.gca().annotate('',
    2933			xy = (0.6, -0.02),
    2934			xycoords = 'axes fraction',
    2935			xytext = (.4, -0.02),
    2936            arrowprops = dict(arrowstyle = "->", color = 'k'),
    2937            )
    2938
    2939
    2940		x2 = -1
    2941		for session in self.sessions:
    2942			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    2943			if vs_time:
    2944				ppl.axvline(x1, color = 'k', lw = .75)
    2945			if x2 > -1:
    2946				if not vs_time:
    2947					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
    2948			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    2949# 			from xlrd import xldate_as_datetime
    2950# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    2951			if vs_time:
    2952				ppl.axvline(x2, color = 'k', lw = .75)
    2953				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
    2954			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
    2955
    2956		ppl.xticks([])
    2957		ppl.yticks([])
    2958
    2959		if output is None:
    2960			if not os.path.exists(dir):
    2961				os.makedirs(dir)
    2962			if filename == None:
    2963				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    2964			ppl.savefig(f'{dir}/{filename}')
    2965			ppl.close(fig)
    2966		elif output == 'ax':
    2967			return ppl.gca()
    2968		elif output == 'fig':
    2969			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

    2972class D47data(D4xdata):
    2973	'''
    2974	Store and process data for a large set of Δ47 analyses,
    2975	usually comprising more than one analytical session.
    2976	'''
    2977
    2978	Nominal_D4x = {
    2979		'ETH-1':   0.2052,
    2980		'ETH-2':   0.2085,
    2981		'ETH-3':   0.6132,
    2982		'ETH-4':   0.4511,
    2983		'IAEA-C1': 0.3018,
    2984		'IAEA-C2': 0.6409,
    2985		'MERCK':   0.5135,
    2986		} # I-CDES (Bernasconi et al., 2021)
    2987	'''
    2988	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
    2989	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
    2990	reference frame.
    2991
    2992	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
    2993	```py
    2994	{
    2995		'ETH-1'   : 0.2052,
    2996		'ETH-2'   : 0.2085,
    2997		'ETH-3'   : 0.6132,
    2998		'ETH-4'   : 0.4511,
    2999		'IAEA-C1' : 0.3018,
    3000		'IAEA-C2' : 0.6409,
    3001		'MERCK'   : 0.5135,
    3002	}
    3003	```
    3004	'''
    3005
    3006
    3007	@property
    3008	def Nominal_D47(self):
    3009		return self.Nominal_D4x
    3010
    3011
    3012	@Nominal_D47.setter
    3013	def Nominal_D47(self, new):
    3014		self.Nominal_D4x = dict(**new)
    3015		self.refresh()
    3016
    3017
    3018	def __init__(self, l = [], **kwargs):
    3019		'''
    3020		**Parameters:** same as `D4xdata.__init__()`
    3021		'''
    3022		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    3023
    3024
    3025	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    3026		'''
    3027		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    3028		value for that temperature, and add treat these samples as additional anchors.
    3029
    3030		**Parameters**
    3031
    3032		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    3033		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    3034		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    3035		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    3036		if `new`: keep pre-existing anchors but update them in case of conflict
    3037		between old and new Δ47 values;
    3038		if `old`: keep pre-existing anchors but preserve their original Δ47
    3039		values in case of conflict.
    3040		'''
    3041		f = {
    3042			'petersen': fCO2eqD47_Petersen,
    3043			'wang': fCO2eqD47_Wang,
    3044			}[fCo2eqD47]
    3045		foo = {}
    3046		for r in self:
    3047			if 'Teq' in r:
    3048				if r['Sample'] in foo:
    3049					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    3050				else:
    3051					foo[r['Sample']] = f(r['Teq'])
    3052			else:
    3053					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    3054
    3055		if priority == 'replace':
    3056			self.Nominal_D47 = {}
    3057		for s in foo:
    3058			if priority != 'old' or s not in self.Nominal_D47:
    3059				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

    3018	def __init__(self, l = [], **kwargs):
    3019		'''
    3020		**Parameters:** same as `D4xdata.__init__()`
    3021		'''
    3022		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,
    }

def D47fromTeq(self, fCo2eqD47='petersen', priority='new'): View Source

    3025	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    3026		'''
    3027		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    3028		value for that temperature, and add treat these samples as additional anchors.
    3029
    3030		**Parameters**
    3031
    3032		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    3033		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    3034		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    3035		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    3036		if `new`: keep pre-existing anchors but update them in case of conflict
    3037		between old and new Δ47 values;
    3038		if `old`: keep pre-existing anchors but preserve their original Δ47
    3039		values in case of conflict.
    3040		'''
    3041		f = {
    3042			'petersen': fCO2eqD47_Petersen,
    3043			'wang': fCO2eqD47_Wang,
    3044			}[fCo2eqD47]
    3045		foo = {}
    3046		for r in self:
    3047			if 'Teq' in r:
    3048				if r['Sample'] in foo:
    3049					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    3050				else:
    3051					foo[r['Sample']] = f(r['Teq'])
    3052			else:
    3053					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    3054
    3055		if priority == 'replace':
    3056			self.Nominal_D47 = {}
    3057		for s in foo:
    3058			if priority != 'old' or s not in self.Nominal_D47:
    3059				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

    3064class D48data(D4xdata):
    3065	'''
    3066	Store and process data for a large set of Δ48 analyses,
    3067	usually comprising more than one analytical session.
    3068	'''
    3069
    3070	Nominal_D4x = {
    3071		'ETH-1':  0.138,
    3072		'ETH-2':  0.138,
    3073		'ETH-3':  0.270,
    3074		'ETH-4':  0.223,
    3075		'GU-1':  -0.419,
    3076		} # (Fiebig et al., 2019, 2021)
    3077	'''
    3078	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
    3079	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
    3080	reference frame.
    3081
    3082	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
    3083	Fiebig et al. (in press)):
    3084
    3085	```py
    3086	{
    3087		'ETH-1' :  0.138,
    3088		'ETH-2' :  0.138,
    3089		'ETH-3' :  0.270,
    3090		'ETH-4' :  0.223,
    3091		'GU-1'  : -0.419,
    3092	}
    3093	```
    3094	'''
    3095
    3096
    3097	@property
    3098	def Nominal_D48(self):
    3099		return self.Nominal_D4x
    3100
    3101
    3102	@Nominal_D48.setter
    3103	def Nominal_D48(self, new):
    3104		self.Nominal_D4x = dict(**new)
    3105		self.refresh()
    3106
    3107
    3108	def __init__(self, l = [], **kwargs):
    3109		'''
    3110		**Parameters:** same as `D4xdata.__init__()`
    3111		'''
    3112		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

    3108	def __init__(self, l = [], **kwargs):
    3109		'''
    3110		**Parameters:** same as `D4xdata.__init__()`
    3111		'''
    3112		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,
    }

##### 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