D47crunch API documentation

# D47crunch

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

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

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

# 1. Tutorial

## 1.1 Installation

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

    python -m pip install D47crunch

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

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

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

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

## 1.2 Usage

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

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

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

    import D47crunch
    mydata = D47crunch.D47data()

For now, this object is empty:

    >>> print(mydata)
    []

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

    mydata.read('rawdata.csv')

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

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

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

We can now print a summary of the data processing:

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

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

To see the actual results:

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

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

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

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

# 2. How-to

## 2.1 Simulate a virtual data set to play with

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

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

    from D47crunch import *

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

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

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

    D.crunch()
    D.standardize()

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

## 2.2 Control data quality

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

    from D47crunch import *
    from random import shuffle

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

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

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

    # create D47data instance:
    data47 = D47data(data)

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

### 2.2.1 Plotting the distribution of analyses through time

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

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

### 2.2.2 Generating session plots

    data47.plot_sessions()

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

### 2.2.3 Plotting Δ47 or Δ48 residuals

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

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

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

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

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

17O correction parameters are defined by:

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

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

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

    from D47crunch import D4xdata, D47data

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

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

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

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

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

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

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

    from D47crunch import D47data

    # first create D47data object:
    mydata = D47data()

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

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

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

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

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

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

    from D47crunch import D47data

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

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

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

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

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

## 2.4 Process paired Δ47 and Δ48 values

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

    from D47crunch import *

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

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

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

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

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

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

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

Expected output:

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

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

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

## API Documentation

View Source

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

def fCO2eqD47_Petersen(T): View Source

    63def fCO2eqD47_Petersen(T):
    64	'''
    65	CO2 equilibrium Δ47 value as a function of T (in degrees C)
    66	according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).
    67
    68	'''
    69	return float(_fCO2eqD47_Petersen(T))

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

def fCO2eqD47_Wang(T): View Source

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

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

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

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

Compute covariance-aware linear combinations

Parameters

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

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

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

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

Formats a list of lists of strings as a CSV

Parameters

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

Example

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

outputs:

    a,b,c
    d,e,f

def pf(txt): View Source

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

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

def smart_type(x): View Source

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

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

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

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

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

Parameters

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

Example

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

yields:

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

def transpose_table(x): View Source

    195def transpose_table(x):
    196	'''
    197	Transpose a list if lists
    198
    199	**Parameters**
    200
    201	+ `x`: a list of lists
    202
    203	**Example**
    204
    205	```py
    206	x = [[1, 2], [3, 4]]
    207	print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
    208	```
    209	'''
    210	return [[e for e in c] for c in zip(*x)]

Transpose a list if lists

Parameters

- `x`: a list of lists

Example

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

def w_avg(X, sX): View Source

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

Compute variance-weighted average

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

Parameters

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

Tip

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

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

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

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

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

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

Parameters

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

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

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

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

Parameters

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

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

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

    412def virtual_data(
    413	samples = [],
    414	a47 = 1., b47 = 0., c47 = -0.9,
    415	a48 = 1., b48 = 0., c48 = -0.45,
    416	rD47 = 0.015, rD48 = 0.045,
    417	d13Cwg_VPDB = None, d18Owg_VSMOW = None,
    418	session = None,
    419	Nominal_D47 = None, Nominal_D48 = None,
    420	Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
    421	ALPHA_18O_ACID_REACTION = None,
    422	R13_VPDB = None,
    423	R17_VSMOW = None,
    424	R18_VSMOW = None,
    425	LAMBDA_17 = None,
    426	R18_VPDB = None,
    427	seed = 0,
    428	):
    429	'''
    430	Return list with simulated analyses from a single session.
    431
    432	**Parameters**
    433
    434	+ `samples`: a list of entries; each entry is a dictionary with the following fields:
    435	    * `Sample`: the name of the sample
    436	    * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
    437	    * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
    438	    * `N`: how many analyses to generate for this sample
    439	+ `a47`: scrambling factor for Δ47
    440	+ `b47`: compositional nonlinearity for Δ47
    441	+ `c47`: working gas offset for Δ47
    442	+ `a48`: scrambling factor for Δ48
    443	+ `b48`: compositional nonlinearity for Δ48
    444	+ `c48`: working gas offset for Δ48
    445	+ `rD47`: analytical repeatability of Δ47
    446	+ `rD48`: analytical repeatability of Δ48
    447	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
    448		(by default equal to the `simulate_single_analysis` default values)
    449	+ `session`: name of the session (no name by default)
    450	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
    451		if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
    452	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
    453		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
    454		(by default equal to the `simulate_single_analysis` defaults)
    455	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
    456		(by default equal to the `simulate_single_analysis` defaults)
    457	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
    458		correction parameters (by default equal to the `simulate_single_analysis` default)
    459	+ `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations
    460
    461
    462	Here is an example of using this method to generate an arbitrary combination of
    463	anchors and unknowns for a bunch of sessions:
    464
    465	```py
    466	args = dict(
    467		samples = [
    468			dict(Sample = 'ETH-1', N = 4),
    469			dict(Sample = 'ETH-2', N = 5),
    470			dict(Sample = 'ETH-3', N = 6),
    471			dict(Sample = 'FOO', N = 2,
    472				d13C_VPDB = -5., d18O_VPDB = -10.,
    473				D47 = 0.3, D48 = 0.15),
    474			], rD47 = 0.010, rD48 = 0.030)
    475
    476	session1 = virtual_data(session = 'Session_01', **args, seed = 123)
    477	session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
    478	session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
    479	session4 = virtual_data(session = 'Session_04', **args, seed = 123456)
    480
    481	D = D47data(session1 + session2 + session3 + session4)
    482
    483	D.crunch()
    484	D.standardize()
    485
    486	D.table_of_sessions(verbose = True, save_to_file = False)
    487	D.table_of_samples(verbose = True, save_to_file = False)
    488	D.table_of_analyses(verbose = True, save_to_file = False)
    489	```
    490
    491	This should output something like:
    492
    493	```
    494	[table_of_sessions]
    495	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    496	Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
    497	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    498	Session_01  15   2       -4.000        26.000  0.0000  0.0000  0.0110  0.997 ± 0.017  -0.097 ± 0.244  -0.896 ± 0.006
    499	Session_02  15   2       -4.000        26.000  0.0000  0.0000  0.0109  1.002 ± 0.017  -0.110 ± 0.244  -0.901 ± 0.006
    500	Session_03  15   2       -4.000        26.000  0.0000  0.0000  0.0107  1.010 ± 0.017  -0.037 ± 0.244  -0.904 ± 0.006
    501	Session_04  15   2       -4.000        26.000  0.0000  0.0000  0.0106  1.001 ± 0.017  -0.181 ± 0.244  -0.894 ± 0.006
    502	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    503
    504	[table_of_samples]
    505	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    506	Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
    507	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    508	ETH-1   16       2.02       37.02  0.2052                    0.0079
    509	ETH-2   20     -10.17       19.88  0.2085                    0.0100
    510	ETH-3   24       1.71       37.45  0.6132                    0.0105
    511	FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
    512	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    513
    514	[table_of_analyses]
    515	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    516	UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
    517	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    518	1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.122986   21.273526   27.780042    2.020000   37.024281  -0.706013  -0.328878  -0.000013  0.192554
    519	2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.130144   21.282615   27.780042    2.020000   37.024281  -0.698974  -0.319981  -0.000013  0.199615
    520	3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.149219   21.299572   27.780042    2.020000   37.024281  -0.680215  -0.303383  -0.000013  0.218429
    521	4    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.136616   21.233128   27.780042    2.020000   37.024281  -0.692609  -0.368421  -0.000013  0.205998
    522	5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.697171  -12.203054  -18.023381  -10.170000   19.875825  -0.680771  -0.290128  -0.000002  0.215054
    523	6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701124  -12.184422  -18.023381  -10.170000   19.875825  -0.684772  -0.271272  -0.000002  0.211041
    524	7    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715105  -12.195251  -18.023381  -10.170000   19.875825  -0.698923  -0.282232  -0.000002  0.196848
    525	8    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701529  -12.204963  -18.023381  -10.170000   19.875825  -0.685182  -0.292061  -0.000002  0.210630
    526	9    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.711420  -12.228478  -18.023381  -10.170000   19.875825  -0.695193  -0.315859  -0.000002  0.200589
    527	10   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.666719   22.296486   28.306614    1.710000   37.450394  -0.290459  -0.147284  -0.000014  0.609363
    528	11   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.671553   22.291060   28.306614    1.710000   37.450394  -0.285706  -0.152592  -0.000014  0.614130
    529	12   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.652854   22.273271   28.306614    1.710000   37.450394  -0.304093  -0.169990  -0.000014  0.595689
    530	13   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684168   22.263156   28.306614    1.710000   37.450394  -0.273302  -0.179883  -0.000014  0.626572
    531	14   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.662702   22.253578   28.306614    1.710000   37.450394  -0.294409  -0.189251  -0.000014  0.605401
    532	15   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.681957   22.230907   28.306614    1.710000   37.450394  -0.275476  -0.211424  -0.000014  0.624391
    533	16   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.312044    5.395798    4.665655   -5.000000   28.907344  -0.598436  -0.268176  -0.000006  0.298996
    534	17   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328123    5.307086    4.665655   -5.000000   28.907344  -0.582387  -0.356389  -0.000006  0.315092
    535	18   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.122201   21.340606   27.780042    2.020000   37.024281  -0.706785  -0.263217  -0.000013  0.195135
    536	19   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.134868   21.305714   27.780042    2.020000   37.024281  -0.694328  -0.297370  -0.000013  0.207564
    537	20   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140008   21.261931   27.780042    2.020000   37.024281  -0.689273  -0.340227  -0.000013  0.212607
    538	21   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.135540   21.298472   27.780042    2.020000   37.024281  -0.693667  -0.304459  -0.000013  0.208224
    539	22   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701213  -12.202602  -18.023381  -10.170000   19.875825  -0.684862  -0.289671  -0.000002  0.213842
    540	23   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.685649  -12.190405  -18.023381  -10.170000   19.875825  -0.669108  -0.277327  -0.000002  0.229559
    541	24   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.719003  -12.257955  -18.023381  -10.170000   19.875825  -0.702869  -0.345692  -0.000002  0.195876
    542	25   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700592  -12.204641  -18.023381  -10.170000   19.875825  -0.684233  -0.291735  -0.000002  0.214469
    543	26   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720426  -12.214561  -18.023381  -10.170000   19.875825  -0.704308  -0.301774  -0.000002  0.194439
    544	27   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.673044   22.262090   28.306614    1.710000   37.450394  -0.284240  -0.180926  -0.000014  0.616730
    545	28   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.666542   22.263401   28.306614    1.710000   37.450394  -0.290634  -0.179643  -0.000014  0.610350
    546	29   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.680487   22.243486   28.306614    1.710000   37.450394  -0.276921  -0.199121  -0.000014  0.624031
    547	30   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.663900   22.245175   28.306614    1.710000   37.450394  -0.293231  -0.197469  -0.000014  0.607759
    548	31   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.674379   22.301309   28.306614    1.710000   37.450394  -0.282927  -0.142568  -0.000014  0.618039
    549	32   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.660825   22.270466   28.306614    1.710000   37.450394  -0.296255  -0.172733  -0.000014  0.604742
    550	33   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.294076    5.349940    4.665655   -5.000000   28.907344  -0.616369  -0.313776  -0.000006  0.283707
    551	34   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.313775    5.292121    4.665655   -5.000000   28.907344  -0.596708  -0.371269  -0.000006  0.303323
    552	35   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.121613   21.259909   27.780042    2.020000   37.024281  -0.707364  -0.342207  -0.000013  0.194934
    553	36   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.145714   21.304889   27.780042    2.020000   37.024281  -0.683661  -0.298178  -0.000013  0.218401
    554	37   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.126573   21.325093   27.780042    2.020000   37.024281  -0.702485  -0.278401  -0.000013  0.199764
    555	38   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.132057   21.323211   27.780042    2.020000   37.024281  -0.697092  -0.280244  -0.000013  0.205104
    556	39   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.708448  -12.232023  -18.023381  -10.170000   19.875825  -0.692185  -0.319447  -0.000002  0.208915
    557	40   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.714417  -12.202504  -18.023381  -10.170000   19.875825  -0.698226  -0.289572  -0.000002  0.202934
    558	41   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720039  -12.264469  -18.023381  -10.170000   19.875825  -0.703917  -0.352285  -0.000002  0.197300
    559	42   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701953  -12.228550  -18.023381  -10.170000   19.875825  -0.685611  -0.315932  -0.000002  0.215423
    560	43   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.704535  -12.213634  -18.023381  -10.170000   19.875825  -0.688224  -0.300836  -0.000002  0.212837
    561	44   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.652920   22.230043   28.306614    1.710000   37.450394  -0.304028  -0.212269  -0.000014  0.594265
    562	45   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.691485   22.261017   28.306614    1.710000   37.450394  -0.266106  -0.181975  -0.000014  0.631810
    563	46   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.679119   22.305357   28.306614    1.710000   37.450394  -0.278266  -0.138609  -0.000014  0.619771
    564	47   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.663623   22.327286   28.306614    1.710000   37.450394  -0.293503  -0.117161  -0.000014  0.604685
    565	48   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.678524   22.282103   28.306614    1.710000   37.450394  -0.278851  -0.161352  -0.000014  0.619192
    566	49   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.666246   22.283361   28.306614    1.710000   37.450394  -0.290925  -0.160121  -0.000014  0.607238
    567	50   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.309929    5.340249    4.665655   -5.000000   28.907344  -0.600546  -0.323413  -0.000006  0.300148
    568	51   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.317548    5.334102    4.665655   -5.000000   28.907344  -0.592942  -0.329524  -0.000006  0.307676
    569	52   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.136865   21.300298   27.780042    2.020000   37.024281  -0.692364  -0.302672  -0.000013  0.204033
    570	53   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.133538   21.291260   27.780042    2.020000   37.024281  -0.695637  -0.311519  -0.000013  0.200762
    571	54   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.139991   21.319865   27.780042    2.020000   37.024281  -0.689290  -0.283519  -0.000013  0.207107
    572	55   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.145748   21.330075   27.780042    2.020000   37.024281  -0.683629  -0.273524  -0.000013  0.212766
    573	56   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702989  -12.202762  -18.023381  -10.170000   19.875825  -0.686660  -0.289833  -0.000002  0.204507
    574	57   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.692830  -12.240287  -18.023381  -10.170000   19.875825  -0.676377  -0.327811  -0.000002  0.214786
    575	58   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702899  -12.180291  -18.023381  -10.170000   19.875825  -0.686568  -0.267091  -0.000002  0.204598
    576	59   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709282  -12.282257  -18.023381  -10.170000   19.875825  -0.693029  -0.370287  -0.000002  0.198140
    577	60   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.679330  -12.235994  -18.023381  -10.170000   19.875825  -0.662712  -0.323466  -0.000002  0.228446
    578	61   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.695594   22.238663   28.306614    1.710000   37.450394  -0.262066  -0.203838  -0.000014  0.634200
    579	62   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.663504   22.286354   28.306614    1.710000   37.450394  -0.293620  -0.157194  -0.000014  0.602656
    580	63   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666457   22.254290   28.306614    1.710000   37.450394  -0.290717  -0.188555  -0.000014  0.605558
    581	64   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666910   22.223232   28.306614    1.710000   37.450394  -0.290271  -0.218930  -0.000014  0.606004
    582	65   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.679662   22.257256   28.306614    1.710000   37.450394  -0.277732  -0.185653  -0.000014  0.618539
    583	66   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.676768   22.267680   28.306614    1.710000   37.450394  -0.280578  -0.175459  -0.000014  0.615693
    584	67   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.307663    5.317330    4.665655   -5.000000   28.907344  -0.602808  -0.346202  -0.000006  0.290853
    585	68   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.308562    5.331400    4.665655   -5.000000   28.907344  -0.601911  -0.332212  -0.000006  0.291749
    586	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    587	```
    588	'''
    589
    590	kwargs = locals().copy()
    591
    592	from numpy import random as nprandom
    593	if seed:
    594		rng = nprandom.default_rng(seed)
    595	else:
    596		rng = nprandom.default_rng()
    597
    598	N = sum([s['N'] for s in samples])
    599	errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    600	errors47 *= rD47 / stdev(errors47) # scale errors to rD47
    601	errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    602	errors48 *= rD48 / stdev(errors48) # scale errors to rD48
    603
    604	k = 0
    605	out = []
    606	for s in samples:
    607		kw = {}
    608		kw['sample'] = s['Sample']
    609		kw = {
    610			**kw,
    611			**{var: kwargs[var]
    612				for var in [
    613					'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
    614					'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
    615					'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
    616					'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
    617					]
    618				if kwargs[var] is not None},
    619			**{var: s[var]
    620				for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
    621				if var in s},
    622			}
    623
    624		sN = s['N']
    625		while sN:
    626			out.append(simulate_single_analysis(**kw))
    627			out[-1]['d47'] += errors47[k] * a47
    628			out[-1]['d48'] += errors48[k] * a48
    629			sN -= 1
    630			k += 1
    631
    632		if session is not None:
    633			for r in out:
    634				r['Session'] = session
    635	return out

