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')

time_distribution.png

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.

D47_plot_Session_03.png

2.2.3 Plotting Δ47 or Δ48 residuals

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

residuals.png

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

2.2.4 Checking δ13C and δ18O dispersion

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

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

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

bulk_compositions.png

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:

17O correction parameters are defined by:

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

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

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

Parameters

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

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

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

Return list with simulated analyses from a single session.

Parameters

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

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

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

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

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

D.crunch()
D.standardize()

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

This should output something like:

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

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

[table_of_analyses] 
–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
1    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.122986   21.273526   27.780042    2.020000   37.024281  -0.706013  -0.328878  -0.000013  0.192554
2    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.130144   21.282615   27.780042    2.020000   37.024281  -0.698974  -0.319981  -0.000013  0.199615
3    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.149219   21.299572   27.780042    2.020000   37.024281  -0.680215  -0.303383  -0.000013  0.218429
4    Session_01   ETH-1       -4.000        26.000   6.018962  10.747026   16.136616   21.233128   27.780042    2.020000   37.024281  -0.692609  -0.368421  -0.000013  0.205998
5    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.697171  -12.203054  -18.023381  -10.170000   19.875825  -0.680771  -0.290128  -0.000002  0.215054
6    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701124  -12.184422  -18.023381  -10.170000   19.875825  -0.684772  -0.271272  -0.000002  0.211041
7    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.715105  -12.195251  -18.023381  -10.170000   19.875825  -0.698923  -0.282232  -0.000002  0.196848
8    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701529  -12.204963  -18.023381  -10.170000   19.875825  -0.685182  -0.292061  -0.000002  0.210630
9    Session_01   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.711420  -12.228478  -18.023381  -10.170000   19.875825  -0.695193  -0.315859  -0.000002  0.200589
10   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.666719   22.296486   28.306614    1.710000   37.450394  -0.290459  -0.147284  -0.000014  0.609363
11   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.671553   22.291060   28.306614    1.710000   37.450394  -0.285706  -0.152592  -0.000014  0.614130
12   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.652854   22.273271   28.306614    1.710000   37.450394  -0.304093  -0.169990  -0.000014  0.595689
13   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.684168   22.263156   28.306614    1.710000   37.450394  -0.273302  -0.179883  -0.000014  0.626572
14   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.662702   22.253578   28.306614    1.710000   37.450394  -0.294409  -0.189251  -0.000014  0.605401
15   Session_01   ETH-3       -4.000        26.000   5.742374  11.161270   16.681957   22.230907   28.306614    1.710000   37.450394  -0.275476  -0.211424  -0.000014  0.624391
16   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.312044    5.395798    4.665655   -5.000000   28.907344  -0.598436  -0.268176  -0.000006  0.298996
17   Session_01     FOO       -4.000        26.000  -0.840413   2.828738    1.328123    5.307086    4.665655   -5.000000   28.907344  -0.582387  -0.356389  -0.000006  0.315092
18   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.122201   21.340606   27.780042    2.020000   37.024281  -0.706785  -0.263217  -0.000013  0.195135
19   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.134868   21.305714   27.780042    2.020000   37.024281  -0.694328  -0.297370  -0.000013  0.207564
20   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.140008   21.261931   27.780042    2.020000   37.024281  -0.689273  -0.340227  -0.000013  0.212607
21   Session_02   ETH-1       -4.000        26.000   6.018962  10.747026   16.135540   21.298472   27.780042    2.020000   37.024281  -0.693667  -0.304459  -0.000013  0.208224
22   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701213  -12.202602  -18.023381  -10.170000   19.875825  -0.684862  -0.289671  -0.000002  0.213842
23   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.685649  -12.190405  -18.023381  -10.170000   19.875825  -0.669108  -0.277327  -0.000002  0.229559
24   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.719003  -12.257955  -18.023381  -10.170000   19.875825  -0.702869  -0.345692  -0.000002  0.195876
25   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.700592  -12.204641  -18.023381  -10.170000   19.875825  -0.684233  -0.291735  -0.000002  0.214469
26   Session_02   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720426  -12.214561  -18.023381  -10.170000   19.875825  -0.704308  -0.301774  -0.000002  0.194439
27   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.673044   22.262090   28.306614    1.710000   37.450394  -0.284240  -0.180926  -0.000014  0.616730
28   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.666542   22.263401   28.306614    1.710000   37.450394  -0.290634  -0.179643  -0.000014  0.610350
29   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.680487   22.243486   28.306614    1.710000   37.450394  -0.276921  -0.199121  -0.000014  0.624031
30   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.663900   22.245175   28.306614    1.710000   37.450394  -0.293231  -0.197469  -0.000014  0.607759
31   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.674379   22.301309   28.306614    1.710000   37.450394  -0.282927  -0.142568  -0.000014  0.618039
32   Session_02   ETH-3       -4.000        26.000   5.742374  11.161270   16.660825   22.270466   28.306614    1.710000   37.450394  -0.296255  -0.172733  -0.000014  0.604742
33   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.294076    5.349940    4.665655   -5.000000   28.907344  -0.616369  -0.313776  -0.000006  0.283707
34   Session_02     FOO       -4.000        26.000  -0.840413   2.828738    1.313775    5.292121    4.665655   -5.000000   28.907344  -0.596708  -0.371269  -0.000006  0.303323
35   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.121613   21.259909   27.780042    2.020000   37.024281  -0.707364  -0.342207  -0.000013  0.194934
36   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.145714   21.304889   27.780042    2.020000   37.024281  -0.683661  -0.298178  -0.000013  0.218401
37   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.126573   21.325093   27.780042    2.020000   37.024281  -0.702485  -0.278401  -0.000013  0.199764
38   Session_03   ETH-1       -4.000        26.000   6.018962  10.747026   16.132057   21.323211   27.780042    2.020000   37.024281  -0.697092  -0.280244  -0.000013  0.205104
39   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.708448  -12.232023  -18.023381  -10.170000   19.875825  -0.692185  -0.319447  -0.000002  0.208915
40   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.714417  -12.202504  -18.023381  -10.170000   19.875825  -0.698226  -0.289572  -0.000002  0.202934
41   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.720039  -12.264469  -18.023381  -10.170000   19.875825  -0.703917  -0.352285  -0.000002  0.197300
42   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.701953  -12.228550  -18.023381  -10.170000   19.875825  -0.685611  -0.315932  -0.000002  0.215423
43   Session_03   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.704535  -12.213634  -18.023381  -10.170000   19.875825  -0.688224  -0.300836  -0.000002  0.212837
44   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.652920   22.230043   28.306614    1.710000   37.450394  -0.304028  -0.212269  -0.000014  0.594265
45   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.691485   22.261017   28.306614    1.710000   37.450394  -0.266106  -0.181975  -0.000014  0.631810
46   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.679119   22.305357   28.306614    1.710000   37.450394  -0.278266  -0.138609  -0.000014  0.619771
47   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.663623   22.327286   28.306614    1.710000   37.450394  -0.293503  -0.117161  -0.000014  0.604685
48   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.678524   22.282103   28.306614    1.710000   37.450394  -0.278851  -0.161352  -0.000014  0.619192
49   Session_03   ETH-3       -4.000        26.000   5.742374  11.161270   16.666246   22.283361   28.306614    1.710000   37.450394  -0.290925  -0.160121  -0.000014  0.607238
50   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.309929    5.340249    4.665655   -5.000000   28.907344  -0.600546  -0.323413  -0.000006  0.300148
51   Session_03     FOO       -4.000        26.000  -0.840413   2.828738    1.317548    5.334102    4.665655   -5.000000   28.907344  -0.592942  -0.329524  -0.000006  0.307676
52   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.136865   21.300298   27.780042    2.020000   37.024281  -0.692364  -0.302672  -0.000013  0.204033
53   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.133538   21.291260   27.780042    2.020000   37.024281  -0.695637  -0.311519  -0.000013  0.200762
54   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.139991   21.319865   27.780042    2.020000   37.024281  -0.689290  -0.283519  -0.000013  0.207107
55   Session_04   ETH-1       -4.000        26.000   6.018962  10.747026   16.145748   21.330075   27.780042    2.020000   37.024281  -0.683629  -0.273524  -0.000013  0.212766
56   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702989  -12.202762  -18.023381  -10.170000   19.875825  -0.686660  -0.289833  -0.000002  0.204507
57   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.692830  -12.240287  -18.023381  -10.170000   19.875825  -0.676377  -0.327811  -0.000002  0.214786
58   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.702899  -12.180291  -18.023381  -10.170000   19.875825  -0.686568  -0.267091  -0.000002  0.204598
59   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.709282  -12.282257  -18.023381  -10.170000   19.875825  -0.693029  -0.370287  -0.000002  0.198140
60   Session_04   ETH-2       -4.000        26.000  -5.995859  -5.976076  -12.679330  -12.235994  -18.023381  -10.170000   19.875825  -0.662712  -0.323466  -0.000002  0.228446
61   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.695594   22.238663   28.306614    1.710000   37.450394  -0.262066  -0.203838  -0.000014  0.634200
62   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.663504   22.286354   28.306614    1.710000   37.450394  -0.293620  -0.157194  -0.000014  0.602656
63   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666457   22.254290   28.306614    1.710000   37.450394  -0.290717  -0.188555  -0.000014  0.605558
64   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.666910   22.223232   28.306614    1.710000   37.450394  -0.290271  -0.218930  -0.000014  0.606004
65   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.679662   22.257256   28.306614    1.710000   37.450394  -0.277732  -0.185653  -0.000014  0.618539
66   Session_04   ETH-3       -4.000        26.000   5.742374  11.161270   16.676768   22.267680   28.306614    1.710000   37.450394  -0.280578  -0.175459  -0.000014  0.615693
67   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.307663    5.317330    4.665655   -5.000000   28.907344  -0.602808  -0.346202  -0.000006  0.290853
68   Session_04     FOO       -4.000        26.000  -0.840413   2.828738    1.308562    5.331400    4.665655   -5.000000   28.907344  -0.601911  -0.332212  -0.000006  0.291749
–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
def table_of_samples( data47=None, data48=None, dir='output', filename=None, save_to_file=True, print_out=True, output=None):
646def table_of_samples(
647	data47 = None,
648	data48 = None,
649	dir = 'output',
650	filename = None,
651	save_to_file = True,
652	print_out = True,
653	output = None,
654	):
655	'''
656	Print out, save to disk and/or return a combined table of samples
657	for a pair of `D47data` and `D48data` objects.
658
659	**Parameters**
660
661	+ `data47`: `D47data` instance
662	+ `data48`: `D48data` instance
663	+ `dir`: the directory in which to save the table
664	+ `filename`: the name to the csv file to write to
665	+ `save_to_file`: whether to save the table to disk
666	+ `print_out`: whether to print out the table
667	+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
668		if set to `'raw'`: return a list of list of strings
669		(e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
670	'''
671	if data47 is None:
672		if data48 is None:
673			raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
674		else:
675			return data48.table_of_samples(
676				dir = dir,
677				filename = filename,
678				save_to_file = save_to_file,
679				print_out = print_out,
680				output = output
681				)
682	else:
683		if data48 is None:
684			return data47.table_of_samples(
685				dir = dir,
686				filename = filename,
687				save_to_file = save_to_file,
688				print_out = print_out,
689				output = output
690				)
691		else:
692			out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
693			out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
694			out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:])
695
696			if save_to_file:
697				if not os.path.exists(dir):
698					os.makedirs(dir)
699				if filename is None:
700					filename = f'D47D48_samples.csv'
701				with open(f'{dir}/{filename}', 'w') as fid:
702					fid.write(make_csv(out))
703			if print_out:
704				print('\n'+pretty_table(out))
705			if output == 'raw':
706				return out
707			elif output == 'pretty':
708				return pretty_table(out)

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

