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.2.4 Checking δ13C and δ18O dispersion

    mydata = D47data(virtual_data(
        session = 'mysession',
        samples = [
            dict(Sample = 'ETH-1', N = 4),
            dict(Sample = 'ETH-2', N = 4),
            dict(Sample = 'ETH-3', N = 4),
            dict(Sample = 'MYSAMPLE', N = 8, D47 = 0.6, D48 = 0.1, d13C_VPDB = -4.0, d18O_VPDB = -12.0),
        ], seed = 123))

    mydata.refresh()
    mydata.wg()
    mydata.crunch()
    mydata.plot_bulk_compositions()

`D4xdata.plot_bulk_compositions()` produces a series of plots, one for each sample, and an additional plot with all samples together. For example, here is the plot for sample `MYSAMPLE`:

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

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

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

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

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

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

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

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

def msg(self, txt): View Source

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

    1078	def vmsg(self, txt):
    1079		'''
    1080		Log a message to `self.logfile` and print it out
    1081		'''
    1082		self.log(txt)
    1083		print(txt)

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

def log(self, *txts): View Source

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

    1096	def refresh(self, session = 'mySession'):
    1097		'''
    1098		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    1099		'''
    1100		self.fill_in_missing_info(session = session)
    1101		self.refresh_sessions()
    1102		self.refresh_samples()

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

def refresh_sessions(self): View Source

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

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

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

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

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

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

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

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

Fill in optional fields with default values

def standardize_d13C(self): View Source

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    2403	@make_verbal
    2404	def repeatabilities(self):
    2405		'''
    2406		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    2407		(for all samples, for anchors, and for unknowns).
    2408		'''
    2409		self.msg('Computing reproducibilities for all sessions')
    2410
    2411		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    2412		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    2413		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    2414		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    2415		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

    2418	@make_verbal
    2419	def consolidate(self, tables = True, plots = True):
    2420		'''
    2421		Collect information about samples, sessions and repeatabilities.
    2422		'''
    2423		self.consolidate_samples()
    2424		self.consolidate_sessions()
    2425		self.repeatabilities()
    2426
    2427		if tables:
    2428			self.summary()
    2429			self.table_of_sessions()
    2430			self.table_of_analyses()
    2431			self.table_of_samples()
    2432
    2433		if plots:
    2434			self.plot_sessions()