Return list with simulated analyses from a single session.

Parameters

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

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

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

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

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

    D.crunch()
    D.standardize()

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

This should output something like:

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

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

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

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

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

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

Parameters

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

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

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

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

Parameters

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

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

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

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

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

Parameters

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

class D4xdata(builtins.list): View Source

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

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

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

    972	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
    973		'''
    974		**Parameters**
    975
    976		+ `l`: a list of dictionaries, with each dictionary including at least the keys
    977		`Sample`, `d45`, `d46`, and `d47` or `d48`.
    978		+ `mass`: `'47'` or `'48'`
    979		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
    980		+ `session`: define session name for analyses without a `Session` key
    981		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
    982
    983		Returns a `D4xdata` object derived from `list`.
    984		'''
    985		self._4x = mass
    986		self.verbose = verbose
    987		self.prefix = 'D4xdata'
    988		self.logfile = logfile
    989		list.__init__(self, l)
    990		self.Nf = None
    991		self.repeatability = {}
    992		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

     995	def make_verbal(oldfun):
     996		'''
     997		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
     998		'''
     999		@wraps(oldfun)
    1000		def newfun(*args, verbose = '', **kwargs):
    1001			myself = args[0]
    1002			oldprefix = myself.prefix
    1003			myself.prefix = oldfun.__name__
    1004			if verbose != '':
    1005				oldverbose = myself.verbose
    1006				myself.verbose = verbose
    1007			out = oldfun(*args, **kwargs)
    1008			myself.prefix = oldprefix
    1009			if verbose != '':
    1010				myself.verbose = oldverbose
    1011			return out
    1012		return newfun

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

def msg(self, txt): View Source

    1015	def msg(self, txt):
    1016		'''
    1017		Log a message to `self.logfile`, and print it out if `verbose = True`
    1018		'''
    1019		self.log(txt)
    1020		if self.verbose:
    1021			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

    1024	def vmsg(self, txt):
    1025		'''
    1026		Log a message to `self.logfile` and print it out
    1027		'''
    1028		self.log(txt)
    1029		print(txt)

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

def log(self, *txts): View Source

    1032	def log(self, *txts):
    1033		'''
    1034		Log a message to `self.logfile`
    1035		'''
    1036		if self.logfile:
    1037			with open(self.logfile, 'a') as fid:
    1038				for txt in txts:
    1039					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

    1042	def refresh(self, session = 'mySession'):
    1043		'''
    1044		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    1045		'''
    1046		self.fill_in_missing_info(session = session)
    1047		self.refresh_sessions()
    1048		self.refresh_samples()

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