Parameters

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

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

Parameters

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

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

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

Parameters

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

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

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

Parameters

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

Returns a D4xdata object derived from list.

R13_VPDB = 0.01118

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

R18_VSMOW = 0.0020052

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

LAMBDA_17 = 0.528

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

R17_VSMOW = 0.00038475

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

R18_VPDB = 0.0020672007840000003

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

R17_VPDB = 0.0003909861828790272

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

LEVENE_REF_SAMPLE = 'ETH-3'

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

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

ALPHA_18O_ACID_REACTION = 1.008129

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

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

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

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

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

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

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

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

d13C_STANDARDIZATION_METHOD = '2pt'

Method by which to standardize δ13C values:

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

Method by which to standardize δ18O values:

  • none: do not apply any δ18O standardization.
  • '1pt': within each session, offset all initial δ18O values so as to minimize the difference between final δ18OVPDB values and Nominal_d18O_VPDB (averaged over all analyses for which Nominal_d18O_VPDB is defined).
  • '2pt': within each session, apply a affine trasformation to all δ18O values so as to minimize the difference between final δ18OVPDB values and Nominal_d18O_VPDB (averaged over all analyses for which Nominal_d18O_VPDB is defined).
def make_verbal(oldfun):
1049	def make_verbal(oldfun):
1050		'''
1051		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
1052		'''
1053		@wraps(oldfun)
1054		def newfun(*args, verbose = '', **kwargs):
1055			myself = args[0]
1056			oldprefix = myself.prefix
1057			myself.prefix = oldfun.__name__
1058			if verbose != '':
1059				oldverbose = myself.verbose
1060				myself.verbose = verbose
1061			out = oldfun(*args, **kwargs)
1062			myself.prefix = oldprefix
1063			if verbose != '':
1064				myself.verbose = oldverbose
1065			return out
1066		return newfun

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

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

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

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