Collect information about samples, sessions and repeatabilities.

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

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

    2473	@make_verbal
    2474	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
    2475		'''
    2476		Compute the repeatability of `[r[key] for r in self]`
    2477		'''
    2478
    2479		if samples == 'all samples':
    2480			mysamples = [k for k in self.samples]
    2481		elif samples == 'anchors':
    2482			mysamples = [k for k in self.anchors]
    2483		elif samples == 'unknowns':
    2484			mysamples = [k for k in self.unknowns]
    2485		else:
    2486			mysamples = samples
    2487
    2488		if sessions == 'all sessions':
    2489			sessions = [k for k in self.sessions]
    2490
    2491		if key in ['D47', 'D48']:
    2492			# Full disclosure: the definition of Nf is tricky/debatable
    2493			G = [r for r in self if r['Sample'] in mysamples and r['Session'] in sessions]
    2494			chisq = (np.array([r[f'{key}_residual'] for r in G])**2).sum()
    2495			Nf = len(G)
    2496# 			print(f'len(G) = {Nf}')
    2497			Nf -= len([s for s in mysamples if s in self.unknowns])
    2498# 			print(f'{len([s for s in mysamples if s in self.unknowns])} unknown samples to consider')
    2499			for session in sessions:
    2500				Np = len([
    2501					_ for _ in self.standardization.params
    2502					if (
    2503						self.standardization.params[_].expr is not None
    2504						and (
    2505							(_[0] in 'abc' and _[1] == '_' and _[2:] == pf(session))
    2506							or (_[0] in 'abc' and _[1:3] == '2_' and _[3:] == pf(session))
    2507							)
    2508						)
    2509					])
    2510# 				print(f'session {session}: {Np} parameters to consider')
    2511				Na = len({
    2512					r['Sample'] for r in self.sessions[session]['data']
    2513					if r['Sample'] in self.anchors and r['Sample'] in mysamples
    2514					})
    2515# 				print(f'session {session}: {Na} different anchors in that session')
    2516				Nf -= min(Np, Na)
    2517# 			print(f'Nf = {Nf}')
    2518
    2519# 			for sample in mysamples :
    2520# 				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2521# 				if len(X) > 1 :
    2522# 					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
    2523# 					if sample in self.unknowns:
    2524# 						Nf += len(X) - 1
    2525# 					else:
    2526# 						Nf += len(X)
    2527# 			if samples in ['anchors', 'all samples']:
    2528# 				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
    2529			r = (chisq / Nf)**.5 if Nf > 0 else 0
    2530
    2531		else: # if key not in ['D47', 'D48']
    2532			chisq, Nf = 0, 0
    2533			for sample in mysamples :
    2534				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2535				if len(X) > 1 :
    2536					Nf += len(X) - 1
    2537					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
    2538			r = (chisq / Nf)**.5 if Nf > 0 else 0
    2539
    2540		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
    2541		return r

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

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

    2543	def sample_average(self, samples, weights = 'equal', normalize = True):
    2544		'''
    2545		Weighted average Δ4x value of a group of samples, accounting for covariance.
    2546
    2547		Returns the weighed average Δ4x value and associated SE
    2548		of a group of samples. Weights are equal by default. If `normalize` is
    2549		true, `weights` will be rescaled so that their sum equals 1.
    2550
    2551		**Examples**
    2552
    2553		```python
    2554		self.sample_average(['X','Y'], [1, 2])
    2555		```
    2556
    2557		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
    2558		where Δ4x(X) and Δ4x(Y) are the average Δ4x
    2559		values of samples X and Y, respectively.
    2560
    2561		```python
    2562		self.sample_average(['X','Y'], [1, -1], normalize = False)
    2563		```
    2564
    2565		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
    2566		'''
    2567		if weights == 'equal':
    2568			weights = [1/len(samples)] * len(samples)
    2569
    2570		if normalize:
    2571			s = sum(weights)
    2572			if s:
    2573				weights = [w/s for w in weights]
    2574
    2575		try:
    2576# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
    2577# 			C = self.standardization.covar[indices,:][:,indices]
    2578			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
    2579			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
    2580			return correlated_sum(X, C, weights)
    2581		except ValueError:
    2582			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

    2585	def sample_D4x_covar(self, sample1, sample2 = None):
    2586		'''
    2587		Covariance between Δ4x values of samples
    2588
    2589		Returns the error covariance between the average Δ4x values of two
    2590		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
    2591		returns the Δ4x variance for that sample.
    2592		'''
    2593		if sample2 is None:
    2594			sample2 = sample1
    2595		if self.standardization_method == 'pooled':
    2596			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
    2597			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
    2598			return self.standardization.covar[i, j]
    2599		elif self.standardization_method == 'indep_sessions':
    2600			if sample1 == sample2:
    2601				return self.samples[sample1][f'SE_D{self._4x}']**2
    2602			else:
    2603				c = 0
    2604				for session in self.sessions:
    2605					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
    2606					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
    2607					if sdata1 and sdata2:
    2608						a = self.sessions[session]['a']
    2609						# !! TODO: CM below does not account for temporal changes in standardization parameters
    2610						CM = self.sessions[session]['CM'][:3,:3]
    2611						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
    2612						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
    2613						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
    2614						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
    2615						c += (
    2616							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
    2617							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
    2618							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
    2619							@ CM
    2620							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
    2621							) / a**2
    2622				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

    2624	def sample_D4x_correl(self, sample1, sample2 = None):
    2625		'''
    2626		Correlation between Δ4x errors of samples
    2627
    2628		Returns the error correlation between the average Δ4x values of two samples.
    2629		'''
    2630		if sample2 is None or sample2 == sample1:
    2631			return 1.
    2632		return (
    2633			self.sample_D4x_covar(sample1, sample2)
    2634			/ self.unknowns[sample1][f'SE_D{self._4x}']
    2635			/ self.unknowns[sample2][f'SE_D{self._4x}']
    2636			)

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

    2638	def plot_single_session(self,
    2639		session,
    2640		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
    2641		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
    2642		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
    2643		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
    2644		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
    2645		xylimits = 'free', # | 'constant'
    2646		x_label = None,
    2647		y_label = None,
    2648		error_contour_interval = 'auto',
    2649		fig = 'new',
    2650		):
    2651		'''
    2652		Generate plot for a single session
    2653		'''
    2654		if x_label is None:
    2655			x_label = f'δ$_{{{self._4x}}}$ (‰)'
    2656		if y_label is None:
    2657			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
    2658
    2659		out = _SessionPlot()
    2660		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
    2661		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
    2662
    2663		if fig == 'new':
    2664			out.fig = ppl.figure(figsize = (6,6))
    2665			ppl.subplots_adjust(.1,.1,.9,.9)
    2666
    2667		out.anchor_analyses, = ppl.plot(
    2668			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    2669			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    2670			**kw_plot_anchors)
    2671		out.unknown_analyses, = ppl.plot(
    2672			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    2673			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    2674			**kw_plot_unknowns)
    2675		out.anchor_avg = ppl.plot(
    2676			np.array([ np.array([
    2677				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    2678				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    2679				]) for sample in anchors]).T,
    2680			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
    2681			**kw_plot_anchor_avg)
    2682		out.unknown_avg = ppl.plot(
    2683			np.array([ np.array([
    2684				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    2685				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    2686				]) for sample in unknowns]).T,
    2687			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
    2688			**kw_plot_unknown_avg)
    2689		if xylimits == 'constant':
    2690			x = [r[f'd{self._4x}'] for r in self]
    2691			y = [r[f'D{self._4x}'] for r in self]
    2692			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
    2693			w, h = x2-x1, y2-y1
    2694			x1 -= w/20
    2695			x2 += w/20
    2696			y1 -= h/20
    2697			y2 += h/20
    2698			ppl.axis([x1, x2, y1, y2])
    2699		elif xylimits == 'free':
    2700			x1, x2, y1, y2 = ppl.axis()
    2701		else:
    2702			x1, x2, y1, y2 = ppl.axis(xylimits)
    2703
    2704		if error_contour_interval != 'none':
    2705			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
    2706			XI,YI = np.meshgrid(xi, yi)
    2707			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
    2708			if error_contour_interval == 'auto':
    2709				rng = np.max(SI) - np.min(SI)
    2710				if rng <= 0.01:
    2711					cinterval = 0.001
    2712				elif rng <= 0.03:
    2713					cinterval = 0.004
    2714				elif rng <= 0.1:
    2715					cinterval = 0.01
    2716				elif rng <= 0.3:
    2717					cinterval = 0.03
    2718				elif rng <= 1.:
    2719					cinterval = 0.1
    2720				else:
    2721					cinterval = 0.5
    2722			else:
    2723				cinterval = error_contour_interval
    2724
    2725			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
    2726			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
    2727			out.clabel = ppl.clabel(out.contour)
    2728
    2729		ppl.xlabel(x_label)
    2730		ppl.ylabel(y_label)
    2731		ppl.title(session, weight = 'bold')
    2732		ppl.grid(alpha = .2)
    2733		out.ax = ppl.gca()
    2734
    2735		return out