def refresh_sessions(self): View Source

    1051	def refresh_sessions(self):
    1052		'''
    1053		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    1054		to `False` for all sessions.
    1055		'''
    1056		self.sessions = {
    1057			s: {'data': [r for r in self if r['Session'] == s]}
    1058			for s in sorted({r['Session'] for r in self})
    1059			}
    1060		for s in self.sessions:
    1061			self.sessions[s]['scrambling_drift'] = False
    1062			self.sessions[s]['slope_drift'] = False
    1063			self.sessions[s]['wg_drift'] = False
    1064			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    1065			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

    1068	def refresh_samples(self):
    1069		'''
    1070		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    1071		'''
    1072		self.samples = {
    1073			s: {'data': [r for r in self if r['Sample'] == s]}
    1074			for s in sorted({r['Sample'] for r in self})
    1075			}
    1076		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    1077		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

    1080	def read(self, filename, sep = '', session = ''):
    1081		'''
    1082		Read file in csv format to load data into a `D47data` object.
    1083
    1084		In the csv file, spaces before and after field separators (`','` by default)
    1085		are optional. Each line corresponds to a single analysis.
    1086
    1087		The required fields are:
    1088
    1089		+ `UID`: a unique identifier
    1090		+ `Session`: an identifier for the analytical session
    1091		+ `Sample`: a sample identifier
    1092		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    1093
    1094		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    1095		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    1096		and `d49` are optional, and set to NaN by default.
    1097
    1098		**Parameters**
    1099
    1100		+ `fileneme`: the path of the file to read
    1101		+ `sep`: csv separator delimiting the fields
    1102		+ `session`: set `Session` field to this string for all analyses
    1103		'''
    1104		with open(filename) as fid:
    1105			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

    1108	def input(self, txt, sep = '', session = ''):
    1109		'''
    1110		Read `txt` string in csv format to load analysis data into a `D47data` object.
    1111
    1112		In the csv string, spaces before and after field separators (`','` by default)
    1113		are optional. Each line corresponds to a single analysis.
    1114
    1115		The required fields are:
    1116
    1117		+ `UID`: a unique identifier
    1118		+ `Session`: an identifier for the analytical session
    1119		+ `Sample`: a sample identifier
    1120		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    1121
    1122		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    1123		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    1124		and `d49` are optional, and set to NaN by default.
    1125
    1126		**Parameters**
    1127
    1128		+ `txt`: the csv string to read
    1129		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    1130		whichever appers most often in `txt`.
    1131		+ `session`: set `Session` field to this string for all analyses
    1132		'''
    1133		if sep == '':
    1134			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    1135		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    1136		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:]]
    1137
    1138		if session != '':
    1139			for r in data:
    1140				r['Session'] = session
    1141
    1142		self += data
    1143		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

    1146	@make_verbal
    1147	def wg(self, samples = None, a18_acid = None):
    1148		'''
    1149		Compute bulk composition of the working gas for each session based on
    1150		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
    1151		`self.Nominal_d18O_VPDB`.
    1152		'''
    1153
    1154		self.msg('Computing WG composition:')
    1155
    1156		if a18_acid is None:
    1157			a18_acid = self.ALPHA_18O_ACID_REACTION
    1158		if samples is None:
    1159			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
    1160
    1161		assert a18_acid, f'Acid fractionation factor should not be zero.'
    1162
    1163		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
    1164		R45R46_standards = {}
    1165		for sample in samples:
    1166			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
    1167			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
    1168			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
    1169			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
    1170			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
    1171
    1172			C12_s = 1 / (1 + R13_s)
    1173			C13_s = R13_s / (1 + R13_s)
    1174			C16_s = 1 / (1 + R17_s + R18_s)
    1175			C17_s = R17_s / (1 + R17_s + R18_s)
    1176			C18_s = R18_s / (1 + R17_s + R18_s)
    1177
    1178			C626_s = C12_s * C16_s ** 2
    1179			C627_s = 2 * C12_s * C16_s * C17_s
    1180			C628_s = 2 * C12_s * C16_s * C18_s
    1181			C636_s = C13_s * C16_s ** 2
    1182			C637_s = 2 * C13_s * C16_s * C17_s
    1183			C727_s = C12_s * C17_s ** 2
    1184
    1185			R45_s = (C627_s + C636_s) / C626_s
    1186			R46_s = (C628_s + C637_s + C727_s) / C626_s
    1187			R45R46_standards[sample] = (R45_s, R46_s)
    1188
    1189		for s in self.sessions:
    1190			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
    1191			assert db, f'No sample from {samples} found in session "{s}".'
    1192# 			dbsamples = sorted({r['Sample'] for r in db})
    1193
    1194			X = [r['d45'] for r in db]
    1195			Y = [R45R46_standards[r['Sample']][0] for r in db]
    1196			x1, x2 = np.min(X), np.max(X)
    1197
    1198			if x1 < x2:
    1199				wgcoord = x1/(x1-x2)
    1200			else:
    1201				wgcoord = 999
    1202
    1203			if wgcoord < -.5 or wgcoord > 1.5:
    1204				# unreasonable to extrapolate to d45 = 0
    1205				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    1206			else :
    1207				# d45 = 0 is reasonably well bracketed
    1208				R45_wg = np.polyfit(X, Y, 1)[1]
    1209
    1210			X = [r['d46'] for r in db]
    1211			Y = [R45R46_standards[r['Sample']][1] for r in db]
    1212			x1, x2 = np.min(X), np.max(X)
    1213
    1214			if x1 < x2:
    1215				wgcoord = x1/(x1-x2)
    1216			else:
    1217				wgcoord = 999
    1218
    1219			if wgcoord < -.5 or wgcoord > 1.5:
    1220				# unreasonable to extrapolate to d46 = 0
    1221				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    1222			else :
    1223				# d46 = 0 is reasonably well bracketed
    1224				R46_wg = np.polyfit(X, Y, 1)[1]
    1225
    1226			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
    1227
    1228			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
    1229
    1230			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
    1231			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
    1232			for r in self.sessions[s]['data']:
    1233				r['d13Cwg_VPDB'] = d13Cwg_VPDB
    1234				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

    1237	def compute_bulk_delta(self, R45, R46, D17O = 0):
    1238		'''
    1239		Compute δ13C_VPDB and δ18O_VSMOW,
    1240		by solving the generalized form of equation (17) from
    1241		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
    1242		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
    1243		solving the corresponding second-order Taylor polynomial.
    1244		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
    1245		'''
    1246
    1247		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
    1248
    1249		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
    1250		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
    1251		C = 2 * self.R18_VSMOW
    1252		D = -R46
    1253
    1254		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
    1255		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
    1256		cc = A + B + C + D
    1257
    1258		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
    1259
    1260		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
    1261		R17 = K * R18 ** self.LAMBDA_17
    1262		R13 = R45 - 2 * R17
    1263
    1264		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
    1265
    1266		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

    1269	@make_verbal
    1270	def crunch(self, verbose = ''):
    1271		'''
    1272		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    1273		'''
    1274		for r in self:
    1275			self.compute_bulk_and_clumping_deltas(r)
    1276		self.standardize_d13C()
    1277		self.standardize_d18O()
    1278		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

    1281	def fill_in_missing_info(self, session = 'mySession'):
    1282		'''
    1283		Fill in optional fields with default values
    1284		'''
    1285		for i,r in enumerate(self):
    1286			if 'D17O' not in r:
    1287				r['D17O'] = 0.
    1288			if 'UID' not in r:
    1289				r['UID'] = f'{i+1}'
    1290			if 'Session' not in r:
    1291				r['Session'] = session
    1292			for k in ['d47', 'd48', 'd49']:
    1293				if k not in r:
    1294					r[k] = np.nan

Fill in optional fields with default values