Log a message to self.logfile and print it out

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

Log a message to self.logfile

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

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

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

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

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

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

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

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

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

The required fields are:

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

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

Parameters

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

Read txt string in csv format to load analysis data into a D47data object.

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

The required fields are:

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

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

Parameters

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

Compute bulk composition of the working gas for each session based on the carbonate standards defined in both self.Nominal_d13C_VPDB and self.Nominal_d18O_VPDB.

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

Compute δ13CVPDB and δ18OVSMOW, by solving the generalized form of equation (17) from Brand et al. (2010), assuming that δ18OVSMOW is not too big (0 ± 50 ‰) and solving the corresponding second-order Taylor polynomial. (Appendix A of Daëron et al., 2016)

@make_verbal
def crunch(self, verbose=''):
1323	@make_verbal
1324	def crunch(self, verbose = ''):
1325		'''
1326		Compute bulk composition and raw clumped isotope anomalies for all analyses.
1327		'''
1328		for r in self:
1329			self.compute_bulk_and_clumping_deltas(r)
1330		self.standardize_d13C()
1331		self.standardize_d18O()
1332		self.msg(f"Crunched {len(self)} analyses.")

Compute bulk composition and raw clumped isotope anomalies for all analyses.

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

Fill in optional fields with default values

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

Perform δ13C standadization within each session s according to self.sessions[s]['d13C_standardization_method'], which is defined by default by D47data.refresh_sessions()as equal to self.d13C_STANDARDIZATION_METHOD, but may be redefined abitrarily at a later stage.

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