Generate plot for a single session

def plot_residuals( self, kde=False, hist=False, binwidth=0.6666666666666666, dir='output', filename=None, highlight=[], colors=None, figsize=None): View Source

    2737	def plot_residuals(
    2738		self,
    2739		kde = False,
    2740		hist = False,
    2741		binwidth = 2/3,
    2742		dir = 'output',
    2743		filename = None,
    2744		highlight = [],
    2745		colors = None,
    2746		figsize = None,
    2747		):
    2748		'''
    2749		Plot residuals of each analysis as a function of time (actually, as a function of
    2750		the order of analyses in the `D4xdata` object)
    2751
    2752		+ `kde`: whether to add a kernel density estimate of residuals
    2753		+ `hist`: whether to add a histogram of residuals (incompatible with `kde`)
    2754		+ `histbins`: specify bin edges for the histogram
    2755		+ `dir`: the directory in which to save the plot
    2756		+ `highlight`: a list of samples to highlight
    2757		+ `colors`: a dict of `{<sample>: <color>}` for all samples
    2758		+ `figsize`: (width, height) of figure
    2759		'''
    2760
    2761		from matplotlib import ticker
    2762
    2763		# Layout
    2764		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
    2765		if hist or kde:
    2766			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
    2767			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
    2768		else:
    2769			ppl.subplots_adjust(.08,.05,.78,.8)
    2770			ax1 = ppl.subplot(111)
    2771
    2772		# Colors
    2773		N = len(self.anchors)
    2774		if colors is None:
    2775			if len(highlight) > 0:
    2776				Nh = len(highlight)
    2777				if Nh == 1:
    2778					colors = {highlight[0]: (0,0,0)}
    2779				elif Nh == 3:
    2780					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
    2781				elif Nh == 4:
    2782					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    2783				else:
    2784					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
    2785			else:
    2786				if N == 3:
    2787					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
    2788				elif N == 4:
    2789					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    2790				else:
    2791					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
    2792
    2793		ppl.sca(ax1)
    2794
    2795		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
    2796
    2797		ax1.yaxis.set_major_formatter(ticker.FuncFormatter(lambda x, pos: f'${x:+.0f}$' if x else '$0$'))
    2798
    2799		session = self[0]['Session']
    2800		x1 = 0
    2801# 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
    2802		x_sessions = {}
    2803		one_or_more_singlets = False
    2804		one_or_more_multiplets = False
    2805		multiplets = set()
    2806		for k,r in enumerate(self):
    2807			if r['Session'] != session:
    2808				x2 = k-1
    2809				x_sessions[session] = (x1+x2)/2
    2810				ppl.axvline(k - 0.5, color = 'k', lw = .5)
    2811				session = r['Session']
    2812				x1 = k
    2813			singlet = len(self.samples[r['Sample']]['data']) == 1
    2814			if not singlet:
    2815				multiplets.add(r['Sample'])
    2816			if r['Sample'] in self.unknowns:
    2817				if singlet:
    2818					one_or_more_singlets = True
    2819				else:
    2820					one_or_more_multiplets = True
    2821			kw = dict(
    2822				marker = 'x' if singlet else '+',
    2823				ms = 4 if singlet else 5,
    2824				ls = 'None',
    2825				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
    2826				mew = 1,
    2827				alpha = 0.2 if singlet else 1,
    2828				)
    2829			if highlight and r['Sample'] not in highlight:
    2830				kw['alpha'] = 0.2
    2831			ppl.plot(k, 1e3 * r[f'D{self._4x}_residual'], **kw)
    2832		x2 = k
    2833		x_sessions[session] = (x1+x2)/2
    2834
    2835		ppl.axhspan(-self.repeatability[f'r_D{self._4x}']*1000, self.repeatability[f'r_D{self._4x}']*1000, color = 'k', alpha = .05, lw = 1)
    2836		ppl.axhspan(-self.repeatability[f'r_D{self._4x}']*1000*self.t95, self.repeatability[f'r_D{self._4x}']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
    2837		if not (hist or kde):
    2838			ppl.text(len(self), self.repeatability[f'r_D{self._4x}']*1000, f"   SD = {self.repeatability['r_D{self._4x}']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
    2839			ppl.text(len(self), self.repeatability[f'r_D{self._4x}']*1000*self.t95, f"   95% CL = ± {self.repeatability['r_D{self._4x}']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')
    2840
    2841		xmin, xmax, ymin, ymax = ppl.axis()
    2842		for s in x_sessions:
    2843			ppl.text(
    2844				x_sessions[s],
    2845				ymax +1,
    2846				s,
    2847				va = 'bottom',
    2848				**(
    2849					dict(ha = 'center')
    2850					if len(self.sessions[s]['data']) > (0.15 * len(self))
    2851					else dict(ha = 'left', rotation = 45)
    2852					)
    2853				)
    2854
    2855		if hist or kde:
    2856			ppl.sca(ax2)
    2857
    2858		for s in colors:
    2859			kw['marker'] = '+'
    2860			kw['ms'] = 5
    2861			kw['mec'] = colors[s]
    2862			kw['label'] = s
    2863			kw['alpha'] = 1
    2864			ppl.plot([], [], **kw)
    2865
    2866		kw['mec'] = (0,0,0)
    2867
    2868		if one_or_more_singlets:
    2869			kw['marker'] = 'x'
    2870			kw['ms'] = 4
    2871			kw['alpha'] = .2
    2872			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
    2873			ppl.plot([], [], **kw)
    2874
    2875		if one_or_more_multiplets:
    2876			kw['marker'] = '+'
    2877			kw['ms'] = 4
    2878			kw['alpha'] = 1
    2879			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
    2880			ppl.plot([], [], **kw)
    2881
    2882		if hist or kde:
    2883			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
    2884		else:
    2885			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
    2886		leg.set_zorder(-1000)
    2887
    2888		ppl.sca(ax1)
    2889
    2890		ppl.ylabel(f'Δ$_{{{self._4x}}}$ residuals (ppm)')
    2891		ppl.xticks([])
    2892		ppl.axis([-1, len(self), None, None])
    2893
    2894		if hist or kde:
    2895			ppl.sca(ax2)
    2896			X = 1e3 * np.array([r[f'D{self._4x}_residual'] for r in self if r['Sample'] in multiplets or r['Sample'] in self.anchors])
    2897
    2898			if kde:
    2899				from scipy.stats import gaussian_kde
    2900				yi = np.linspace(ymin, ymax, 201)
    2901				xi = gaussian_kde(X).evaluate(yi)
    2902				ppl.fill_betweenx(yi, xi, xi*0, fc = (0,0,0,.15), lw = 1, ec = (.75,.75,.75,1))
    2903# 				ppl.plot(xi, yi, 'k-', lw = 1)
    2904				ppl.axis([0, None, ymin, ymax])
    2905			elif hist:
    2906				ppl.hist(
    2907					X,
    2908					orientation = 'horizontal',
    2909					histtype = 'stepfilled',
    2910					ec = [.4]*3,
    2911					fc = [.25]*3,
    2912					alpha = .25,
    2913					bins = np.linspace(-9e3*self.repeatability[f'r_D{self._4x}'], 9e3*self.repeatability[f'r_D{self._4x}'], int(18/binwidth+1)),
    2914					)
    2915				ppl.axis([None, None, ymin, ymax])
    2916			ppl.text(0, 0,
    2917				f"   SD = {self.repeatability[f'r_D{self._4x}']*1000:.1f} ppm\n   95% CL = ± {self.repeatability[f'r_D{self._4x}']*1000*self.t95:.1f} ppm",
    2918				size = 7.5,
    2919				alpha = 1,
    2920				va = 'center',
    2921				ha = 'left',
    2922				)
    2923
    2924			ppl.xticks([])
    2925			ppl.yticks([])
    2926# 			ax2.spines['left'].set_visible(False)
    2927			ax2.spines['right'].set_visible(False)
    2928			ax2.spines['top'].set_visible(False)
    2929			ax2.spines['bottom'].set_visible(False)
    2930
    2931
    2932		if not os.path.exists(dir):
    2933			os.makedirs(dir)
    2934		if filename is None:
    2935			return fig
    2936		elif filename == '':
    2937			filename = f'D{self._4x}_residuals.pdf'
    2938		ppl.savefig(f'{dir}/{filename}')
    2939		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)

- `kde`: whether to add a kernel density estimate of residuals
- `hist`: whether to add a histogram of residuals (incompatible with `kde`)
- `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

    2942	def simulate(self, *args, **kwargs):
    2943		'''
    2944		Legacy function with warning message pointing to `virtual_data()`
    2945		'''
    2946		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

    2948	def plot_distribution_of_analyses(
    2949		self,
    2950		dir = 'output',
    2951		filename = None,
    2952		vs_time = False,
    2953		figsize = (6,4),
    2954		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
    2955		output = None,
    2956		):
    2957		'''
    2958		Plot temporal distribution of all analyses in the data set.
    2959
    2960		**Parameters**
    2961
    2962		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    2963		'''
    2964
    2965		asamples = [s for s in self.anchors]
    2966		usamples = [s for s in self.unknowns]
    2967		if output is None or output == 'fig':
    2968			fig = ppl.figure(figsize = figsize)
    2969			ppl.subplots_adjust(*subplots_adjust)
    2970		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    2971		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    2972		Xmax += (Xmax-Xmin)/40
    2973		Xmin -= (Xmax-Xmin)/41
    2974		for k, s in enumerate(asamples + usamples):
    2975			if vs_time:
    2976				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    2977			else:
    2978				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    2979			Y = [-k for x in X]
    2980			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
    2981			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    2982			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
    2983		ppl.axis([Xmin, Xmax, -k-1, 1])
    2984		ppl.xlabel('\ntime')
    2985		ppl.gca().annotate('',
    2986			xy = (0.6, -0.02),
    2987			xycoords = 'axes fraction',
    2988			xytext = (.4, -0.02),
    2989            arrowprops = dict(arrowstyle = "->", color = 'k'),
    2990            )
    2991
    2992
    2993		x2 = -1
    2994		for session in self.sessions:
    2995			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    2996			if vs_time:
    2997				ppl.axvline(x1, color = 'k', lw = .75)
    2998			if x2 > -1:
    2999				if not vs_time:
    3000					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
    3001			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    3002# 			from xlrd import xldate_as_datetime
    3003# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    3004			if vs_time:
    3005				ppl.axvline(x2, color = 'k', lw = .75)
    3006				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
    3007			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
    3008
    3009		ppl.xticks([])
    3010		ppl.yticks([])
    3011
    3012		if output is None:
    3013			if not os.path.exists(dir):
    3014				os.makedirs(dir)
    3015			if filename == None:
    3016				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    3017			ppl.savefig(f'{dir}/{filename}')
    3018			ppl.close(fig)
    3019		elif output == 'ax':
    3020			return ppl.gca()
    3021		elif output == 'fig':
    3022			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.

def plot_bulk_compositions( self, samples=None, dir='output/bulk_compositions', figsize=(6, 6), subplots_adjust=(0.15, 0.12, 0.95, 0.92), show=False, sample_color=(0, 0.5, 1), analysis_color=(0.7, 0.7, 0.7), labeldist=0.3, radius=0.05): View Source

    3025	def plot_bulk_compositions(
    3026		self,
    3027		samples = None,
    3028		dir = 'output/bulk_compositions',
    3029		figsize = (6,6),
    3030		subplots_adjust = (0.15, 0.12, 0.95, 0.92),
    3031		show = False,
    3032		sample_color = (0,.5,1),
    3033		analysis_color = (.7,.7,.7),
    3034		labeldist = 0.3,
    3035		radius = 0.05,
    3036		):
    3037		'''
    3038		Plot δ13C_VBDP vs δ18O_VSMOW (of CO2) for all analyses.
    3039
    3040		By default, creates a directory `./output/bulk_compositions` where plots for
    3041		each sample are saved. Another plot named `__all__.pdf` shows all analyses together.
    3042
    3043
    3044		**Parameters**
    3045
    3046		+ `samples`: Only these samples are processed (by default: all samples).
    3047		+ `dir`: where to save the plots
    3048		+ `figsize`: (width, height) of figure
    3049		+ `subplots_adjust`: passed to `subplots_adjust()`
    3050		+ `show`: whether to call `matplotlib.pyplot.show()` on the plot with all samples,
    3051		allowing for interactive visualization/exploration in (δ13C, δ18O) space.
    3052		+ `sample_color`: color used for replicate markers/labels
    3053		+ `analysis_color`: color used for sample markers/labels
    3054		+ `labeldist`: distance (in inches) from replicate markers to replicate labels
    3055		+ `radius`: radius of the dashed circle providing scale. No circle if `radius = 0`.
    3056		'''
    3057
    3058		from matplotlib.patches import Ellipse
    3059
    3060		if samples is None:
    3061			samples = [_ for _ in self.samples]
    3062
    3063		saved = {}
    3064
    3065		for s in samples:
    3066
    3067			fig = ppl.figure(figsize = figsize)
    3068			fig.subplots_adjust(*subplots_adjust)
    3069			ax = ppl.subplot(111)
    3070			ppl.xlabel('$δ^{18}O_{VSMOW}$ of $CO_2$ (‰)')
    3071			ppl.ylabel('$δ^{13}C_{VPDB}$ (‰)')
    3072			ppl.title(s)
    3073
    3074
    3075			XY = np.array([[_['d18O_VSMOW'], _['d13C_VPDB']] for _ in self.samples[s]['data']])
    3076			UID = [_['UID'] for _ in self.samples[s]['data']]
    3077			XY0 = XY.mean(0)
    3078
    3079			for xy in XY:
    3080				ppl.plot([xy[0], XY0[0]], [xy[1], XY0[1]], '-', lw = 1, color = analysis_color)
    3081
    3082			ppl.plot(*XY.T, 'wo', mew = 1, mec = analysis_color)
    3083			ppl.plot(*XY0, 'wo', mew = 2, mec = sample_color)
    3084			ppl.text(*XY0, f'  {s}', va = 'center', ha = 'left', color = sample_color, weight = 'bold')
    3085			saved[s] = [XY, XY0]
    3086
    3087			x1, x2, y1, y2 = ppl.axis()
    3088			x0, dx = (x1+x2)/2, (x2-x1)/2
    3089			y0, dy = (y1+y2)/2, (y2-y1)/2
    3090			dx, dy = [max(max(dx, dy), radius)]*2
    3091
    3092			ppl.axis([
    3093				x0 - 1.2*dx,
    3094				x0 + 1.2*dx,
    3095				y0 - 1.2*dy,
    3096				y0 + 1.2*dy,
    3097				])
    3098
    3099			XY0_in_display_space = fig.dpi_scale_trans.inverted().transform(ax.transData.transform(XY0))
    3100
    3101			for xy, uid in zip(XY, UID):
    3102
    3103				xy_in_display_space = fig.dpi_scale_trans.inverted().transform(ax.transData.transform(xy))
    3104				vector_in_display_space = xy_in_display_space - XY0_in_display_space
    3105
    3106				if (vector_in_display_space**2).sum() > 0:
    3107
    3108					unit_vector_in_display_space = vector_in_display_space / ((vector_in_display_space**2).sum())**0.5
    3109					label_vector_in_display_space = vector_in_display_space + unit_vector_in_display_space * labeldist
    3110					label_xy_in_display_space = XY0_in_display_space + label_vector_in_display_space
    3111					label_xy_in_data_space = ax.transData.inverted().transform(fig.dpi_scale_trans.transform(label_xy_in_display_space))
    3112
    3113					ppl.text(*label_xy_in_data_space, uid, va = 'center', ha = 'center', color = analysis_color)
    3114
    3115				else:
    3116
    3117					ppl.text(*xy, f'{uid}  ', va = 'center', ha = 'right', color = analysis_color)
    3118
    3119			if radius:
    3120				ax.add_artist(Ellipse(
    3121					xy = XY0,
    3122					width = radius*2,
    3123					height = radius*2,
    3124					ls = (0, (2,2)),
    3125					lw = .7,
    3126					ec = analysis_color,
    3127					fc = 'None',
    3128					))
    3129				ppl.text(
    3130					XY0[0],
    3131					XY0[1]-radius,
    3132					f'\n± {radius*1e3:.0f} ppm',
    3133					color = analysis_color,
    3134					va = 'top',
    3135					ha = 'center',
    3136					linespacing = 0.4,
    3137					size = 8,
    3138					)
    3139
    3140			if not os.path.exists(dir):
    3141				os.makedirs(dir)
    3142			fig.savefig(f'{dir}/{s}.pdf')
    3143			ppl.close(fig)
    3144
    3145		fig = ppl.figure(figsize = figsize)
    3146		fig.subplots_adjust(*subplots_adjust)
    3147		ppl.xlabel('$δ^{18}O_{VSMOW}$ of $CO_2$ (‰)')
    3148		ppl.ylabel('$δ^{13}C_{VPDB}$ (‰)')
    3149
    3150		for s in saved:
    3151			for xy in saved[s][0]:
    3152				ppl.plot([xy[0], saved[s][1][0]], [xy[1], saved[s][1][1]], '-', lw = 1, color = analysis_color)
    3153			ppl.plot(*saved[s][0].T, 'wo', mew = 1, mec = analysis_color)
    3154			ppl.plot(*saved[s][1], 'wo', mew = 1.5, mec = sample_color)
    3155			ppl.text(*saved[s][1], f'  {s}', va = 'center', ha = 'left', color = sample_color, weight = 'bold')
    3156
    3157		x1, x2, y1, y2 = ppl.axis()
    3158		ppl.axis([
    3159			x1 - (x2-x1)/10,
    3160			x2 + (x2-x1)/10,
    3161			y1 - (y2-y1)/10,
    3162			y2 + (y2-y1)/10,
    3163			])
    3164
    3165
    3166		if not os.path.exists(dir):
    3167			os.makedirs(dir)
    3168		fig.savefig(f'{dir}/__all__.pdf')
    3169		if show:
    3170			ppl.show()
    3171		ppl.close(fig)

Plot δ13C_VBDP vs δ18OVSMOW (of CO2) for all analyses.

By default, creates a directory `./output/bulk_compositions` where plots for each sample are saved. Another plot named `__all__.pdf` shows all analyses together.

Parameters

- `samples`: Only these samples are processed (by default: all samples).
- `dir`: where to save the plots
- `figsize`: (width, height) of figure
- `subplots_adjust`: passed to `subplots_adjust()`
- `show`: whether to call `matplotlib.pyplot.show()` on the plot with all samples, allowing for interactive visualization/exploration in (δ13C, δ18O) space.
- `sample_color`: color used for replicate markers/labels
- `analysis_color`: color used for sample markers/labels
- `labeldist`: distance (in inches) from replicate markers to replicate labels
- `radius`: radius of the dashed circle providing scale. No circle if `radius = 0`.

##### Inherited Members

builtins.list
  clear
  copy
  append
  insert
  extend
  pop
  remove
  index
  count
  reverse
  sort

class D47data(D4xdata): View Source

    3175class D47data(D4xdata):
    3176	'''
    3177	Store and process data for a large set of Δ47 analyses,
    3178	usually comprising more than one analytical session.
    3179	'''
    3180
    3181	Nominal_D4x = {
    3182		'ETH-1':   0.2052,
    3183		'ETH-2':   0.2085,
    3184		'ETH-3':   0.6132,
    3185		'ETH-4':   0.4511,
    3186		'IAEA-C1': 0.3018,
    3187		'IAEA-C2': 0.6409,
    3188		'MERCK':   0.5135,
    3189		} # I-CDES (Bernasconi et al., 2021)
    3190	'''
    3191	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
    3192	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
    3193	reference frame.
    3194
    3195	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
    3196	```py
    3197	{
    3198		'ETH-1'   : 0.2052,
    3199		'ETH-2'   : 0.2085,
    3200		'ETH-3'   : 0.6132,
    3201		'ETH-4'   : 0.4511,
    3202		'IAEA-C1' : 0.3018,
    3203		'IAEA-C2' : 0.6409,
    3204		'MERCK'   : 0.5135,
    3205	}
    3206	```
    3207	'''
    3208
    3209
    3210	@property
    3211	def Nominal_D47(self):
    3212		return self.Nominal_D4x
    3213
    3214
    3215	@Nominal_D47.setter
    3216	def Nominal_D47(self, new):
    3217		self.Nominal_D4x = dict(**new)
    3218		self.refresh()
    3219
    3220
    3221	def __init__(self, l = [], **kwargs):
    3222		'''
    3223		**Parameters:** same as `D4xdata.__init__()`
    3224		'''
    3225		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    3226
    3227
    3228	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    3229		'''
    3230		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    3231		value for that temperature, and add treat these samples as additional anchors.
    3232
    3233		**Parameters**
    3234
    3235		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    3236		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    3237		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    3238		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    3239		if `new`: keep pre-existing anchors but update them in case of conflict
    3240		between old and new Δ47 values;
    3241		if `old`: keep pre-existing anchors but preserve their original Δ47
    3242		values in case of conflict.
    3243		'''
    3244		f = {
    3245			'petersen': fCO2eqD47_Petersen,
    3246			'wang': fCO2eqD47_Wang,
    3247			}[fCo2eqD47]
    3248		foo = {}
    3249		for r in self:
    3250			if 'Teq' in r:
    3251				if r['Sample'] in foo:
    3252					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    3253				else:
    3254					foo[r['Sample']] = f(r['Teq'])
    3255			else:
    3256					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    3257
    3258		if priority == 'replace':
    3259			self.Nominal_D47 = {}
    3260		for s in foo:
    3261			if priority != 'old' or s not in self.Nominal_D47:
    3262				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

    3221	def __init__(self, l = [], **kwargs):
    3222		'''
    3223		**Parameters:** same as `D4xdata.__init__()`
    3224		'''
    3225		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

    3228	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    3229		'''
    3230		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    3231		value for that temperature, and add treat these samples as additional anchors.
    3232
    3233		**Parameters**
    3234
    3235		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    3236		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    3237		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    3238		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    3239		if `new`: keep pre-existing anchors but update them in case of conflict
    3240		between old and new Δ47 values;
    3241		if `old`: keep pre-existing anchors but preserve their original Δ47
    3242		values in case of conflict.
    3243		'''
    3244		f = {
    3245			'petersen': fCO2eqD47_Petersen,
    3246			'wang': fCO2eqD47_Wang,
    3247			}[fCo2eqD47]
    3248		foo = {}
    3249		for r in self:
    3250			if 'Teq' in r:
    3251				if r['Sample'] in foo:
    3252					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    3253				else:
    3254					foo[r['Sample']] = f(r['Teq'])
    3255			else:
    3256					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    3257
    3258		if priority == 'replace':
    3259			self.Nominal_D47 = {}
    3260		for s in foo:
    3261			if priority != 'old' or s not in self.Nominal_D47:
    3262				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
  plot_bulk_compositions
builtins.list
  clear
  copy
  append
  insert
  extend
  pop
  remove
  index
  count
  reverse
  sort

class D48data(D4xdata): View Source

    3267class D48data(D4xdata):
    3268	'''
    3269	Store and process data for a large set of Δ48 analyses,
    3270	usually comprising more than one analytical session.
    3271	'''
    3272
    3273	Nominal_D4x = {
    3274		'ETH-1':  0.138,
    3275		'ETH-2':  0.138,
    3276		'ETH-3':  0.270,
    3277		'ETH-4':  0.223,
    3278		'GU-1':  -0.419,
    3279		} # (Fiebig et al., 2019, 2021)
    3280	'''
    3281	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
    3282	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
    3283	reference frame.
    3284
    3285	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
    3286	Fiebig et al. (in press)):
    3287
    3288	```py
    3289	{
    3290		'ETH-1' :  0.138,
    3291		'ETH-2' :  0.138,
    3292		'ETH-3' :  0.270,
    3293		'ETH-4' :  0.223,
    3294		'GU-1'  : -0.419,
    3295	}
    3296	```
    3297	'''
    3298
    3299
    3300	@property
    3301	def Nominal_D48(self):
    3302		return self.Nominal_D4x
    3303
    3304
    3305	@Nominal_D48.setter
    3306	def Nominal_D48(self, new):
    3307		self.Nominal_D4x = dict(**new)
    3308		self.refresh()
    3309
    3310
    3311	def __init__(self, l = [], **kwargs):
    3312		'''
    3313		**Parameters:** same as `D4xdata.__init__()`
    3314		'''
    3315		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

    3311	def __init__(self, l = [], **kwargs):
    3312		'''
    3313		**Parameters:** same as `D4xdata.__init__()`
    3314		'''
    3315		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
  plot_bulk_compositions
builtins.list
  clear
  copy
  append
  insert
  extend
  pop
  remove
  index
  count
  reverse
  sort