def standardize_d13C(self): View Source

    1297	def standardize_d13C(self):
    1298		'''
    1299		Perform δ13C standadization within each session `s` according to
    1300		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
    1301		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
    1302		may be redefined abitrarily at a later stage.
    1303		'''
    1304		for s in self.sessions:
    1305			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
    1306				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]
    1307				X,Y = zip(*XY)
    1308				if self.sessions[s]['d13C_standardization_method'] == '1pt':
    1309					offset = np.mean(Y) - np.mean(X)
    1310					for r in self.sessions[s]['data']:
    1311						r['d13C_VPDB'] += offset
    1312				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
    1313					a,b = np.polyfit(X,Y,1)
    1314					for r in self.sessions[s]['data']:
    1315						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

    1317	def standardize_d18O(self):
    1318		'''
    1319		Perform δ18O standadization within each session `s` according to
    1320		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
    1321		which is defined by default by `D47data.refresh_sessions()`as equal to
    1322		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
    1323		'''
    1324		for s in self.sessions:
    1325			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
    1326				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]
    1327				X,Y = zip(*XY)
    1328				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
    1329				if self.sessions[s]['d18O_standardization_method'] == '1pt':
    1330					offset = np.mean(Y) - np.mean(X)
    1331					for r in self.sessions[s]['data']:
    1332						r['d18O_VSMOW'] += offset
    1333				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
    1334					a,b = np.polyfit(X,Y,1)
    1335					for r in self.sessions[s]['data']:
    1336						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

    1339	def compute_bulk_and_clumping_deltas(self, r):
    1340		'''
    1341		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
    1342		'''
    1343
    1344		# Compute working gas R13, R18, and isobar ratios
    1345		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
    1346		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
    1347		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
    1348
    1349		# Compute analyte isobar ratios
    1350		R45 = (1 + r['d45'] / 1000) * R45_wg
    1351		R46 = (1 + r['d46'] / 1000) * R46_wg
    1352		R47 = (1 + r['d47'] / 1000) * R47_wg
    1353		R48 = (1 + r['d48'] / 1000) * R48_wg
    1354		R49 = (1 + r['d49'] / 1000) * R49_wg
    1355
    1356		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
    1357		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
    1358		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
    1359
    1360		# Compute stochastic isobar ratios of the analyte
    1361		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
    1362			R13, R18, D17O = r['D17O']
    1363		)
    1364
    1365		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
    1366		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
    1367		if (R45 / R45stoch - 1) > 5e-8:
    1368			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
    1369		if (R46 / R46stoch - 1) > 5e-8:
    1370			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
    1371
    1372		# Compute raw clumped isotope anomalies
    1373		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
    1374		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
    1375		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

    1378	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
    1379		'''
    1380		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
    1381		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
    1382		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
    1383		'''
    1384
    1385		# Compute R17
    1386		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
    1387
    1388		# Compute isotope concentrations
    1389		C12 = (1 + R13) ** -1
    1390		C13 = C12 * R13
    1391		C16 = (1 + R17 + R18) ** -1
    1392		C17 = C16 * R17
    1393		C18 = C16 * R18
    1394
    1395		# Compute stochastic isotopologue concentrations
    1396		C626 = C16 * C12 * C16
    1397		C627 = C16 * C12 * C17 * 2
    1398		C628 = C16 * C12 * C18 * 2
    1399		C636 = C16 * C13 * C16
    1400		C637 = C16 * C13 * C17 * 2
    1401		C638 = C16 * C13 * C18 * 2
    1402		C727 = C17 * C12 * C17
    1403		C728 = C17 * C12 * C18 * 2
    1404		C737 = C17 * C13 * C17
    1405		C738 = C17 * C13 * C18 * 2
    1406		C828 = C18 * C12 * C18
    1407		C838 = C18 * C13 * C18
    1408
    1409		# Compute stochastic isobar ratios
    1410		R45 = (C636 + C627) / C626
    1411		R46 = (C628 + C637 + C727) / C626
    1412		R47 = (C638 + C728 + C737) / C626
    1413		R48 = (C738 + C828) / C626
    1414		R49 = C838 / C626
    1415
    1416		# Account for stochastic anomalies
    1417		R47 *= 1 + D47 / 1000
    1418		R48 *= 1 + D48 / 1000
    1419		R49 *= 1 + D49 / 1000
    1420
    1421		# Return isobar ratios
    1422		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

    1425	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
    1426		'''
    1427		Split unknown samples by UID (treat all analyses as different samples)
    1428		or by session (treat analyses of a given sample in different sessions as
    1429		different samples).
    1430
    1431		**Parameters**
    1432
    1433		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
    1434		+ `grouping`: `by_uid` | `by_session`
    1435		'''
    1436		if samples_to_split == 'all':
    1437			samples_to_split = [s for s in self.unknowns]
    1438		gkeys = {'by_uid':'UID', 'by_session':'Session'}
    1439		self.grouping = grouping.lower()
    1440		if self.grouping in gkeys:
    1441			gkey = gkeys[self.grouping]
    1442		for r in self:
    1443			if r['Sample'] in samples_to_split:
    1444				r['Sample_original'] = r['Sample']
    1445				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
    1446			elif r['Sample'] in self.unknowns:
    1447				r['Sample_original'] = r['Sample']
    1448		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

    1451	def unsplit_samples(self, tables = False):
    1452		'''
    1453		Reverse the effects of `D47data.split_samples()`.
    1454
    1455		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
    1456
    1457		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
    1458		probably use `D4xdata.combine_samples()` instead to reverse the effects of
    1459		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
    1460		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
    1461		that case session-averaged Δ4x values are statistically independent).
    1462		'''
    1463		unknowns_old = sorted({s for s in self.unknowns})
    1464		CM_old = self.standardization.covar[:,:]
    1465		VD_old = self.standardization.params.valuesdict().copy()
    1466		vars_old = self.standardization.var_names
    1467
    1468		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
    1469
    1470		Ns = len(vars_old) - len(unknowns_old)
    1471		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
    1472		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
    1473
    1474		W = np.zeros((len(vars_new), len(vars_old)))
    1475		W[:Ns,:Ns] = np.eye(Ns)
    1476		for u in unknowns_new:
    1477			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
    1478			if self.grouping == 'by_session':
    1479				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
    1480			elif self.grouping == 'by_uid':
    1481				weights = [1 for s in splits]
    1482			sw = sum(weights)
    1483			weights = [w/sw for w in weights]
    1484			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
    1485
    1486		CM_new = W @ CM_old @ W.T
    1487		V = W @ np.array([[VD_old[k]] for k in vars_old])
    1488		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
    1489
    1490		self.standardization.covar = CM_new
    1491		self.standardization.params.valuesdict = lambda : VD_new
    1492		self.standardization.var_names = vars_new
    1493
    1494		for r in self:
    1495			if r['Sample'] in self.unknowns:
    1496				r['Sample_split'] = r['Sample']
    1497				r['Sample'] = r['Sample_original']
    1498
    1499		self.refresh_samples()
    1500		self.consolidate_samples()
    1501		self.repeatabilities()
    1502
    1503		if tables:
    1504			self.table_of_analyses()
    1505			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

    1507	def assign_timestamps(self):
    1508		'''
    1509		Assign a time field `t` of type `float` to each analysis.
    1510
    1511		If `TimeTag` is one of the data fields, `t` is equal within a given session
    1512		to `TimeTag` minus the mean value of `TimeTag` for that session.
    1513		Otherwise, `TimeTag` is by default equal to the index of each analysis
    1514		in the dataset and `t` is defined as above.
    1515		'''
    1516		for session in self.sessions:
    1517			sdata = self.sessions[session]['data']
    1518			try:
    1519				t0 = np.mean([r['TimeTag'] for r in sdata])
    1520				for r in sdata:
    1521					r['t'] = r['TimeTag'] - t0
    1522			except KeyError:
    1523				t0 = (len(sdata)-1)/2
    1524				for t,r in enumerate(sdata):
    1525					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

    1528	def report(self):
    1529		'''
    1530		Prints a report on the standardization fit.
    1531		Only applicable after `D4xdata.standardize(method='pooled')`.
    1532		'''
    1533		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

    1536	def combine_samples(self, sample_groups):
    1537		'''
    1538		Combine analyses of different samples to compute weighted average Δ4x
    1539		and new error (co)variances corresponding to the groups defined by the `sample_groups`
    1540		dictionary.
    1541
    1542		Caution: samples are weighted by number of replicate analyses, which is a
    1543		reasonable default behavior but is not always optimal (e.g., in the case of strongly
    1544		correlated analytical errors for one or more samples).
    1545
    1546		Returns a tuplet of:
    1547
    1548		+ the list of group names
    1549		+ an array of the corresponding Δ4x values
    1550		+ the corresponding (co)variance matrix
    1551
    1552		**Parameters**
    1553
    1554		+ `sample_groups`: a dictionary of the form:
    1555		```py
    1556		{'group1': ['sample_1', 'sample_2'],
    1557		 'group2': ['sample_3', 'sample_4', 'sample_5']}
    1558		```
    1559		'''
    1560
    1561		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
    1562		groups = sorted(sample_groups.keys())
    1563		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
    1564		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
    1565		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
    1566		W = np.array([
    1567			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
    1568			for j in groups])
    1569		D4x_new = W @ D4x_old
    1570		CM_new = W @ CM_old @ W.T
    1571
    1572		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

    1575	@make_verbal
    1576	def standardize(self,
    1577		method = 'pooled',
    1578		weighted_sessions = [],
    1579		consolidate = True,
    1580		consolidate_tables = False,
    1581		consolidate_plots = False,
    1582		constraints = {},
    1583		):
    1584		'''
    1585		Compute absolute Δ4x values for all replicate analyses and for sample averages.
    1586		If `method` argument is set to `'pooled'`, the standardization processes all sessions
    1587		in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
    1588		i.e. that their true Δ4x value does not change between sessions,
    1589		([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
    1590		`'indep_sessions'`, the standardization processes each session independently, based only
    1591		on anchors analyses.
    1592		'''
    1593
    1594		self.standardization_method = method
    1595		self.assign_timestamps()
    1596
    1597		if method == 'pooled':
    1598			if weighted_sessions:
    1599				for session_group in weighted_sessions:
    1600					if self._4x == '47':
    1601						X = D47data([r for r in self if r['Session'] in session_group])
    1602					elif self._4x == '48':
    1603						X = D48data([r for r in self if r['Session'] in session_group])
    1604					X.Nominal_D4x = self.Nominal_D4x.copy()
    1605					X.refresh()
    1606					result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
    1607					w = np.sqrt(result.redchi)
    1608					self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
    1609					for r in X:
    1610						r[f'wD{self._4x}raw'] *= w
    1611			else:
    1612				self.msg(f'All D{self._4x}raw weights set to 1 ‰')
    1613				for r in self:
    1614					r[f'wD{self._4x}raw'] = 1.
    1615
    1616			params = Parameters()
    1617			for k,session in enumerate(self.sessions):
    1618				self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
    1619				self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
    1620				self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
    1621				s = pf(session)
    1622				params.add(f'a_{s}', value = 0.9)
    1623				params.add(f'b_{s}', value = 0.)
    1624				params.add(f'c_{s}', value = -0.9)
    1625				params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
    1626				params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
    1627				params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
    1628			for sample in self.unknowns:
    1629				params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
    1630
    1631			for k in constraints:
    1632				params[k].expr = constraints[k]
    1633
    1634			def residuals(p):
    1635				R = []
    1636				for r in self:
    1637					session = pf(r['Session'])
    1638					sample = pf(r['Sample'])
    1639					if r['Sample'] in self.Nominal_D4x:
    1640						R += [ (
    1641							r[f'D{self._4x}raw'] - (
    1642								p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
    1643								+ p[f'b_{session}'] * r[f'd{self._4x}']
    1644								+	p[f'c_{session}']
    1645								+ r['t'] * (
    1646									p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
    1647									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    1648									+	p[f'c2_{session}']
    1649									)
    1650								)
    1651							) / r[f'wD{self._4x}raw'] ]
    1652					else:
    1653						R += [ (
    1654							r[f'D{self._4x}raw'] - (
    1655								p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
    1656								+ p[f'b_{session}'] * r[f'd{self._4x}']
    1657								+	p[f'c_{session}']
    1658								+ r['t'] * (
    1659									p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
    1660									+ p[f'b2_{session}'] * r[f'd{self._4x}']
    1661									+	p[f'c2_{session}']
    1662									)
    1663								)
    1664							) / r[f'wD{self._4x}raw'] ]
    1665				return R
    1666
    1667			M = Minimizer(residuals, params)
    1668			result = M.least_squares()
    1669			self.Nf = result.nfree
    1670			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
    1671# 			if self.verbose:
    1672# 				report_fit(result)
    1673
    1674			for r in self:
    1675				s = pf(r["Session"])
    1676				a = result.params.valuesdict()[f'a_{s}']
    1677				b = result.params.valuesdict()[f'b_{s}']
    1678				c = result.params.valuesdict()[f'c_{s}']
    1679				a2 = result.params.valuesdict()[f'a2_{s}']
    1680				b2 = result.params.valuesdict()[f'b2_{s}']
    1681				c2 = result.params.valuesdict()[f'c2_{s}']
    1682				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'])
    1683
    1684			self.standardization = result
    1685
    1686			for session in self.sessions:
    1687				self.sessions[session]['Np'] = 3
    1688				for k in ['scrambling', 'slope', 'wg']:
    1689					if self.sessions[session][f'{k}_drift']:
    1690						self.sessions[session]['Np'] += 1
    1691
    1692			if consolidate:
    1693				self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
    1694			return result
    1695
    1696
    1697		elif method == 'indep_sessions':
    1698
    1699			if weighted_sessions:
    1700				for session_group in weighted_sessions:
    1701					X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
    1702					X.Nominal_D4x = self.Nominal_D4x.copy()
    1703					X.refresh()
    1704					# This is only done to assign r['wD47raw'] for r in X:
    1705					X.standardize(method = method, weighted_sessions = [], consolidate = False)
    1706					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}')
    1707			else:
    1708				self.msg('All weights set to 1 ‰')
    1709				for r in self:
    1710					r[f'wD{self._4x}raw'] = 1
    1711
    1712			for session in self.sessions:
    1713				s = self.sessions[session]
    1714				p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
    1715				p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
    1716				s['Np'] = sum(p_active)
    1717				sdata = s['data']
    1718
    1719				A = np.array([
    1720					[
    1721						self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
    1722						r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
    1723						1 / r[f'wD{self._4x}raw'],
    1724						self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
    1725						r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
    1726						r['t'] / r[f'wD{self._4x}raw']
    1727						]
    1728					for r in sdata if r['Sample'] in self.anchors
    1729					])[:,p_active] # only keep columns for the active parameters
    1730				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])
    1731				s['Na'] = Y.size
    1732				CM = linalg.inv(A.T @ A)
    1733				bf = (CM @ A.T @ Y).T[0,:]
    1734				k = 0
    1735				for n,a in zip(p_names, p_active):
    1736					if a:
    1737						s[n] = bf[k]
    1738# 						self.msg(f'{n} = {bf[k]}')
    1739						k += 1
    1740					else:
    1741						s[n] = 0.
    1742# 						self.msg(f'{n} = 0.0')
    1743
    1744				for r in sdata :
    1745					a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
    1746					r[f'D{self._4x}'] = (r[f'D{self._4x}raw'] - c - b * r[f'd{self._4x}'] - c2 * r['t'] - b2 * r['t'] * r[f'd{self._4x}']) / (a + a2 * r['t'])
    1747					r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
    1748
    1749				s['CM'] = np.zeros((6,6))
    1750				i = 0
    1751				k_active = [j for j,a in enumerate(p_active) if a]
    1752				for j,a in enumerate(p_active):
    1753					if a:
    1754						s['CM'][j,k_active] = CM[i,:]
    1755						i += 1
    1756
    1757			if not weighted_sessions:
    1758				w = self.rmswd()['rmswd']
    1759				for r in self:
    1760						r[f'wD{self._4x}'] *= w
    1761						r[f'wD{self._4x}raw'] *= w
    1762				for session in self.sessions:
    1763					self.sessions[session]['CM'] *= w**2
    1764
    1765			for session in self.sessions:
    1766				s = self.sessions[session]
    1767				s['SE_a'] = s['CM'][0,0]**.5
    1768				s['SE_b'] = s['CM'][1,1]**.5
    1769				s['SE_c'] = s['CM'][2,2]**.5
    1770				s['SE_a2'] = s['CM'][3,3]**.5
    1771				s['SE_b2'] = s['CM'][4,4]**.5
    1772				s['SE_c2'] = s['CM'][5,5]**.5
    1773
    1774			if not weighted_sessions:
    1775				self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
    1776			else:
    1777				self.Nf = 0
    1778				for sg in weighted_sessions:
    1779					self.Nf += self.rmswd(sessions = sg)['Nf']
    1780
    1781			self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
    1782
    1783			avgD4x = {
    1784				sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
    1785				for sample in self.samples
    1786				}
    1787			chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
    1788			rD4x = (chi2/self.Nf)**.5
    1789			self.repeatability[f'sigma_{self._4x}'] = rD4x
    1790
    1791			if consolidate:
    1792				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

    1795	def standardization_error(self, session, d4x, D4x, t = 0):
    1796		'''
    1797		Compute standardization error for a given session and
    1798		(δ47, Δ47) composition.
    1799		'''
    1800		a = self.sessions[session]['a']
    1801		b = self.sessions[session]['b']
    1802		c = self.sessions[session]['c']
    1803		a2 = self.sessions[session]['a2']
    1804		b2 = self.sessions[session]['b2']
    1805		c2 = self.sessions[session]['c2']
    1806		CM = self.sessions[session]['CM']
    1807
    1808		x, y = D4x, d4x
    1809		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
    1810# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
    1811		dxdy = -(b+b2*t) / (a+a2*t)
    1812		dxdz = 1. / (a+a2*t)
    1813		dxda = -x / (a+a2*t)
    1814		dxdb = -y / (a+a2*t)
    1815		dxdc = -1. / (a+a2*t)
    1816		dxda2 = -x * a2 / (a+a2*t)
    1817		dxdb2 = -y * t / (a+a2*t)
    1818		dxdc2 = -t / (a+a2*t)
    1819		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
    1820		sx = (V @ CM @ V.T) ** .5
    1821		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

    1824	@make_verbal
    1825	def summary(self,
    1826		dir = 'output',
    1827		filename = None,
    1828		save_to_file = True,
    1829		print_out = True,
    1830		):
    1831		'''
    1832		Print out an/or save to disk a summary of the standardization results.
    1833
    1834		**Parameters**
    1835
    1836		+ `dir`: the directory in which to save the table
    1837		+ `filename`: the name to the csv file to write to
    1838		+ `save_to_file`: whether to save the table to disk
    1839		+ `print_out`: whether to print out the table
    1840		'''
    1841
    1842		out = []
    1843		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
    1844		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])})"]]
    1845		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
    1846		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
    1847		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
    1848		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
    1849		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
    1850		out += [['Model degrees of freedom', f"{self.Nf}"]]
    1851		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
    1852		out += [['Standardization method', self.standardization_method]]
    1853
    1854		if save_to_file:
    1855			if not os.path.exists(dir):
    1856				os.makedirs(dir)
    1857			if filename is None:
    1858				filename = f'D{self._4x}_summary.csv'
    1859			with open(f'{dir}/{filename}', 'w') as fid:
    1860				fid.write(make_csv(out))
    1861		if print_out:
    1862			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

    1865	@make_verbal
    1866	def table_of_sessions(self,
    1867		dir = 'output',
    1868		filename = None,
    1869		save_to_file = True,
    1870		print_out = True,
    1871		output = None,
    1872		):
    1873		'''
    1874		Print out an/or save to disk a table of sessions.
    1875
    1876		**Parameters**
    1877
    1878		+ `dir`: the directory in which to save the table
    1879		+ `filename`: the name to the csv file to write to
    1880		+ `save_to_file`: whether to save the table to disk
    1881		+ `print_out`: whether to print out the table
    1882		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    1883		    if set to `'raw'`: return a list of list of strings
    1884		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    1885		'''
    1886		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
    1887		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
    1888		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
    1889
    1890		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']]
    1891		if include_a2:
    1892			out[-1] += ['a2 ± SE']
    1893		if include_b2:
    1894			out[-1] += ['b2 ± SE']
    1895		if include_c2:
    1896			out[-1] += ['c2 ± SE']
    1897		for session in self.sessions:
    1898			out += [[
    1899				session,
    1900				f"{self.sessions[session]['Na']}",
    1901				f"{self.sessions[session]['Nu']}",
    1902				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
    1903				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
    1904				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
    1905				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
    1906				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
    1907				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
    1908				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
    1909				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
    1910				]]
    1911			if include_a2:
    1912				if self.sessions[session]['scrambling_drift']:
    1913					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
    1914				else:
    1915					out[-1] += ['']
    1916			if include_b2:
    1917				if self.sessions[session]['slope_drift']:
    1918					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
    1919				else:
    1920					out[-1] += ['']
    1921			if include_c2:
    1922				if self.sessions[session]['wg_drift']:
    1923					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
    1924				else:
    1925					out[-1] += ['']
    1926
    1927		if save_to_file:
    1928			if not os.path.exists(dir):
    1929				os.makedirs(dir)
    1930			if filename is None:
    1931				filename = f'D{self._4x}_sessions.csv'
    1932			with open(f'{dir}/{filename}', 'w') as fid:
    1933				fid.write(make_csv(out))
    1934		if print_out:
    1935			self.msg('\n' + pretty_table(out))
    1936		if output == 'raw':
    1937			return out
    1938		elif output == 'pretty':
    1939			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

    1942	@make_verbal
    1943	def table_of_analyses(
    1944		self,
    1945		dir = 'output',
    1946		filename = None,
    1947		save_to_file = True,
    1948		print_out = True,
    1949		output = None,
    1950		):
    1951		'''
    1952		Print out an/or save to disk a table of analyses.
    1953
    1954		**Parameters**
    1955
    1956		+ `dir`: the directory in which to save the table
    1957		+ `filename`: the name to the csv file to write to
    1958		+ `save_to_file`: whether to save the table to disk
    1959		+ `print_out`: whether to print out the table
    1960		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    1961		    if set to `'raw'`: return a list of list of strings
    1962		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    1963		'''
    1964
    1965		out = [['UID','Session','Sample']]
    1966		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}]
    1967		for f in extra_fields:
    1968			out[-1] += [f[0]]
    1969		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
    1970		for r in self:
    1971			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
    1972			for f in extra_fields:
    1973				out[-1] += [f"{r[f[0]]:{f[1]}}"]
    1974			out[-1] += [
    1975				f"{r['d13Cwg_VPDB']:.3f}",
    1976				f"{r['d18Owg_VSMOW']:.3f}",
    1977				f"{r['d45']:.6f}",
    1978				f"{r['d46']:.6f}",
    1979				f"{r['d47']:.6f}",
    1980				f"{r['d48']:.6f}",
    1981				f"{r['d49']:.6f}",
    1982				f"{r['d13C_VPDB']:.6f}",
    1983				f"{r['d18O_VSMOW']:.6f}",
    1984				f"{r['D47raw']:.6f}",
    1985				f"{r['D48raw']:.6f}",
    1986				f"{r['D49raw']:.6f}",
    1987				f"{r[f'D{self._4x}']:.6f}"
    1988				]
    1989		if save_to_file:
    1990			if not os.path.exists(dir):
    1991				os.makedirs(dir)
    1992			if filename is None:
    1993				filename = f'D{self._4x}_analyses.csv'
    1994			with open(f'{dir}/{filename}', 'w') as fid:
    1995				fid.write(make_csv(out))
    1996		if print_out:
    1997			self.msg('\n' + pretty_table(out))
    1998		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

    2000	@make_verbal
    2001	def covar_table(
    2002		self,
    2003		correl = False,
    2004		dir = 'output',
    2005		filename = None,
    2006		save_to_file = True,
    2007		print_out = True,
    2008		output = None,
    2009		):
    2010		'''
    2011		Print out, save to disk and/or return the variance-covariance matrix of D4x
    2012		for all unknown samples.
    2013
    2014		**Parameters**
    2015
    2016		+ `dir`: the directory in which to save the csv
    2017		+ `filename`: the name of the csv file to write to
    2018		+ `save_to_file`: whether to save the csv
    2019		+ `print_out`: whether to print out the matrix
    2020		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
    2021		    if set to `'raw'`: return a list of list of strings
    2022		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    2023		'''
    2024		samples = sorted([u for u in self.unknowns])
    2025		out = [[''] + samples]
    2026		for s1 in samples:
    2027			out.append([s1])
    2028			for s2 in samples:
    2029				if correl:
    2030					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
    2031				else:
    2032					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
    2033
    2034		if save_to_file:
    2035			if not os.path.exists(dir):
    2036				os.makedirs(dir)
    2037			if filename is None:
    2038				if correl:
    2039					filename = f'D{self._4x}_correl.csv'
    2040				else:
    2041					filename = f'D{self._4x}_covar.csv'
    2042			with open(f'{dir}/{filename}', 'w') as fid:
    2043				fid.write(make_csv(out))
    2044		if print_out:
    2045			self.msg('\n'+pretty_table(out))
    2046		if output == 'raw':
    2047			return out
    2048		elif output == 'pretty':
    2049			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

    2051	@make_verbal
    2052	def table_of_samples(
    2053		self,
    2054		dir = 'output',
    2055		filename = None,
    2056		save_to_file = True,
    2057		print_out = True,
    2058		output = None,
    2059		):
    2060		'''
    2061		Print out, save to disk and/or return a table of samples.
    2062
    2063		**Parameters**
    2064
    2065		+ `dir`: the directory in which to save the csv
    2066		+ `filename`: the name of the csv file to write to
    2067		+ `save_to_file`: whether to save the csv
    2068		+ `print_out`: whether to print out the table
    2069		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    2070		    if set to `'raw'`: return a list of list of strings
    2071		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    2072		'''
    2073
    2074		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
    2075		for sample in self.anchors:
    2076			out += [[
    2077				f"{sample}",
    2078				f"{self.samples[sample]['N']}",
    2079				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    2080				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    2081				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
    2082				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
    2083				]]
    2084		for sample in self.unknowns:
    2085			out += [[
    2086				f"{sample}",
    2087				f"{self.samples[sample]['N']}",
    2088				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    2089				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    2090				f"{self.samples[sample][f'D{self._4x}']:.4f}",
    2091				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
    2092				f"± {self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
    2093				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
    2094				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
    2095				]]
    2096		if save_to_file:
    2097			if not os.path.exists(dir):
    2098				os.makedirs(dir)
    2099			if filename is None:
    2100				filename = f'D{self._4x}_samples.csv'
    2101			with open(f'{dir}/{filename}', 'w') as fid:
    2102				fid.write(make_csv(out))
    2103		if print_out:
    2104			self.msg('\n'+pretty_table(out))
    2105		if output == 'raw':
    2106			return out
    2107		elif output == 'pretty':
    2108			return pretty_table(out)