Perform δ18O standadization within each session s according to self.ALPHA_18O_ACID_REACTION and self.sessions[s]['d18O_standardization_method'], which is defined by default by D47data.refresh_sessions()as equal to self.d18O_STANDARDIZATION_METHOD, but may be redefined abitrarily at a later stage.

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

Compute δ13CVPDB, δ18OVSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis r.

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

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

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

Split unknown samples by UID (treat all analyses as different samples) or by session (treat analyses of a given sample in different sessions as different samples).

Parameters

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

Reverse the effects of D47data.split_samples().

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

After D4xdata.standardize() with method='indep_sessions', one should probably use D4xdata.combine_samples() instead to reverse the effects of D47data.split_samples() with grouping='by_uid', or w_avg() to reverse the effects of D47data.split_samples() with grouping='by_sessions' (because in that case session-averaged Δ4x values are statistically independent).

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

Assign a time field t of type float to each analysis.

If TimeTag is one of the data fields, t is equal within a given session to TimeTag minus the mean value of TimeTag for that session. Otherwise, TimeTag is by default equal to the index of each analysis in the dataset and t is defined as above.

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

Prints a report on the standardization fit. Only applicable after D4xdata.standardize(method='pooled').

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

Combine analyses of different samples to compute weighted average Δ4x and new error (co)variances corresponding to the groups defined by the sample_groups dictionary.

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

Returns a tuplet of:

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

Parameters

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

Compute absolute Δ4x values for all replicate analyses and for sample averages. If method argument is set to 'pooled', the standardization processes all sessions in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous, i.e. that their true Δ4x value does not change between sessions, (Daëron, 2021). If method argument is set to 'indep_sessions', the standardization processes each session independently, based only on anchors analyses.

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

