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

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

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

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

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

def msg(self, txt): View Source

    1065	def msg(self, txt):
    1066		'''
    1067		Log a message to `self.logfile`, and print it out if `verbose = True`
    1068		'''
    1069		self.log(txt)
    1070		if self.verbose:
    1071			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

    1074	def vmsg(self, txt):
    1075		'''
    1076		Log a message to `self.logfile` and print it out
    1077		'''
    1078		self.log(txt)
    1079		print(txt)

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

def log(self, *txts): View Source

    1082	def log(self, *txts):
    1083		'''
    1084		Log a message to `self.logfile`
    1085		'''
    1086		if self.logfile:
    1087			with open(self.logfile, 'a') as fid:
    1088				for txt in txts:
    1089					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

    1092	def refresh(self, session = 'mySession'):
    1093		'''
    1094		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    1095		'''
    1096		self.fill_in_missing_info(session = session)
    1097		self.refresh_sessions()
    1098		self.refresh_samples()

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

def refresh_sessions(self): View Source

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

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

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

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

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

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

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

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

Fill in optional fields with default values

def standardize_d13C(self): View Source

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

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

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

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

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

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

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

    1578	def report(self):
    1579		'''
    1580		Prints a report on the standardization fit.
    1581		Only applicable after `D4xdata.standardize(method='pooled')`.
    1582		'''
    1583		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

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

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

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

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

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

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

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

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

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

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

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

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

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

Collect information about samples, sessions and repeatabilities.

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

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

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

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

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

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

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

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

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

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

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

    2894	def simulate(self, *args, **kwargs):
    2895		'''
    2896		Legacy function with warning message pointing to `virtual_data()`
    2897		'''
    2898		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

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

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

    3023	def __init__(self, l = [], **kwargs):
    3024		'''
    3025		**Parameters:** same as `D4xdata.__init__()`
    3026		'''
    3027		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

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

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

    3113	def __init__(self, l = [], **kwargs):
    3114		'''
    3115		**Parameters:** same as `D4xdata.__init__()`
    3116		'''
    3117		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