Print out, save to disk and/or return 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

    2111	def plot_sessions(self, dir = 'output', figsize = (8,8)):
    2112		'''
    2113		Generate session plots and save them to disk.
    2114
    2115		**Parameters**
    2116
    2117		+ `dir`: the directory in which to save the plots
    2118		+ `figsize`: the width and height (in inches) of each plot
    2119		'''
    2120		if not os.path.exists(dir):
    2121			os.makedirs(dir)
    2122
    2123		for session in self.sessions:
    2124			sp = self.plot_single_session(session, xylimits = 'constant')
    2125			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
    2126			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

    2129	@make_verbal
    2130	def consolidate_samples(self):
    2131		'''
    2132		Compile various statistics for each sample.
    2133
    2134		For each anchor sample:
    2135
    2136		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
    2137		+ `SE_D47` or `SE_D48`: set to zero by definition
    2138
    2139		For each unknown sample:
    2140
    2141		+ `D47` or `D48`: the standardized Δ4x value for this unknown
    2142		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
    2143
    2144		For each anchor and unknown:
    2145
    2146		+ `N`: the total number of analyses of this sample
    2147		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
    2148		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
    2149		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
    2150		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
    2151		variance, indicating whether the Δ4x repeatability this sample differs significantly from
    2152		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
    2153		'''
    2154		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
    2155		for sample in self.samples:
    2156			self.samples[sample]['N'] = len(self.samples[sample]['data'])
    2157			if self.samples[sample]['N'] > 1:
    2158				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
    2159
    2160			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
    2161			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
    2162
    2163			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
    2164			if len(D4x_pop) > 2:
    2165				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
    2166
    2167		if self.standardization_method == 'pooled':
    2168			for sample in self.anchors:
    2169				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    2170				self.samples[sample][f'SE_D{self._4x}'] = 0.
    2171			for sample in self.unknowns:
    2172				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
    2173				try:
    2174					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
    2175				except ValueError:
    2176					# when `sample` is constrained by self.standardize(constraints = {...}),
    2177					# it is no longer listed in self.standardization.var_names.
    2178					# Temporary fix: define SE as zero for now
    2179					self.samples[sample][f'SE_D4{self._4x}'] = 0.
    2180
    2181		elif self.standardization_method == 'indep_sessions':
    2182			for sample in self.anchors:
    2183				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    2184				self.samples[sample][f'SE_D{self._4x}'] = 0.
    2185			for sample in self.unknowns:
    2186				self.msg(f'Consolidating sample {sample}')
    2187				self.unknowns[sample][f'session_D{self._4x}'] = {}
    2188				session_avg = []
    2189				for session in self.sessions:
    2190					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
    2191					if sdata:
    2192						self.msg(f'{sample} found in session {session}')
    2193						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
    2194						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
    2195						# !! TODO: sigma_s below does not account for temporal changes in standardization error
    2196						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
    2197						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
    2198						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
    2199						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
    2200				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
    2201				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
    2202				wsum = sum([weights[s] for s in weights])
    2203				for s in weights:
    2204					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]