Compute standardization error for a given session and (δ47, Δ47) composition.

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

Print out an/or save to disk a summary of the standardization results.

Parameters

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

Print out an/or save to disk a table of sessions.

Parameters

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

Print out an/or save to disk a table of analyses.

Parameters

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

Print out, save to disk and/or return the variance-covariance matrix of D4x for all unknown samples.

Parameters

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

Print out, save to disk and/or return a table of samples.

Parameters

  • dir: the directory in which to save the csv
  • filename: the name of the csv file to write to
  • save_to_file: whether to save the csv
  • print_out: whether to print out the table
  • output: if set to 'pretty': return a pretty text table (see pretty_table()); if set to 'raw': return a list of list of strings (e.g., [['header1', 'header2'], ['0.1', '0.2']])
def plot_sessions(self, dir='output', figsize=(8, 8)):
2180	def plot_sessions(self, dir = 'output', figsize = (8,8)):
2181		'''
2182		Generate session plots and save them to disk.
2183
2184		**Parameters**
2185
2186		+ `dir`: the directory in which to save the plots
2187		+ `figsize`: the width and height (in inches) of each plot
2188		'''
2189		if not os.path.exists(dir):
2190			os.makedirs(dir)
2191
2192		for session in self.sessions:
2193			sp = self.plot_single_session(session, xylimits = 'constant')
2194			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
2195			ppl.close(sp.fig)

Generate session plots and save them to disk.

Parameters

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

Compile various statistics for each sample.

For each anchor sample:

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

For each unknown sample:

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

For each anchor and unknown:

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

Compute various statistics for each session.

  • Na: Number of anchor analyses in the session
  • Nu: Number of unknown analyses in the session
  • r_d13C_VPDB: δ13CVPDB repeatability of analyses within the session
  • r_d18O_VSMOW: δ18OVSMOW repeatability of analyses within the session
  • r_D47 or r_D48: Δ4x repeatability of analyses within the session
  • a: scrambling factor
  • b: compositional slope
  • c: WG offset
  • SE_a: Model stadard erorr of a
  • SE_b: Model stadard erorr of b
  • SE_c: Model stadard erorr of c
  • scrambling_drift (boolean): whether to allow a temporal drift in the scrambling factor (a)
  • slope_drift (boolean): whether to allow a temporal drift in the compositional slope (b)
  • wg_drift (boolean): whether to allow a temporal drift in the WG offset (c)
  • a2: scrambling factor drift
  • b2: compositional slope drift
  • c2: WG offset drift
  • Np: Number of standardization parameters to fit
  • CM: model covariance matrix for (a, b, c, a2, b2, c2)
  • d13Cwg_VPDB: δ13CVPDB of WG
  • d18Owg_VSMOW: δ18OVSMOW of WG
@make_verbal
def repeatabilities(self):
2403	@make_verbal
2404	def repeatabilities(self):
2405		'''
2406		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
2407		(for all samples, for anchors, and for unknowns).
2408		'''
2409		self.msg('Computing reproducibilities for all sessions')
2410
2411		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
2412		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
2413		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
2414		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
2415		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')

Compute analytical repeatabilities for δ13CVPDB, δ18OVSMOW, Δ4x (for all samples, for anchors, and for unknowns).

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

Collect information about samples, sessions and repeatabilities.

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

Compute the χ2, root mean squared weighted deviation (i.e. reduced χ2), and corresponding degrees of freedom of the Δ4x values for samples in samples and sessions in sessions.

Only used in D4xdata.standardize() with method='indep_sessions'.

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

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

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

Weighted average Δ4x value of a group of samples, accounting for covariance.

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

Examples

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

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

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

returns the value and SE of the difference Δ4x(X) - Δ4x(Y).

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

Covariance between Δ4x values of samples

Returns the error covariance between the average Δ4x values of two samples. If if only sample_1 is specified, or if sample_1 == sample_2), returns the Δ4x variance for that sample.

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

Correlation between Δ4x errors of samples

Returns the error correlation between the average Δ4x values of two samples.

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