Compile various statistics for each sample.

For each anchor sample:

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

For each unknown sample:

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

For each anchor and unknown:

- `N`: the total number of analyses of this sample
- `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
- `d13C_VPDB`: the average δ13CVPDB value for this sample
- `d18O_VSMOW`: the average δ18OVSMOW value for this sample (as CO2)
- `p_Levene`: the p-value from a Levene test of equal variance, indicating whether the Δ4x repeatability this sample differs significantly from that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.

def consolidate_sessions(self): View Source

    2207	def consolidate_sessions(self):
    2208		'''
    2209		Compute various statistics for each session.
    2210
    2211		+ `Na`: Number of anchor analyses in the session
    2212		+ `Nu`: Number of unknown analyses in the session
    2213		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
    2214		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
    2215		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
    2216		+ `a`: scrambling factor
    2217		+ `b`: compositional slope
    2218		+ `c`: WG offset
    2219		+ `SE_a`: Model stadard erorr of `a`
    2220		+ `SE_b`: Model stadard erorr of `b`
    2221		+ `SE_c`: Model stadard erorr of `c`
    2222		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
    2223		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
    2224		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
    2225		+ `a2`: scrambling factor drift
    2226		+ `b2`: compositional slope drift
    2227		+ `c2`: WG offset drift
    2228		+ `Np`: Number of standardization parameters to fit
    2229		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
    2230		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
    2231		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
    2232		'''
    2233		for session in self.sessions:
    2234			if 'd13Cwg_VPDB' not in self.sessions[session]:
    2235				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
    2236			if 'd18Owg_VSMOW' not in self.sessions[session]:
    2237				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
    2238			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
    2239			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
    2240
    2241			self.msg(f'Computing repeatabilities for session {session}')
    2242			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
    2243			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
    2244			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
    2245
    2246		if self.standardization_method == 'pooled':
    2247			for session in self.sessions:
    2248
    2249				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
    2250				i = self.standardization.var_names.index(f'a_{pf(session)}')
    2251				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
    2252
    2253				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
    2254				i = self.standardization.var_names.index(f'b_{pf(session)}')
    2255				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
    2256
    2257				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
    2258				i = self.standardization.var_names.index(f'c_{pf(session)}')
    2259				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
    2260
    2261				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
    2262				if self.sessions[session]['scrambling_drift']:
    2263					i = self.standardization.var_names.index(f'a2_{pf(session)}')
    2264					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
    2265				else:
    2266					self.sessions[session]['SE_a2'] = 0.
    2267
    2268				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
    2269				if self.sessions[session]['slope_drift']:
    2270					i = self.standardization.var_names.index(f'b2_{pf(session)}')
    2271					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
    2272				else:
    2273					self.sessions[session]['SE_b2'] = 0.
    2274
    2275				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
    2276				if self.sessions[session]['wg_drift']:
    2277					i = self.standardization.var_names.index(f'c2_{pf(session)}')
    2278					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
    2279				else:
    2280					self.sessions[session]['SE_c2'] = 0.
    2281
    2282				i = self.standardization.var_names.index(f'a_{pf(session)}')
    2283				j = self.standardization.var_names.index(f'b_{pf(session)}')
    2284				k = self.standardization.var_names.index(f'c_{pf(session)}')
    2285				CM = np.zeros((6,6))
    2286				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
    2287				try:
    2288					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
    2289					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
    2290					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
    2291					try:
    2292						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    2293						CM[3,4] = self.standardization.covar[i2,j2]
    2294						CM[4,3] = self.standardization.covar[j2,i2]
    2295					except ValueError:
    2296						pass
    2297					try:
    2298						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2299						CM[3,5] = self.standardization.covar[i2,k2]
    2300						CM[5,3] = self.standardization.covar[k2,i2]
    2301					except ValueError:
    2302						pass
    2303				except ValueError:
    2304					pass
    2305				try:
    2306					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    2307					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
    2308					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
    2309					try:
    2310						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2311						CM[4,5] = self.standardization.covar[j2,k2]
    2312						CM[5,4] = self.standardization.covar[k2,j2]
    2313					except ValueError:
    2314						pass
    2315				except ValueError:
    2316					pass
    2317				try:
    2318					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    2319					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
    2320					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
    2321				except ValueError:
    2322					pass
    2323
    2324				self.sessions[session]['CM'] = CM
    2325
    2326		elif self.standardization_method == 'indep_sessions':
    2327			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

    2330	@make_verbal
    2331	def repeatabilities(self):
    2332		'''
    2333		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    2334		(for all samples, for anchors, and for unknowns).
    2335		'''
    2336		self.msg('Computing reproducibilities for all sessions')
    2337
    2338		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    2339		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    2340		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    2341		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    2342		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

    2345	@make_verbal
    2346	def consolidate(self, tables = True, plots = True):
    2347		'''
    2348		Collect information about samples, sessions and repeatabilities.
    2349		'''
    2350		self.consolidate_samples()
    2351		self.consolidate_sessions()
    2352		self.repeatabilities()
    2353
    2354		if tables:
    2355			self.summary()
    2356			self.table_of_sessions()
    2357			self.table_of_analyses()
    2358			self.table_of_samples()
    2359
    2360		if plots:
    2361			self.plot_sessions()

Collect information about samples, sessions and repeatabilities.

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

    2364	@make_verbal
    2365	def rmswd(self,
    2366		samples = 'all samples',
    2367		sessions = 'all sessions',
    2368		):
    2369		'''
    2370		Compute the χ2, root mean squared weighted deviation
    2371		(i.e. reduced χ2), and corresponding degrees of freedom of the
    2372		Δ4x values for samples in `samples` and sessions in `sessions`.
    2373
    2374		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
    2375		'''
    2376		if samples == 'all samples':
    2377			mysamples = [k for k in self.samples]
    2378		elif samples == 'anchors':
    2379			mysamples = [k for k in self.anchors]
    2380		elif samples == 'unknowns':
    2381			mysamples = [k for k in self.unknowns]
    2382		else:
    2383			mysamples = samples
    2384
    2385		if sessions == 'all sessions':
    2386			sessions = [k for k in self.sessions]
    2387
    2388		chisq, Nf = 0, 0
    2389		for sample in mysamples :
    2390			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2391			if len(G) > 1 :
    2392				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
    2393				Nf += (len(G) - 1)
    2394				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
    2395		r = (chisq / Nf)**.5 if Nf > 0 else 0
    2396		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
    2397		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

    2400	@make_verbal
    2401	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
    2402		'''
    2403		Compute the repeatability of `[r[key] for r in self]`
    2404		'''
    2405		# NB: it's debatable whether rD47 should be computed
    2406		# with Nf = len(self)-len(self.samples) instead of
    2407		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
    2408
    2409		if samples == 'all samples':
    2410			mysamples = [k for k in self.samples]
    2411		elif samples == 'anchors':
    2412			mysamples = [k for k in self.anchors]
    2413		elif samples == 'unknowns':
    2414			mysamples = [k for k in self.unknowns]
    2415		else:
    2416			mysamples = samples
    2417
    2418		if sessions == 'all sessions':
    2419			sessions = [k for k in self.sessions]
    2420
    2421		if key in ['D47', 'D48']:
    2422			chisq, Nf = 0, 0
    2423			for sample in mysamples :
    2424				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2425				if len(X) > 1 :
    2426					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
    2427					if sample in self.unknowns:
    2428						Nf += len(X) - 1
    2429					else:
    2430						Nf += len(X)
    2431			if samples in ['anchors', 'all samples']:
    2432				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
    2433			r = (chisq / Nf)**.5 if Nf > 0 else 0
    2434
    2435		else: # if key not in ['D47', 'D48']
    2436			chisq, Nf = 0, 0
    2437			for sample in mysamples :
    2438				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    2439				if len(X) > 1 :
    2440					Nf += len(X) - 1
    2441					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
    2442			r = (chisq / Nf)**.5 if Nf > 0 else 0
    2443
    2444		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
    2445		return r

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

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

    2447	def sample_average(self, samples, weights = 'equal', normalize = True):
    2448		'''
    2449		Weighted average Δ4x value of a group of samples, accounting for covariance.
    2450
    2451		Returns the weighed average Δ4x value and associated SE
    2452		of a group of samples. Weights are equal by default. If `normalize` is
    2453		true, `weights` will be rescaled so that their sum equals 1.
    2454
    2455		**Examples**
    2456
    2457		```python
    2458		self.sample_average(['X','Y'], [1, 2])
    2459		```
    2460
    2461		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
    2462		where Δ4x(X) and Δ4x(Y) are the average Δ4x
    2463		values of samples X and Y, respectively.
    2464
    2465		```python
    2466		self.sample_average(['X','Y'], [1, -1], normalize = False)
    2467		```
    2468
    2469		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
    2470		'''
    2471		if weights == 'equal':
    2472			weights = [1/len(samples)] * len(samples)
    2473
    2474		if normalize:
    2475			s = sum(weights)
    2476			if s:
    2477				weights = [w/s for w in weights]
    2478
    2479		try:
    2480# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
    2481# 			C = self.standardization.covar[indices,:][:,indices]
    2482			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
    2483			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
    2484			return correlated_sum(X, C, weights)
    2485		except ValueError:
    2486			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

    2489	def sample_D4x_covar(self, sample1, sample2 = None):
    2490		'''
    2491		Covariance between Δ4x values of samples
    2492
    2493		Returns the error covariance between the average Δ4x values of two
    2494		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
    2495		returns the Δ4x variance for that sample.
    2496		'''
    2497		if sample2 is None:
    2498			sample2 = sample1
    2499		if self.standardization_method == 'pooled':
    2500			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
    2501			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
    2502			return self.standardization.covar[i, j]
    2503		elif self.standardization_method == 'indep_sessions':
    2504			if sample1 == sample2:
    2505				return self.samples[sample1][f'SE_D{self._4x}']**2
    2506			else:
    2507				c = 0
    2508				for session in self.sessions:
    2509					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
    2510					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
    2511					if sdata1 and sdata2:
    2512						a = self.sessions[session]['a']
    2513						# !! TODO: CM below does not account for temporal changes in standardization parameters
    2514						CM = self.sessions[session]['CM'][:3,:3]
    2515						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
    2516						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
    2517						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
    2518						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
    2519						c += (
    2520							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
    2521							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
    2522							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
    2523							@ CM
    2524							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
    2525							) / a**2
    2526				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

    2528	def sample_D4x_correl(self, sample1, sample2 = None):
    2529		'''
    2530		Correlation between Δ4x errors of samples
    2531
    2532		Returns the error correlation between the average Δ4x values of two samples.
    2533		'''
    2534		if sample2 is None or sample2 == sample1:
    2535			return 1.
    2536		return (
    2537			self.sample_D4x_covar(sample1, sample2)
    2538			/ self.unknowns[sample1][f'SE_D{self._4x}']
    2539			/ self.unknowns[sample2][f'SE_D{self._4x}']
    2540			)

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

    2542	def plot_single_session(self,
    2543		session,
    2544		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
    2545		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
    2546		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
    2547		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
    2548		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
    2549		xylimits = 'free', # | 'constant'
    2550		x_label = None,
    2551		y_label = None,
    2552		error_contour_interval = 'auto',
    2553		fig = 'new',
    2554		):
    2555		'''
    2556		Generate plot for a single session
    2557		'''
    2558		if x_label is None:
    2559			x_label = f'δ$_{{{self._4x}}}$ (‰)'
    2560		if y_label is None:
    2561			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
    2562
    2563		out = _SessionPlot()
    2564		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
    2565		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
    2566
    2567		if fig == 'new':
    2568			out.fig = ppl.figure(figsize = (6,6))
    2569			ppl.subplots_adjust(.1,.1,.9,.9)
    2570
    2571		out.anchor_analyses, = ppl.plot(
    2572			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    2573			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    2574			**kw_plot_anchors)
    2575		out.unknown_analyses, = ppl.plot(
    2576			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    2577			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    2578			**kw_plot_unknowns)
    2579		out.anchor_avg = ppl.plot(
    2580			np.array([ np.array([
    2581				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    2582				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    2583				]) for sample in anchors]).T,
    2584			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
    2585			**kw_plot_anchor_avg)
    2586		out.unknown_avg = ppl.plot(
    2587			np.array([ np.array([
    2588				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    2589				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    2590				]) for sample in unknowns]).T,
    2591			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
    2592			**kw_plot_unknown_avg)
    2593		if xylimits == 'constant':
    2594			x = [r[f'd{self._4x}'] for r in self]
    2595			y = [r[f'D{self._4x}'] for r in self]
    2596			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
    2597			w, h = x2-x1, y2-y1
    2598			x1 -= w/20
    2599			x2 += w/20
    2600			y1 -= h/20
    2601			y2 += h/20
    2602			ppl.axis([x1, x2, y1, y2])
    2603		elif xylimits == 'free':
    2604			x1, x2, y1, y2 = ppl.axis()
    2605		else:
    2606			x1, x2, y1, y2 = ppl.axis(xylimits)
    2607
    2608		if error_contour_interval != 'none':
    2609			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
    2610			XI,YI = np.meshgrid(xi, yi)
    2611			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
    2612			if error_contour_interval == 'auto':
    2613				rng = np.max(SI) - np.min(SI)
    2614				if rng <= 0.01:
    2615					cinterval = 0.001
    2616				elif rng <= 0.03:
    2617					cinterval = 0.004
    2618				elif rng <= 0.1:
    2619					cinterval = 0.01
    2620				elif rng <= 0.3:
    2621					cinterval = 0.03
    2622				elif rng <= 1.:
    2623					cinterval = 0.1
    2624				else:
    2625					cinterval = 0.5
    2626			else:
    2627				cinterval = error_contour_interval
    2628
    2629			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
    2630			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
    2631			out.clabel = ppl.clabel(out.contour)
    2632
    2633		ppl.xlabel(x_label)
    2634		ppl.ylabel(y_label)
    2635		ppl.title(session, weight = 'bold')
    2636		ppl.grid(alpha = .2)
    2637		out.ax = ppl.gca()
    2638
    2639		return out

Generate plot for a single session

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

    2641	def plot_residuals(
    2642		self,
    2643		hist = False,
    2644		binwidth = 2/3,
    2645		dir = 'output',
    2646		filename = None,
    2647		highlight = [],
    2648		colors = None,
    2649		figsize = None,
    2650		):
    2651		'''
    2652		Plot residuals of each analysis as a function of time (actually, as a function of
    2653		the order of analyses in the `D4xdata` object)
    2654
    2655		+ `hist`: whether to add a histogram of residuals
    2656		+ `histbins`: specify bin edges for the histogram
    2657		+ `dir`: the directory in which to save the plot
    2658		+ `highlight`: a list of samples to highlight
    2659		+ `colors`: a dict of `{<sample>: <color>}` for all samples
    2660		+ `figsize`: (width, height) of figure
    2661		'''
    2662		# Layout
    2663		fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
    2664		if hist:
    2665			ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
    2666			ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
    2667		else:
    2668			ppl.subplots_adjust(.08,.05,.78,.8)
    2669			ax1 = ppl.subplot(111)
    2670
    2671		# Colors
    2672		N = len(self.anchors)
    2673		if colors is None:
    2674			if len(highlight) > 0:
    2675				Nh = len(highlight)
    2676				if Nh == 1:
    2677					colors = {highlight[0]: (0,0,0)}
    2678				elif Nh == 3:
    2679					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
    2680				elif Nh == 4:
    2681					colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    2682				else:
    2683					colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
    2684			else:
    2685				if N == 3:
    2686					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
    2687				elif N == 4:
    2688					colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
    2689				else:
    2690					colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
    2691
    2692		ppl.sca(ax1)
    2693
    2694		ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
    2695
    2696		session = self[0]['Session']
    2697		x1 = 0
    2698# 		ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
    2699		x_sessions = {}
    2700		one_or_more_singlets = False
    2701		one_or_more_multiplets = False
    2702		multiplets = set()
    2703		for k,r in enumerate(self):
    2704			if r['Session'] != session:
    2705				x2 = k-1
    2706				x_sessions[session] = (x1+x2)/2
    2707				ppl.axvline(k - 0.5, color = 'k', lw = .5)
    2708				session = r['Session']
    2709				x1 = k
    2710			singlet = len(self.samples[r['Sample']]['data']) == 1
    2711			if not singlet:
    2712				multiplets.add(r['Sample'])
    2713			if r['Sample'] in self.unknowns:
    2714				if singlet:
    2715					one_or_more_singlets = True
    2716				else:
    2717					one_or_more_multiplets = True
    2718			kw = dict(
    2719				marker = 'x' if singlet else '+',
    2720				ms = 4 if singlet else 5,
    2721				ls = 'None',
    2722				mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
    2723				mew = 1,
    2724				alpha = 0.2 if singlet else 1,
    2725				)
    2726			if highlight and r['Sample'] not in highlight:
    2727				kw['alpha'] = 0.2
    2728			ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
    2729		x2 = k
    2730		x_sessions[session] = (x1+x2)/2
    2731
    2732		ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
    2733		ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
    2734		if not hist:
    2735			ppl.text(len(self), self.repeatability['r_D47']*1000, f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
    2736			ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f"   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')
    2737
    2738		xmin, xmax, ymin, ymax = ppl.axis()
    2739		for s in x_sessions:
    2740			ppl.text(
    2741				x_sessions[s],
    2742				ymax +1,
    2743				s,
    2744				va = 'bottom',
    2745				**(
    2746					dict(ha = 'center')
    2747					if len(self.sessions[s]['data']) > (0.15 * len(self))
    2748					else dict(ha = 'left', rotation = 45)
    2749					)
    2750				)
    2751
    2752		if hist:
    2753			ppl.sca(ax2)
    2754
    2755		for s in colors:
    2756			kw['marker'] = '+'
    2757			kw['ms'] = 5
    2758			kw['mec'] = colors[s]
    2759			kw['label'] = s
    2760			kw['alpha'] = 1
    2761			ppl.plot([], [], **kw)
    2762
    2763		kw['mec'] = (0,0,0)
    2764
    2765		if one_or_more_singlets:
    2766			kw['marker'] = 'x'
    2767			kw['ms'] = 4
    2768			kw['alpha'] = .2
    2769			kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
    2770			ppl.plot([], [], **kw)
    2771
    2772		if one_or_more_multiplets:
    2773			kw['marker'] = '+'
    2774			kw['ms'] = 4
    2775			kw['alpha'] = 1
    2776			kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
    2777			ppl.plot([], [], **kw)
    2778
    2779		if hist:
    2780			leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
    2781		else:
    2782			leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
    2783		leg.set_zorder(-1000)
    2784
    2785		ppl.sca(ax1)
    2786
    2787		ppl.ylabel('Δ$_{47}$ residuals (ppm)')
    2788		ppl.xticks([])
    2789		ppl.axis([-1, len(self), None, None])
    2790
    2791		if hist:
    2792			ppl.sca(ax2)
    2793			X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets]
    2794			ppl.hist(
    2795				X,
    2796				orientation = 'horizontal',
    2797				histtype = 'stepfilled',
    2798				ec = [.4]*3,
    2799				fc = [.25]*3,
    2800				alpha = .25,
    2801				bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
    2802				)
    2803			ppl.axis([None, None, ymin, ymax])
    2804			ppl.text(0, 0,
    2805				f"   SD = {self.repeatability['r_D47']*1000:.1f} ppm\n   95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
    2806				size = 8,
    2807				alpha = 1,
    2808				va = 'center',
    2809				ha = 'left',
    2810				)
    2811
    2812			ppl.xticks([])
    2813			ppl.yticks([])
    2814# 			ax2.spines['left'].set_visible(False)
    2815			ax2.spines['right'].set_visible(False)
    2816			ax2.spines['top'].set_visible(False)
    2817			ax2.spines['bottom'].set_visible(False)
    2818
    2819
    2820		if not os.path.exists(dir):
    2821			os.makedirs(dir)
    2822		if filename is None:
    2823			return fig
    2824		elif filename == '':
    2825			filename = f'D{self._4x}_residuals.pdf'
    2826		ppl.savefig(f'{dir}/{filename}')
    2827		ppl.close(fig)

Plot residuals of each analysis as a function of time (actually, as a function of the order of analyses in the `D4xdata` object)

- `hist`: whether to add a histogram of residuals
- `histbins`: specify bin edges for the histogram
- `dir`: the directory in which to save the plot
- `highlight`: a list of samples to highlight
- `colors`: a dict of `{<sample>: <color>}` for all samples
- `figsize`: (width, height) of figure

def simulate(self, *args, **kwargs): View Source

    2830	def simulate(self, *args, **kwargs):
    2831		'''
    2832		Legacy function with warning message pointing to `virtual_data()`
    2833		'''
    2834		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

    2836	def plot_distribution_of_analyses(
    2837		self,
    2838		dir = 'output',
    2839		filename = None,
    2840		vs_time = False,
    2841		figsize = (6,4),
    2842		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
    2843		output = None,
    2844		):
    2845		'''
    2846		Plot temporal distribution of all analyses in the data set.
    2847
    2848		**Parameters**
    2849
    2850		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    2851		'''
    2852
    2853		asamples = [s for s in self.anchors]
    2854		usamples = [s for s in self.unknowns]
    2855		if output is None or output == 'fig':
    2856			fig = ppl.figure(figsize = figsize)
    2857			ppl.subplots_adjust(*subplots_adjust)
    2858		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    2859		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    2860		Xmax += (Xmax-Xmin)/40
    2861		Xmin -= (Xmax-Xmin)/41
    2862		for k, s in enumerate(asamples + usamples):
    2863			if vs_time:
    2864				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    2865			else:
    2866				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    2867			Y = [-k for x in X]
    2868			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
    2869			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    2870			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
    2871		ppl.axis([Xmin, Xmax, -k-1, 1])
    2872		ppl.xlabel('\ntime')
    2873		ppl.gca().annotate('',
    2874			xy = (0.6, -0.02),
    2875			xycoords = 'axes fraction',
    2876			xytext = (.4, -0.02),
    2877            arrowprops = dict(arrowstyle = "->", color = 'k'),
    2878            )
    2879
    2880
    2881		x2 = -1
    2882		for session in self.sessions:
    2883			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    2884			if vs_time:
    2885				ppl.axvline(x1, color = 'k', lw = .75)
    2886			if x2 > -1:
    2887				if not vs_time:
    2888					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
    2889			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    2890# 			from xlrd import xldate_as_datetime
    2891# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    2892			if vs_time:
    2893				ppl.axvline(x2, color = 'k', lw = .75)
    2894				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
    2895			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
    2896
    2897		ppl.xticks([])
    2898		ppl.yticks([])
    2899
    2900		if output is None:
    2901			if not os.path.exists(dir):
    2902				os.makedirs(dir)
    2903			if filename == None:
    2904				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    2905			ppl.savefig(f'{dir}/{filename}')
    2906			ppl.close(fig)
    2907		elif output == 'ax':
    2908			return ppl.gca()
    2909		elif output == 'fig':
    2910			return fig

Plot temporal distribution of all analyses in the data set.

Parameters

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

##### Inherited Members

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

class D47data(D4xdata): View Source

    2913class D47data(D4xdata):
    2914	'''
    2915	Store and process data for a large set of Δ47 analyses,
    2916	usually comprising more than one analytical session.
    2917	'''
    2918
    2919	Nominal_D4x = {
    2920		'ETH-1':   0.2052,
    2921		'ETH-2':   0.2085,
    2922		'ETH-3':   0.6132,
    2923		'ETH-4':   0.4511,
    2924		'IAEA-C1': 0.3018,
    2925		'IAEA-C2': 0.6409,
    2926		'MERCK':   0.5135,
    2927		} # I-CDES (Bernasconi et al., 2021)
    2928	'''
    2929	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
    2930	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
    2931	reference frame.
    2932
    2933	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
    2934	```py
    2935	{
    2936		'ETH-1'   : 0.2052,
    2937		'ETH-2'   : 0.2085,
    2938		'ETH-3'   : 0.6132,
    2939		'ETH-4'   : 0.4511,
    2940		'IAEA-C1' : 0.3018,
    2941		'IAEA-C2' : 0.6409,
    2942		'MERCK'   : 0.5135,
    2943	}
    2944	```
    2945	'''
    2946
    2947
    2948	@property
    2949	def Nominal_D47(self):
    2950		return self.Nominal_D4x
    2951
    2952
    2953	@Nominal_D47.setter
    2954	def Nominal_D47(self, new):
    2955		self.Nominal_D4x = dict(**new)
    2956		self.refresh()
    2957
    2958
    2959	def __init__(self, l = [], **kwargs):
    2960		'''
    2961		**Parameters:** same as `D4xdata.__init__()`
    2962		'''
    2963		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    2964
    2965
    2966	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    2967		'''
    2968		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    2969		value for that temperature, and add treat these samples as additional anchors.
    2970
    2971		**Parameters**
    2972
    2973		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    2974		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    2975		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    2976		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    2977		if `new`: keep pre-existing anchors but update them in case of conflict
    2978		between old and new Δ47 values;
    2979		if `old`: keep pre-existing anchors but preserve their original Δ47
    2980		values in case of conflict.
    2981		'''
    2982		f = {
    2983			'petersen': fCO2eqD47_Petersen,
    2984			'wang': fCO2eqD47_Wang,
    2985			}[fCo2eqD47]
    2986		foo = {}
    2987		for r in self:
    2988			if 'Teq' in r:
    2989				if r['Sample'] in foo:
    2990					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    2991				else:
    2992					foo[r['Sample']] = f(r['Teq'])
    2993			else:
    2994					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    2995
    2996		if priority == 'replace':
    2997			self.Nominal_D47 = {}
    2998		for s in foo:
    2999			if priority != 'old' or s not in self.Nominal_D47:
    3000				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

    2959	def __init__(self, l = [], **kwargs):
    2960		'''
    2961		**Parameters:** same as `D4xdata.__init__()`
    2962		'''
    2963		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

    2966	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    2967		'''
    2968		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    2969		value for that temperature, and add treat these samples as additional anchors.
    2970
    2971		**Parameters**
    2972
    2973		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    2974		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    2975		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    2976		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    2977		if `new`: keep pre-existing anchors but update them in case of conflict
    2978		between old and new Δ47 values;
    2979		if `old`: keep pre-existing anchors but preserve their original Δ47
    2980		values in case of conflict.
    2981		'''
    2982		f = {
    2983			'petersen': fCO2eqD47_Petersen,
    2984			'wang': fCO2eqD47_Wang,
    2985			}[fCo2eqD47]
    2986		foo = {}
    2987		for r in self:
    2988			if 'Teq' in r:
    2989				if r['Sample'] in foo:
    2990					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    2991				else:
    2992					foo[r['Sample']] = f(r['Teq'])
    2993			else:
    2994					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    2995
    2996		if priority == 'replace':
    2997			self.Nominal_D47 = {}
    2998		for s in foo:
    2999			if priority != 'old' or s not in self.Nominal_D47:
    3000				self.Nominal_D47[s] = foo[s]

Find all samples for which `Teq` is specified, compute equilibrium Δ47 value for that temperature, and add treat these samples as additional anchors.

Parameters

- `fCo2eqD47`: Which CO2 equilibrium law to use (`petersen`: Petersen et al. (2019); `wang`: Wang et al. (2019)).
- `priority`: if `replace`: forget old anchors and only use the new ones; if `new`: keep pre-existing anchors but update them in case of conflict between old and new Δ47 values; if `old`: keep pre-existing anchors but preserve their original Δ47 values in case of conflict.

##### Inherited Members

D4xdata
  R13_VPDB
  R18_VSMOW
  LAMBDA_17
  R17_VSMOW
  R18_VPDB
  R17_VPDB
  LEVENE_REF_SAMPLE
  ALPHA_18O_ACID_REACTION
  Nominal_d13C_VPDB
  Nominal_d18O_VPDB
  d13C_STANDARDIZATION_METHOD
  d18O_STANDARDIZATION_METHOD
  make_verbal
  msg
  vmsg
  log
  refresh
  refresh_sessions
  refresh_samples
  read
  input
  wg
  compute_bulk_delta
  crunch
  fill_in_missing_info
  standardize_d13C
  standardize_d18O
  compute_bulk_and_clumping_deltas
  compute_isobar_ratios
  split_samples
  unsplit_samples
  assign_timestamps
  report
  combine_samples
  standardize
  standardization_error
  summary
  table_of_sessions
  table_of_analyses
  covar_table
  table_of_samples
  plot_sessions
  consolidate_samples
  consolidate_sessions
  repeatabilities
  consolidate
  rmswd
  compute_r
  sample_average
  sample_D4x_covar
  sample_D4x_correl
  plot_single_session
  plot_residuals
  simulate
  plot_distribution_of_analyses
builtins.list
  clear
  copy
  append
  insert
  extend
  pop
  remove
  index
  count
  reverse
  sort

class D48data(D4xdata): View Source

    3005class D48data(D4xdata):
    3006	'''
    3007	Store and process data for a large set of Δ48 analyses,
    3008	usually comprising more than one analytical session.
    3009	'''
    3010
    3011	Nominal_D4x = {
    3012		'ETH-1':  0.138,
    3013		'ETH-2':  0.138,
    3014		'ETH-3':  0.270,
    3015		'ETH-4':  0.223,
    3016		'GU-1':  -0.419,
    3017		} # (Fiebig et al., 2019, 2021)
    3018	'''
    3019	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
    3020	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
    3021	reference frame.
    3022
    3023	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
    3024	Fiebig et al. (in press)):
    3025
    3026	```py
    3027	{
    3028		'ETH-1' :  0.138,
    3029		'ETH-2' :  0.138,
    3030		'ETH-3' :  0.270,
    3031		'ETH-4' :  0.223,
    3032		'GU-1'  : -0.419,
    3033	}
    3034	```
    3035	'''
    3036
    3037
    3038	@property
    3039	def Nominal_D48(self):
    3040		return self.Nominal_D4x
    3041
    3042
    3043	@Nominal_D48.setter
    3044	def Nominal_D48(self, new):
    3045		self.Nominal_D4x = dict(**new)
    3046		self.refresh()
    3047
    3048
    3049	def __init__(self, l = [], **kwargs):
    3050		'''
    3051		**Parameters:** same as `D4xdata.__init__()`
    3052		'''
    3053		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

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

Parameters: same as `D4xdata.__init__()`

Nominal_D4x = {'ETH-1': 0.138, 'ETH-2': 0.138, 'ETH-3': 0.27, 'ETH-4': 0.223, 'GU-1': -0.419}

Nominal Δ48 values assigned to the Δ48 anchor samples, used by `D48data.standardize()` to normalize unknown samples to an absolute Δ48 reference frame.

By default equal to (after Fiebig et al. (2019), Fiebig et al. (in press)):

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

##### Inherited Members

D4xdata
  R13_VPDB
  R18_VSMOW
  LAMBDA_17
  R17_VSMOW
  R18_VPDB
  R17_VPDB
  LEVENE_REF_SAMPLE
  ALPHA_18O_ACID_REACTION
  Nominal_d13C_VPDB
  Nominal_d18O_VPDB
  d13C_STANDARDIZATION_METHOD
  d18O_STANDARDIZATION_METHOD
  make_verbal
  msg
  vmsg
  log
  refresh
  refresh_sessions
  refresh_samples
  read
  input
  wg
  compute_bulk_delta
  crunch
  fill_in_missing_info
  standardize_d13C
  standardize_d18O
  compute_bulk_and_clumping_deltas
  compute_isobar_ratios
  split_samples
  unsplit_samples
  assign_timestamps
  report
  combine_samples
  standardize
  standardization_error
  summary
  table_of_sessions
  table_of_analyses
  covar_table
  table_of_samples
  plot_sessions
  consolidate_samples
  consolidate_sessions
  repeatabilities
  consolidate
  rmswd
  compute_r
  sample_average
  sample_D4x_covar
  sample_D4x_correl
  plot_single_session
  plot_residuals
  simulate
  plot_distribution_of_analyses
builtins.list
  clear
  copy
  append
  insert
  extend
  pop
  remove
  index
  count
  reverse
  sort