Generate plot for a single session

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

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

  • kde: whether to add a kernel density estimate of residuals
  • hist: whether to add a histogram of residuals (incompatible with kde)
  • histbins: specify bin edges for the histogram
  • dir: the directory in which to save the plot
  • highlight: a list of samples to highlight
  • colors: a dict of {<sample>: <color>} for all samples
  • figsize: (width, height) of figure
def simulate(self, *args, **kwargs):
2942	def simulate(self, *args, **kwargs):
2943		'''
2944		Legacy function with warning message pointing to `virtual_data()`
2945		'''
2946		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')

Legacy function with warning message pointing to virtual_data()

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

Plot temporal distribution of all analyses in the data set.

Parameters

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

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

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

Parameters

  • samples: Only these samples are processed (by default: all samples).
  • dir: where to save the plots
  • figsize: (width, height) of figure
  • subplots_adjust: passed to subplots_adjust()
  • show: whether to call matplotlib.pyplot.show() on the plot with all samples, allowing for interactive visualization/exploration in (δ13C, δ18O) space.
  • sample_color: color used for replicate markers/labels
  • analysis_color: color used for sample markers/labels
  • labeldist: distance (in inches) from replicate markers to replicate labels
  • radius: radius of the dashed circle providing scale. No circle if radius = 0.
Inherited Members
builtins.list
clear
copy
append
insert
extend
pop
remove
index
count
reverse
sort
class D47data(D4xdata):
3175class D47data(D4xdata):
3176	'''
3177	Store and process data for a large set of Δ47 analyses,
3178	usually comprising more than one analytical session.
3179	'''
3180
3181	Nominal_D4x = {
3182		'ETH-1':   0.2052,
3183		'ETH-2':   0.2085,
3184		'ETH-3':   0.6132,
3185		'ETH-4':   0.4511,
3186		'IAEA-C1': 0.3018,
3187		'IAEA-C2': 0.6409,
3188		'MERCK':   0.5135,
3189		} # I-CDES (Bernasconi et al., 2021)
3190	'''
3191	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
3192	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
3193	reference frame.
3194
3195	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
3196	```py
3197	{
3198		'ETH-1'   : 0.2052,
3199		'ETH-2'   : 0.2085,
3200		'ETH-3'   : 0.6132,
3201		'ETH-4'   : 0.4511,
3202		'IAEA-C1' : 0.3018,
3203		'IAEA-C2' : 0.6409,
3204		'MERCK'   : 0.5135,
3205	}
3206	```
3207	'''
3208
3209
3210	@property
3211	def Nominal_D47(self):
3212		return self.Nominal_D4x
3213	
3214
3215	@Nominal_D47.setter
3216	def Nominal_D47(self, new):
3217		self.Nominal_D4x = dict(**new)
3218		self.refresh()
3219
3220
3221	def __init__(self, l = [], **kwargs):
3222		'''
3223		**Parameters:** same as `D4xdata.__init__()`
3224		'''
3225		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
3226
3227
3228	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
3229		'''
3230		Find all samples for which `Teq` is specified, compute equilibrium Δ47
3231		value for that temperature, and add treat these samples as additional anchors.
3232
3233		**Parameters**
3234
3235		+ `fCo2eqD47`: Which CO2 equilibrium law to use
3236		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
3237		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
3238		+ `priority`: if `replace`: forget old anchors and only use the new ones;
3239		if `new`: keep pre-existing anchors but update them in case of conflict
3240		between old and new Δ47 values;
3241		if `old`: keep pre-existing anchors but preserve their original Δ47
3242		values in case of conflict.
3243		'''
3244		f = {
3245			'petersen': fCO2eqD47_Petersen,
3246			'wang': fCO2eqD47_Wang,
3247			}[fCo2eqD47]
3248		foo = {}
3249		for r in self:
3250			if 'Teq' in r:
3251				if r['Sample'] in foo:
3252					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
3253				else:
3254					foo[r['Sample']] = f(r['Teq'])
3255			else:
3256					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
3257
3258		if priority == 'replace':
3259			self.Nominal_D47 = {}
3260		for s in foo:
3261			if priority != 'old' or s not in self.Nominal_D47:
3262				self.Nominal_D47[s] = foo[s]

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

D47data(l=[], **kwargs)
3221	def __init__(self, l = [], **kwargs):
3222		'''
3223		**Parameters:** same as `D4xdata.__init__()`
3224		'''
3225		D4xdata.__init__(self, l = l, mass = '47', **kwargs)

Parameters: same as D4xdata.__init__()

Nominal_D4x = {'ETH-1': 0.2052, 'ETH-2': 0.2085, 'ETH-3': 0.6132, 'ETH-4': 0.4511, 'IAEA-C1': 0.3018, 'IAEA-C2': 0.6409, 'MERCK': 0.5135}

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

By default equal to (after Bernasconi et al. (2021)):

{
        'ETH-1'   : 0.2052,
        'ETH-2'   : 0.2085,
        'ETH-3'   : 0.6132,
        'ETH-4'   : 0.4511,
        'IAEA-C1' : 0.3018,
        'IAEA-C2' : 0.6409,
        'MERCK'   : 0.5135,
}
def D47fromTeq(self, fCo2eqD47='petersen', priority='new'):
3228	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
3229		'''
3230		Find all samples for which `Teq` is specified, compute equilibrium Δ47
3231		value for that temperature, and add treat these samples as additional anchors.
3232
3233		**Parameters**
3234
3235		+ `fCo2eqD47`: Which CO2 equilibrium law to use
3236		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
3237		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
3238		+ `priority`: if `replace`: forget old anchors and only use the new ones;
3239		if `new`: keep pre-existing anchors but update them in case of conflict
3240		between old and new Δ47 values;
3241		if `old`: keep pre-existing anchors but preserve their original Δ47
3242		values in case of conflict.
3243		'''
3244		f = {
3245			'petersen': fCO2eqD47_Petersen,
3246			'wang': fCO2eqD47_Wang,
3247			}[fCo2eqD47]
3248		foo = {}
3249		for r in self:
3250			if 'Teq' in r:
3251				if r['Sample'] in foo:
3252					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
3253				else:
3254					foo[r['Sample']] = f(r['Teq'])
3255			else:
3256					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
3257
3258		if priority == 'replace':
3259			self.Nominal_D47 = {}
3260		for s in foo:
3261			if priority != 'old' or s not in self.Nominal_D47:
3262				self.Nominal_D47[s] = foo[s]

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

Parameters

  • fCo2eqD47: Which CO2 equilibrium law to use (petersen: Petersen et al. (2019); wang: Wang et al. (2019)).
  • priority: if replace: forget old anchors and only use the new ones; if new: keep pre-existing anchors but update them in case of conflict between old and new Δ47 values; if old: keep pre-existing anchors but preserve their original Δ47 values in case of conflict.
class D48data(D4xdata):
3267class D48data(D4xdata):
3268	'''
3269	Store and process data for a large set of Δ48 analyses,
3270	usually comprising more than one analytical session.
3271	'''
3272
3273	Nominal_D4x = {
3274		'ETH-1':  0.138,
3275		'ETH-2':  0.138,
3276		'ETH-3':  0.270,
3277		'ETH-4':  0.223,
3278		'GU-1':  -0.419,
3279		} # (Fiebig et al., 2019, 2021)
3280	'''
3281	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
3282	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
3283	reference frame.
3284
3285	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
3286	Fiebig et al. (in press)):
3287
3288	```py
3289	{
3290		'ETH-1' :  0.138,
3291		'ETH-2' :  0.138,
3292		'ETH-3' :  0.270,
3293		'ETH-4' :  0.223,
3294		'GU-1'  : -0.419,
3295	}
3296	```
3297	'''
3298
3299
3300	@property
3301	def Nominal_D48(self):
3302		return self.Nominal_D4x
3303
3304	
3305	@Nominal_D48.setter
3306	def Nominal_D48(self, new):
3307		self.Nominal_D4x = dict(**new)
3308		self.refresh()
3309
3310
3311	def __init__(self, l = [], **kwargs):
3312		'''
3313		**Parameters:** same as `D4xdata.__init__()`
3314		'''
3315		D4xdata.__init__(self, l = l, mass = '48', **kwargs)

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

D48data(l=[], **kwargs)
3311	def __init__(self, l = [], **kwargs):
3312		'''
3313		**Parameters:** same as `D4xdata.__init__()`
3314		'''
3315		D4xdata.__init__(self, l = l, mass = '48', **kwargs)

Parameters: same as D4xdata.__init__()

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

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

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

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