D47crunch
@@ -329,19 +323,21 @@1.1 Installation
For those wishing to experiment with the bleeding-edge development version, this can be done through the following steps:
-
-
- Download the
dev
branch source code here and rename it toD47crunch.py
.
+ - Download the
dev
branch source code here and rename it toD47crunch.py
. - 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 importD47crunch
:
+ - 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 importD47crunch
:
- copy
import sys
+
+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).
@@ -349,7 +345,8 @@1.2 Usage
Start by creating a file named rawdata.csv
with the following contents:
UID, Sample, d45, d46, d47, d48, d49
+
+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
@@ -358,23 +355,29 @@ 1.2 Usage
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()
-
import D47crunch
+mydata = D47crunch.D47data()
+
+For now, this object is empty:
->>> print(mydata)
+
+>>> print(mydata)
[]
-
+
+To load the analyses saved in rawdata.csv
into our D47data
object and process the data:
mydata.read('rawdata.csv')
+
+mydata.read('rawdata.csv')
# compute δ13C, δ18O of working gas:
mydata.wg()
@@ -385,11 +388,13 @@ 1.2 Usage
# 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)
+
+>>> mydata.summary(verbose = True, save_to_file = False)
[summary]
––––––––––––––––––––––––––––––– –––––––––
N samples (anchors + unknowns) 5 (3 + 2)
@@ -403,13 +408,15 @@ 1.2 Usage
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)
+
+>>> 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
@@ -420,13 +427,15 @@ 1.2 Usage
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)
+
+>>> 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
@@ -440,7 +449,8 @@ 1.2 Usage
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
@@ -450,7 +460,8 @@2.1 Simulate a virtual data
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 *
+
+from D47crunch import *
args = dict(
samples = [
@@ -483,13 +494,15 @@ 2.1 Simulate a virtual data
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 D47crunch import *
from random import shuffle
# generate virtual data:
@@ -521,12 +534,15 @@ 2.2 Control data quality
# 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')
-
data47.plot_distribution_of_analyses(filename = 'time_distribution.pdf')
+
+2.2.1 Plotting t
2.2.2 Generating session plots
-data47.plot_sessions()
-
+
+data47.plot_sessions()
+
+
data47.plot_sessions()
-
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.
@@ -543,8 +561,10 @@2.2.2 Generating session plots
2.2.3 Plotting Δ47 or Δ48 residuals
-data47.plot_residuals(filename = 'residuals.pdf')
-
data47.plot_residuals(filename = 'residuals.pdf')
+
+D4xdata.Nominal_d13C_VPDB
D4xdata.Nominal_d18O_VPDB
D47data.Nominal_D4x
(also accessible through D47data.Nominal_D47
)D48data.Nominal_D4x
(also accessible through D48data.Nominal_D48
)D47data.Nominal_D4x
(also accessible through D47data.Nominal_D47
)D48data.Nominal_D4x
(also accessible through D48data.Nominal_D48
)17O correction parameters are defined by:
@@ -574,23 +594,24 @@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:
Option 1: by redefining D4xdata.R17_VSMOW
and D47data.Nominal_D47
_before_ creating a D47data
object:
from D47crunch import D4xdata, D47data
+
+from D47crunch import D4xdata, D47data
# redefine R17_VSMOW:
-D4xdata.R17_VSMOW = 0.00037 # new value
+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
+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]
+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
+D47data.Nominal_D4x['ETH-3'] = 0.600
# only now create D47data object:
mydata = D47data()
@@ -603,11 +624,13 @@ # 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
+
+from D47crunch import D47data
# first create D47data object:
mydata = D47data()
@@ -633,11 +656,13 @@ # 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
+
+from D47crunch import D47data
# create two D47data objects:
foo = D47data()
@@ -668,13 +693,15 @@ # 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 *
+
+from D47crunch import *
# generate virtual data:
args = dict(
@@ -708,7 +735,8 @@ 2.4 Process paired Δtable_of_sessions(data47, data48)
table_of_samples(data47, data48)
table_of_analyses(data47, data48)
-
+
+Expected output:
@@ -761,3075 +789,3092 @@2.4 Process paired Δ
-
- View Source
- '''
-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](https://doi.org/10.1029/2020GC009592)).
-
-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.
-
-.. include:: ../docs/tutorial.md
-.. include:: ../docs/howto.md
-'''
-
-__docformat__ = "restructuredtext"
-__author__ = 'Mathieu Daëron'
-__contact__ = 'daeron@lsce.ipsl.fr'
-__copyright__ = 'Copyright (c) 2022 Mathieu Daëron'
-__license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause'
-__date__ = '2022-02-27'
-__version__ = '2.0.3'
-
-import os
-import numpy as np
-from statistics import stdev
-from scipy.stats import t as tstudent
-from scipy.stats import levene
-from scipy.interpolate import interp1d
-from numpy import linalg
-from lmfit import Minimizer, Parameters, report_fit
-from matplotlib import pyplot as ppl
-from datetime import datetime as dt
-from functools import wraps
-from colorsys import hls_to_rgb
-from matplotlib import rcParams
-
-rcParams['font.family'] = 'sans-serif'
-rcParams['font.sans-serif'] = 'Helvetica'
-rcParams['font.size'] = 10
-rcParams['mathtext.fontset'] = 'custom'
-rcParams['mathtext.rm'] = 'sans'
-rcParams['mathtext.bf'] = 'sans:bold'
-rcParams['mathtext.it'] = 'sans:italic'
-rcParams['mathtext.cal'] = 'sans:italic'
-rcParams['mathtext.default'] = 'rm'
-rcParams['xtick.major.size'] = 4
-rcParams['xtick.major.width'] = 1
-rcParams['ytick.major.size'] = 4
-rcParams['ytick.major.width'] = 1
-rcParams['axes.grid'] = False
-rcParams['axes.linewidth'] = 1
-rcParams['grid.linewidth'] = .75
-rcParams['grid.linestyle'] = '-'
-rcParams['grid.alpha'] = .15
-rcParams['savefig.dpi'] = 150
-
-Petersen_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]])
-_fCO2eqD47_Petersen = interp1d(Petersen_etal_CO2eqD47[:,0], Petersen_etal_CO2eqD47[:,1])
-def fCO2eqD47_Petersen(T):
- '''
- CO2 equilibrium Δ47 value as a function of T (in degrees C)
- according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).
-
- '''
- return float(_fCO2eqD47_Petersen(T))
-
-
-Wang_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]])
-_fCO2eqD47_Wang = interp1d(Wang_etal_CO2eqD47[:,0] - 0.15, Wang_etal_CO2eqD47[:,1])
-def fCO2eqD47_Wang(T):
- '''
- CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
- according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
- (supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
- '''
- return float(_fCO2eqD47_Wang(T))
-
-
-def correlated_sum(X, C, w = None):
- '''
- 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`.
- '''
- if w is None:
- w = [1 for x in X]
- return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5
-
-
-def make_csv(x, hsep = ',', vsep = '\n'):
- '''
- 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**
-
- ```py
- print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))
- ```
-
- outputs:
-
- ```py
- a,b,c
- d,e,f
- ```
- '''
- return vsep.join([hsep.join(l) for l in x])
-
-
-def pf(txt):
- '''
- Modify string `txt` to follow `lmfit.Parameter()` naming rules.
- '''
- return txt.replace('-','_').replace('.','_').replace(' ','_')
-
-
-def smart_type(x):
- '''
- 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.
- '''
- try:
- y = float(x)
- except ValueError:
- return x
- if '.' not in x:
- return int(y)
- return y
-
-
-def pretty_table(x, header = 1, hsep = ' ', vsep = '–', align = '<'):
- '''
- 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**
-
- ```py
- 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
- -- ------ ---
- ```
-
- '''
- txt = []
- widths = [np.max([len(e) for e in c]) for c in zip(*x)]
-
- if len(widths) > len(align):
- align += '>' * (len(widths)-len(align))
- sepline = hsep.join([vsep*w for w in widths])
- txt += [sepline]
- for k,l in enumerate(x):
- if k and k == header:
- txt += [sepline]
- txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])]
- txt += [sepline]
- txt += ['']
- return '\n'.join(txt)
-
-
-def transpose_table(x):
- '''
- Transpose a list if lists
-
- **Parameters**
-
- + `x`: a list of lists
-
- **Example**
-
- ```py
- x = [[1, 2], [3, 4]]
- print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
- ```
- '''
- return [[e for e in c] for c in zip(*x)]
-
-
-def w_avg(X, sX) :
- '''
- 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()`:
-
- ```python
- foo = [(0, 1), (1, 0.5), (2, 0.5)]
- print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)
- ```
- '''
- X = [ x for x in X ]
- sX = [ sx for sx in sX ]
- W = [ sx**-2 for sx in sX ]
- W = [ w/sum(W) for w in W ]
- Xavg = sum([ w*x for w,x in zip(W,X) ])
- sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5
- return Xavg, sXavg
-
-
-def read_csv(filename, sep = ''):
- '''
- 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 `\t`,
- whichever appers most often in the contents of `filename`.
- '''
- with open(filename) as fid:
- txt = fid.read()
-
- if sep == '':
- sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
- txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
- return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]]
-
-
-def simulate_single_analysis(
- sample = 'MYSAMPLE',
- d13Cwg_VPDB = -4., d18Owg_VSMOW = 26.,
- d13C_VPDB = None, d18O_VPDB = None,
- D47 = None, D48 = None, D49 = 0., D17O = 0.,
- a47 = 1., b47 = 0., c47 = -0.9,
- a48 = 1., b48 = 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,
- ):
- '''
- 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']`.
- '''
-
- if Nominal_d13C_VPDB is None:
- Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB
-
- if Nominal_d18O_VPDB is None:
- Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB
-
- if ALPHA_18O_ACID_REACTION is None:
- ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION
-
- if R13_VPDB is None:
- R13_VPDB = D4xdata().R13_VPDB
-
- if R17_VSMOW is None:
- R17_VSMOW = D4xdata().R17_VSMOW
-
- if R18_VSMOW is None:
- R18_VSMOW = D4xdata().R18_VSMOW
-
- if LAMBDA_17 is None:
- LAMBDA_17 = D4xdata().LAMBDA_17
-
- if R18_VPDB is None:
- R18_VPDB = D4xdata().R18_VPDB
-
- R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17
-
- if Nominal_D47 is None:
- Nominal_D47 = D47data().Nominal_D47
-
- if Nominal_D48 is None:
- Nominal_D48 = D48data().Nominal_D48
-
- if d13C_VPDB is None:
- if sample in Nominal_d13C_VPDB:
- d13C_VPDB = Nominal_d13C_VPDB[sample]
- else:
- raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.")
-
- if d18O_VPDB is None:
- if sample in Nominal_d18O_VPDB:
- d18O_VPDB = Nominal_d18O_VPDB[sample]
- else:
- raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.")
-
- if D47 is None:
- if sample in Nominal_D47:
- D47 = Nominal_D47[sample]
- else:
- raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.")
-
- if D48 is None:
- if sample in Nominal_D48:
- D48 = Nominal_D48[sample]
- else:
- raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.")
-
- X = D4xdata()
- X.R13_VPDB = R13_VPDB
- X.R17_VSMOW = R17_VSMOW
- X.R18_VSMOW = R18_VSMOW
- X.LAMBDA_17 = LAMBDA_17
- X.R18_VPDB = R18_VPDB
- X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17
-
- R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios(
- R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000),
- R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000),
- )
- R45, R46, R47, R48, R49 = X.compute_isobar_ratios(
- R13 = R13_VPDB * (1 + d13C_VPDB/1000),
- R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
- D17O=D17O, D47=D47, D48=D48, D49=D49,
- )
- R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios(
- R13 = R13_VPDB * (1 + d13C_VPDB/1000),
- R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
- D17O=D17O,
- )
-
- d45 = 1000 * (R45/R45wg - 1)
- d46 = 1000 * (R46/R46wg - 1)
- d47 = 1000 * (R47/R47wg - 1)
- d48 = 1000 * (R48/R48wg - 1)
- d49 = 1000 * (R49/R49wg - 1)
-
- for k in range(3): # dumb iteration to adjust for small changes in d47
- R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch
- R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch
- d47 = 1000 * (R47raw/R47wg - 1)
- d48 = 1000 * (R48raw/R48wg - 1)
-
- return dict(
- Sample = sample,
- D17O = D17O,
- d13Cwg_VPDB = d13Cwg_VPDB,
- d18Owg_VSMOW = d18Owg_VSMOW,
- d45 = d45,
- d46 = d46,
- d47 = d47,
- d48 = d48,
- d49 = d49,
- )
-
-
-def virtual_data(
- samples = [],
- a47 = 1., b47 = 0., c47 = -0.9,
- a48 = 1., b48 = 0., c48 = -0.45,
- rD47 = 0.015, rD48 = 0.045,
- d13Cwg_VPDB = None, d18Owg_VSMOW = None,
- session = None,
- Nominal_D47 = None, Nominal_D48 = None,
- Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
- ALPHA_18O_ACID_REACTION = None,
- R13_VPDB = None,
- R17_VSMOW = None,
- R18_VSMOW = None,
- LAMBDA_17 = None,
- R18_VPDB = None,
- seed = 0,
- ):
- '''
- Return list with simulated analyses from a single session.
-
- **Parameters**
-
- + `samples`: a list of entries; each entry is a dictionary with the following fields:
- * `Sample`: the name of the sample
- * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
- * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
- * `N`: how many analyses to generate for this sample
- + `a47`: scrambling factor for Δ47
- + `b47`: compositional nonlinearity for Δ47
- + `c47`: working gas offset for Δ47
- + `a48`: scrambling factor for Δ48
- + `b48`: compositional nonlinearity for Δ48
- + `c48`: working gas offset for Δ48
- + `rD47`: analytical repeatability of Δ47
- + `rD48`: analytical repeatability of Δ48
- + `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
- (by default equal to the `simulate_single_analysis` default values)
- + `session`: name of the session (no name by default)
- + `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
- if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
- + `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
- δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
- (by default equal to the `simulate_single_analysis` defaults)
- + `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
- (by default equal to the `simulate_single_analysis` defaults)
- + `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
- correction parameters (by default equal to the `simulate_single_analysis` default)
- + `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations
-
-
- Here is an example of using this method to generate an arbitrary combination of
- anchors and unknowns for a bunch of sessions:
-
- ```py
- 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
- ––– –––––––––– –––––– ––––––––––– –––––––––––– ––––––––– ––––––––– –––––––––– –––––––––– –––––––––– –––––––––– –––––––––– ––––––––– ––––––––– ––––––––– ––––––––
- ```
- '''
-
- kwargs = locals().copy()
-
- from numpy import random as nprandom
- if seed:
- rng = nprandom.default_rng(seed)
- else:
- rng = nprandom.default_rng()
-
- N = sum([s['N'] for s in samples])
- errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
- errors47 *= rD47 / stdev(errors47) # scale errors to rD47
- errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
- errors48 *= rD48 / stdev(errors48) # scale errors to rD48
-
- k = 0
- out = []
- for s in samples:
- kw = {}
- kw['sample'] = s['Sample']
- kw = {
- **kw,
- **{var: kwargs[var]
- for var in [
- 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
- 'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
- 'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
- 'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
- ]
- if kwargs[var] is not None},
- **{var: s[var]
- for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
- if var in s},
- }
-
- sN = s['N']
- while sN:
- out.append(simulate_single_analysis(**kw))
- out[-1]['d47'] += errors47[k] * a47
- out[-1]['d48'] += errors48[k] * a48
- sN -= 1
- k += 1
-
- if session is not None:
- for r in out:
- r['Session'] = session
- return out
-
-def table_of_samples(
- data47 = None,
- data48 = None,
- dir = 'output',
- filename = None,
- save_to_file = True,
- print_out = True,
- output = None,
- ):
- '''
- 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']]`)
- '''
- if data47 is None:
- if data48 is None:
- raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
- else:
- return data48.table_of_samples(
- dir = dir,
- filename = filename,
- save_to_file = save_to_file,
- print_out = print_out,
- output = output
- )
- else:
- if data48 is None:
- return data47.table_of_samples(
- dir = dir,
- filename = filename,
- save_to_file = save_to_file,
- print_out = print_out,
- output = output
- )
- else:
- out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
- out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
- out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:])
-
- if save_to_file:
- if not os.path.exists(dir):
- os.makedirs(dir)
- if filename is None:
- filename = f'D47D48_samples.csv'
- with open(f'{dir}/{filename}', 'w') as fid:
- fid.write(make_csv(out))
- if print_out:
- print('\n'+pretty_table(out))
- if output == 'raw':
- return out
- elif output == 'pretty':
- return pretty_table(out)
-
-
-def table_of_sessions(
- data47 = None,
- data48 = None,
- dir = 'output',
- filename = None,
- save_to_file = True,
- print_out = True,
- output = None,
- ):
- '''
- 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']]`)
- '''
- if data47 is None:
- if data48 is None:
- raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
- else:
- return data48.table_of_sessions(
- dir = dir,
- filename = filename,
- save_to_file = save_to_file,
- print_out = print_out,
- output = output
- )
- else:
- if data48 is None:
- return data47.table_of_sessions(
- dir = dir,
- filename = filename,
- save_to_file = save_to_file,
- print_out = print_out,
- output = output
- )
- else:
- out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
- out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
- for k,x in enumerate(out47[0]):
- if k>7:
- out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47')
- out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48')
- out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:])
-
- if save_to_file:
- if not os.path.exists(dir):
- os.makedirs(dir)
- if filename is None:
- filename = f'D47D48_sessions.csv'
- with open(f'{dir}/{filename}', 'w') as fid:
- fid.write(make_csv(out))
- if print_out:
- print('\n'+pretty_table(out))
- if output == 'raw':
- return out
- elif output == 'pretty':
- return pretty_table(out)
-
-
-def table_of_analyses(
- data47 = None,
- data48 = None,
- dir = 'output',
- filename = None,
- save_to_file = True,
- print_out = True,
- output = None,
- ):
- '''
- 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']]`)
- '''
- if data47 is None:
- if data48 is None:
- raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
- else:
- return data48.table_of_analyses(
- dir = dir,
- filename = filename,
- save_to_file = save_to_file,
- print_out = print_out,
- output = output
- )
- else:
- if data48 is None:
- return data47.table_of_analyses(
- dir = dir,
- filename = filename,
- save_to_file = save_to_file,
- print_out = print_out,
- output = output
- )
- else:
- out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
- out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
-
- if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical
- out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:])
- else:
- out47[0][1] = 'Session_47'
- out48[0][1] = 'Session_48'
- out47 = transpose_table(out47)
- out48 = transpose_table(out48)
- out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:])
-
- if save_to_file:
- if not os.path.exists(dir):
- os.makedirs(dir)
- if filename is None:
- filename = f'D47D48_sessions.csv'
- with open(f'{dir}/{filename}', 'w') as fid:
- fid.write(make_csv(out))
- if print_out:
- print('\n'+pretty_table(out))
- if output == 'raw':
- return out
- elif output == 'pretty':
- return pretty_table(out)
-
-
-class D4xdata(list):
- '''
- Store and process data for a large set of Δ47 and/or Δ48
- analyses, usually comprising more than one analytical session.
- '''
-
- ### 17O CORRECTION PARAMETERS
- R13_VPDB = 0.01118 # (Chang & Li, 1990)
- '''
- Absolute (13C/12C) ratio of VPDB.
- By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm))
- '''
-
- R18_VSMOW = 0.0020052 # (Baertschi, 1976)
- '''
- Absolute (18O/16C) ratio of VSMOW.
- By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1))
- '''
-
- LAMBDA_17 = 0.528 # (Barkan & Luz, 2005)
- '''
- Mass-dependent exponent for triple oxygen isotopes.
- By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250))
- '''
-
- R17_VSMOW = 0.00038475 # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB)
- '''
- Absolute (17O/16C) ratio of VSMOW.
- By default equal to 0.00038475
- ([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011),
- rescaled to `R13_VPDB`)
- '''
-
- R18_VPDB = R18_VSMOW * 1.03092
- '''
- Absolute (18O/16C) ratio of VPDB.
- By definition equal to `R18_VSMOW * 1.03092`.
- '''
-
- R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17
- '''
- 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](https://en.wikipedia.org/wiki/Levene%27s_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 = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6) # (Kim et al., 2007, calcite)
- '''
- 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](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)).
- '''
-
- Nominal_d13C_VPDB = {
- 'ETH-1': 2.02,
- 'ETH-2': -10.17,
- 'ETH-3': 1.71,
- } # (Bernasconi et al., 2018)
- '''
- Nominal δ13C_VPDB 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)](https://doi.org/10.1029/2017GC007385).
- '''
-
- Nominal_d18O_VPDB = {
- 'ETH-1': -2.19,
- 'ETH-2': -18.69,
- 'ETH-3': -1.78,
- } # (Bernasconi et al., 2018)
- '''
- Nominal δ18O_VPDB 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)](https://doi.org/10.1029/2017GC007385).
- '''
-
- 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 δ13C_VPDB 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 δ13C_VPDB
- 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 δ18O_VPDB 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 δ18O_VPDB
- values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB`
- is defined).
- '''
-
- def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
- '''
- **Parameters**
-
- + `l`: a list of dictionaries, with each dictionary including at least the keys
- `Sample`, `d45`, `d46`, and `d47` or `d48`.
- + `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`.
- '''
- self._4x = mass
- self.verbose = verbose
- self.prefix = 'D4xdata'
- self.logfile = logfile
- list.__init__(self, l)
- self.Nf = None
- self.repeatability = {}
- self.refresh(session = session)
-
-
- def make_verbal(oldfun):
- '''
- Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
- '''
- @wraps(oldfun)
- def newfun(*args, verbose = '', **kwargs):
- myself = args[0]
- oldprefix = myself.prefix
- myself.prefix = oldfun.__name__
- if verbose != '':
- oldverbose = myself.verbose
- myself.verbose = verbose
- out = oldfun(*args, **kwargs)
- myself.prefix = oldprefix
- if verbose != '':
- myself.verbose = oldverbose
- return out
- return newfun
-
-
- def msg(self, txt):
- '''
- Log a message to `self.logfile`, and print it out if `verbose = True`
- '''
- self.log(txt)
- if self.verbose:
- print(f'{f"[{self.prefix}]":<16} {txt}')
-
-
- def vmsg(self, txt):
- '''
- Log a message to `self.logfile` and print it out
- '''
- self.log(txt)
- print(txt)
-
-
- def log(self, *txts):
- '''
- Log a message to `self.logfile`
- '''
- if self.logfile:
- with open(self.logfile, 'a') as fid:
- for txt in txts:
- fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
-
-
- def refresh(self, session = 'mySession'):
- '''
- Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
- '''
- self.fill_in_missing_info(session = session)
- self.refresh_sessions()
- self.refresh_samples()
-
-
- def refresh_sessions(self):
- '''
- Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
- to `False` for all sessions.
- '''
- self.sessions = {
- s: {'data': [r for r in self if r['Session'] == s]}
- for s in sorted({r['Session'] for r in self})
- }
- for s in self.sessions:
- self.sessions[s]['scrambling_drift'] = False
- self.sessions[s]['slope_drift'] = False
- self.sessions[s]['wg_drift'] = False
- self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
- self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
-
-
- def refresh_samples(self):
- '''
- Define `self.samples`, `self.anchors`, and `self.unknowns`.
- '''
- self.samples = {
- s: {'data': [r for r in self if r['Sample'] == s]}
- for s in sorted({r['Sample'] for r in self})
- }
- self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
- self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
-
-
- def read(self, filename, sep = '', 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
- '''
- with open(filename) as fid:
- self.input(fid.read(), sep = sep, session = session)
-
-
- def input(self, txt, sep = '', session = ''):
- '''
- 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 `\t`,
- whichever appers most often in `txt`.
- + `session`: set `Session` field to this string for all analyses
- '''
- if sep == '':
- sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
- txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
- 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:]]
-
- if session != '':
- for r in data:
- r['Session'] = session
-
- self += data
- self.refresh()
-
-
- @make_verbal
- def wg(self, samples = None, a18_acid = None):
- '''
- 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`.
- '''
-
- self.msg('Computing WG composition:')
-
- if a18_acid is None:
- a18_acid = self.ALPHA_18O_ACID_REACTION
- if samples is None:
- samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
-
- assert a18_acid, f'Acid fractionation factor should not be zero.'
-
- samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
- R45R46_standards = {}
- for sample in samples:
- d13C_vpdb = self.Nominal_d13C_VPDB[sample]
- d18O_vpdb = self.Nominal_d18O_VPDB[sample]
- R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
- R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
- R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
-
- C12_s = 1 / (1 + R13_s)
- C13_s = R13_s / (1 + R13_s)
- C16_s = 1 / (1 + R17_s + R18_s)
- C17_s = R17_s / (1 + R17_s + R18_s)
- C18_s = R18_s / (1 + R17_s + R18_s)
-
- C626_s = C12_s * C16_s ** 2
- C627_s = 2 * C12_s * C16_s * C17_s
- C628_s = 2 * C12_s * C16_s * C18_s
- C636_s = C13_s * C16_s ** 2
- C637_s = 2 * C13_s * C16_s * C17_s
- C727_s = C12_s * C17_s ** 2
-
- R45_s = (C627_s + C636_s) / C626_s
- R46_s = (C628_s + C637_s + C727_s) / C626_s
- R45R46_standards[sample] = (R45_s, R46_s)
-
- for s in self.sessions:
- db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
- assert db, f'No sample from {samples} found in session "{s}".'
-# dbsamples = sorted({r['Sample'] for r in db})
-
- X = [r['d45'] for r in db]
- Y = [R45R46_standards[r['Sample']][0] for r in db]
- x1, x2 = np.min(X), np.max(X)
-
- if x1 < x2:
- wgcoord = x1/(x1-x2)
- else:
- wgcoord = 999
-
- if wgcoord < -.5 or wgcoord > 1.5:
- # unreasonable to extrapolate to d45 = 0
- R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
- else :
- # d45 = 0 is reasonably well bracketed
- R45_wg = np.polyfit(X, Y, 1)[1]
-
- X = [r['d46'] for r in db]
- Y = [R45R46_standards[r['Sample']][1] for r in db]
- x1, x2 = np.min(X), np.max(X)
-
- if x1 < x2:
- wgcoord = x1/(x1-x2)
- else:
- wgcoord = 999
-
- if wgcoord < -.5 or wgcoord > 1.5:
- # unreasonable to extrapolate to d46 = 0
- R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
- else :
- # d46 = 0 is reasonably well bracketed
- R46_wg = np.polyfit(X, Y, 1)[1]
-
- d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
-
- self.msg(f'Session {s} WG: δ13C_VPDB = {d13Cwg_VPDB:.3f} δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
-
- self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
- self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
- for r in self.sessions[s]['data']:
- r['d13Cwg_VPDB'] = d13Cwg_VPDB
- r['d18Owg_VSMOW'] = d18Owg_VSMOW
-
-
- def compute_bulk_delta(self, R45, R46, D17O = 0):
- '''
- Compute δ13C_VPDB and δ18O_VSMOW,
- by solving the generalized form of equation (17) from
- [Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
- assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
- solving the corresponding second-order Taylor polynomial.
- (Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
- '''
-
- K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
-
- A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
- B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
- C = 2 * self.R18_VSMOW
- D = -R46
-
- aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
- bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
- cc = A + B + C + D
-
- d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
-
- R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
- R17 = K * R18 ** self.LAMBDA_17
- R13 = R45 - 2 * R17
-
- d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
-
- return d13C_VPDB, d18O_VSMOW
-
-
- @make_verbal
- def crunch(self, verbose = ''):
- '''
- Compute bulk composition and raw clumped isotope anomalies for all analyses.
- '''
- for r in self:
- self.compute_bulk_and_clumping_deltas(r)
- self.standardize_d13C()
- self.standardize_d18O()
- self.msg(f"Crunched {len(self)} analyses.")
-
-
- def fill_in_missing_info(self, session = 'mySession'):
- '''
- Fill in optional fields with default values
- '''
- for i,r in enumerate(self):
- if 'D17O' not in r:
- r['D17O'] = 0.
- if 'UID' not in r:
- r['UID'] = f'{i+1}'
- if 'Session' not in r:
- r['Session'] = session
- for k in ['d47', 'd48', 'd49']:
- if k not in r:
- r[k] = np.nan
-
-
- def standardize_d13C(self):
- '''
- 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.
- '''
- for s in self.sessions:
- if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
- 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]
- X,Y = zip(*XY)
- if self.sessions[s]['d13C_standardization_method'] == '1pt':
- offset = np.mean(Y) - np.mean(X)
- for r in self.sessions[s]['data']:
- r['d13C_VPDB'] += offset
- elif self.sessions[s]['d13C_standardization_method'] == '2pt':
- a,b = np.polyfit(X,Y,1)
- for r in self.sessions[s]['data']:
- r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
-
- def standardize_d18O(self):
- '''
- 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.
- '''
- for s in self.sessions:
- if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
- 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]
- X,Y = zip(*XY)
- Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
- if self.sessions[s]['d18O_standardization_method'] == '1pt':
- offset = np.mean(Y) - np.mean(X)
- for r in self.sessions[s]['data']:
- r['d18O_VSMOW'] += offset
- elif self.sessions[s]['d18O_standardization_method'] == '2pt':
- a,b = np.polyfit(X,Y,1)
- for r in self.sessions[s]['data']:
- r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
-
-
- def compute_bulk_and_clumping_deltas(self, r):
- '''
- Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
- '''
-
- # Compute working gas R13, R18, and isobar ratios
- R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
- R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
- R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
-
- # Compute analyte isobar ratios
- R45 = (1 + r['d45'] / 1000) * R45_wg
- R46 = (1 + r['d46'] / 1000) * R46_wg
- R47 = (1 + r['d47'] / 1000) * R47_wg
- R48 = (1 + r['d48'] / 1000) * R48_wg
- R49 = (1 + r['d49'] / 1000) * R49_wg
-
- r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
- R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
- R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
-
- # Compute stochastic isobar ratios of the analyte
- R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
- R13, R18, D17O = r['D17O']
- )
-
- # Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
- # and raise a warning if the corresponding anomalies exceed 0.02 ppm.
- if (R45 / R45stoch - 1) > 5e-8:
- self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
- if (R46 / R46stoch - 1) > 5e-8:
- self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
-
- # Compute raw clumped isotope anomalies
- r['D47raw'] = 1000 * (R47 / R47stoch - 1)
- r['D48raw'] = 1000 * (R48 / R48stoch - 1)
- r['D49raw'] = 1000 * (R49 / R49stoch - 1)
-
-
- def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
- '''
- 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.
- '''
-
- # Compute R17
- R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
-
- # Compute isotope concentrations
- C12 = (1 + R13) ** -1
- C13 = C12 * R13
- C16 = (1 + R17 + R18) ** -1
- C17 = C16 * R17
- C18 = C16 * R18
-
- # Compute stochastic isotopologue concentrations
- C626 = C16 * C12 * C16
- C627 = C16 * C12 * C17 * 2
- C628 = C16 * C12 * C18 * 2
- C636 = C16 * C13 * C16
- C637 = C16 * C13 * C17 * 2
- C638 = C16 * C13 * C18 * 2
- C727 = C17 * C12 * C17
- C728 = C17 * C12 * C18 * 2
- C737 = C17 * C13 * C17
- C738 = C17 * C13 * C18 * 2
- C828 = C18 * C12 * C18
- C838 = C18 * C13 * C18
-
- # Compute stochastic isobar ratios
- R45 = (C636 + C627) / C626
- R46 = (C628 + C637 + C727) / C626
- R47 = (C638 + C728 + C737) / C626
- R48 = (C738 + C828) / C626
- R49 = C838 / C626
-
- # Account for stochastic anomalies
- R47 *= 1 + D47 / 1000
- R48 *= 1 + D48 / 1000
- R49 *= 1 + D49 / 1000
-
- # Return isobar ratios
- return R45, R46, R47, R48, R49
-
-
- def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
- '''
- 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`
- '''
- if samples_to_split == 'all':
- samples_to_split = [s for s in self.unknowns]
- gkeys = {'by_uid':'UID', 'by_session':'Session'}
- self.grouping = grouping.lower()
- if self.grouping in gkeys:
- gkey = gkeys[self.grouping]
- for r in self:
- if r['Sample'] in samples_to_split:
- r['Sample_original'] = r['Sample']
- r['Sample'] = f"{r['Sample']}__{r[gkey]}"
- elif r['Sample'] in self.unknowns:
- r['Sample_original'] = r['Sample']
- self.refresh_samples()
-
-
- def unsplit_samples(self, tables = False):
- '''
- 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).
- '''
- unknowns_old = sorted({s for s in self.unknowns})
- CM_old = self.standardization.covar[:,:]
- VD_old = self.standardization.params.valuesdict().copy()
- vars_old = self.standardization.var_names
-
- unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
-
- Ns = len(vars_old) - len(unknowns_old)
- vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
- VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
-
- W = np.zeros((len(vars_new), len(vars_old)))
- W[:Ns,:Ns] = np.eye(Ns)
- for u in unknowns_new:
- splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
- if self.grouping == 'by_session':
- weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
- elif self.grouping == 'by_uid':
- weights = [1 for s in splits]
- sw = sum(weights)
- weights = [w/sw for w in weights]
- W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
-
- CM_new = W @ CM_old @ W.T
- V = W @ np.array([[VD_old[k]] for k in vars_old])
- VD_new = {k:v[0] for k,v in zip(vars_new, V)}
-
- self.standardization.covar = CM_new
- self.standardization.params.valuesdict = lambda : VD_new
- self.standardization.var_names = vars_new
-
- for r in self:
- if r['Sample'] in self.unknowns:
- r['Sample_split'] = r['Sample']
- r['Sample'] = r['Sample_original']
-
- self.refresh_samples()
- self.consolidate_samples()
- self.repeatabilities()
-
- if tables:
- self.table_of_analyses()
- self.table_of_samples()
-
- def assign_timestamps(self):
- '''
- 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.
- '''
- for session in self.sessions:
- sdata = self.sessions[session]['data']
- try:
- t0 = np.mean([r['TimeTag'] for r in sdata])
- for r in sdata:
- r['t'] = r['TimeTag'] - t0
- except KeyError:
- t0 = (len(sdata)-1)/2
- for t,r in enumerate(sdata):
- r['t'] = t - t0
-
-
- def report(self):
- '''
- Prints a report on the standardization fit.
- Only applicable after `D4xdata.standardize(method='pooled')`.
- '''
- report_fit(self.standardization)
-
-
- def combine_samples(self, sample_groups):
- '''
- 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:
- ```py
- {'group1': ['sample_1', 'sample_2'],
- 'group2': ['sample_3', 'sample_4', 'sample_5']}
- ```
- '''
-
- samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
- groups = sorted(sample_groups.keys())
- group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
- D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
- CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
- W = np.array([
- [self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
- for j in groups])
- D4x_new = W @ D4x_old
- CM_new = W @ CM_old @ W.T
-
- return groups, D4x_new[:,0], CM_new
-
-
- @make_verbal
- def standardize(self,
- method = 'pooled',
- weighted_sessions = [],
- consolidate = True,
- consolidate_tables = False,
- consolidate_plots = False,
- constraints = {},
- ):
- '''
- 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](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
- `'indep_sessions'`, the standardization processes each session independently, based only
- on anchors analyses.
- '''
-
- self.standardization_method = method
- self.assign_timestamps()
-
- if method == 'pooled':
- if weighted_sessions:
- for session_group in weighted_sessions:
- X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
- X.Nominal_D4x = self.Nominal_D4x.copy()
- X.refresh()
- result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
- w = np.sqrt(result.redchi)
- self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
- for r in X:
- r[f'wD{self._4x}raw'] *= w
- else:
- self.msg(f'All D{self._4x}raw weights set to 1 ‰')
- for r in self:
- r[f'wD{self._4x}raw'] = 1.
-
- params = Parameters()
- for k,session in enumerate(self.sessions):
- self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
- self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
- self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
- s = pf(session)
- params.add(f'a_{s}', value = 0.9)
- params.add(f'b_{s}', value = 0.)
- params.add(f'c_{s}', value = -0.9)
- params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
- params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
- params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
- for sample in self.unknowns:
- params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
-
- for k in constraints:
- params[k].expr = constraints[k]
-
- def residuals(p):
- R = []
- for r in self:
- session = pf(r['Session'])
- sample = pf(r['Sample'])
- if r['Sample'] in self.Nominal_D4x:
- R += [ (
- r[f'D{self._4x}raw'] - (
- p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
- + p[f'b_{session}'] * r[f'd{self._4x}']
- + p[f'c_{session}']
- + r['t'] * (
- p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
- + p[f'b2_{session}'] * r[f'd{self._4x}']
- + p[f'c2_{session}']
- )
- )
- ) / r[f'wD{self._4x}raw'] ]
- else:
- R += [ (
- r[f'D{self._4x}raw'] - (
- p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
- + p[f'b_{session}'] * r[f'd{self._4x}']
- + p[f'c_{session}']
- + r['t'] * (
- p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
- + p[f'b2_{session}'] * r[f'd{self._4x}']
- + p[f'c2_{session}']
- )
- )
- ) / r[f'wD{self._4x}raw'] ]
- return R
-
- M = Minimizer(residuals, params)
- result = M.least_squares()
- self.Nf = result.nfree
- self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
-# if self.verbose:
-# report_fit(result)
-
- for r in self:
- s = pf(r["Session"])
- a = result.params.valuesdict()[f'a_{s}']
- b = result.params.valuesdict()[f'b_{s}']
- c = result.params.valuesdict()[f'c_{s}']
- a2 = result.params.valuesdict()[f'a2_{s}']
- b2 = result.params.valuesdict()[f'b2_{s}']
- c2 = result.params.valuesdict()[f'c2_{s}']
- 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'])
-
- self.standardization = result
-
- for session in self.sessions:
- self.sessions[session]['Np'] = 3
- for k in ['scrambling', 'slope', 'wg']:
- if self.sessions[session][f'{k}_drift']:
- self.sessions[session]['Np'] += 1
-
- if consolidate:
- self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
- return result
-
-
- elif method == 'indep_sessions':
-
- if weighted_sessions:
- for session_group in weighted_sessions:
- X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
- X.Nominal_D4x = self.Nominal_D4x.copy()
- X.refresh()
- # This is only done to assign r['wD47raw'] for r in X:
- X.standardize(method = method, weighted_sessions = [], consolidate = False)
- 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}')
- else:
- self.msg('All weights set to 1 ‰')
- for r in self:
- r[f'wD{self._4x}raw'] = 1
-
- for session in self.sessions:
- s = self.sessions[session]
- p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
- p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
- s['Np'] = sum(p_active)
- sdata = s['data']
-
- A = np.array([
- [
- self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
- r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
- 1 / r[f'wD{self._4x}raw'],
- self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
- r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
- r['t'] / r[f'wD{self._4x}raw']
- ]
- for r in sdata if r['Sample'] in self.anchors
- ])[:,p_active] # only keep columns for the active parameters
- 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])
- s['Na'] = Y.size
- CM = linalg.inv(A.T @ A)
- bf = (CM @ A.T @ Y).T[0,:]
- k = 0
- for n,a in zip(p_names, p_active):
- if a:
- s[n] = bf[k]
-# self.msg(f'{n} = {bf[k]}')
- k += 1
- else:
- s[n] = 0.
-# self.msg(f'{n} = 0.0')
-
- for r in sdata :
- a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
- 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'])
- r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
-
- s['CM'] = np.zeros((6,6))
- i = 0
- k_active = [j for j,a in enumerate(p_active) if a]
- for j,a in enumerate(p_active):
- if a:
- s['CM'][j,k_active] = CM[i,:]
- i += 1
-
- if not weighted_sessions:
- w = self.rmswd()['rmswd']
- for r in self:
- r[f'wD{self._4x}'] *= w
- r[f'wD{self._4x}raw'] *= w
- for session in self.sessions:
- self.sessions[session]['CM'] *= w**2
-
- for session in self.sessions:
- s = self.sessions[session]
- s['SE_a'] = s['CM'][0,0]**.5
- s['SE_b'] = s['CM'][1,1]**.5
- s['SE_c'] = s['CM'][2,2]**.5
- s['SE_a2'] = s['CM'][3,3]**.5
- s['SE_b2'] = s['CM'][4,4]**.5
- s['SE_c2'] = s['CM'][5,5]**.5
-
- if not weighted_sessions:
- self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
- else:
- self.Nf = 0
- for sg in weighted_sessions:
- self.Nf += self.rmswd(sessions = sg)['Nf']
-
- self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
-
- avgD4x = {
- sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
- for sample in self.samples
- }
- chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
- rD4x = (chi2/self.Nf)**.5
- self.repeatability[f'sigma_{self._4x}'] = rD4x
-
- if consolidate:
- self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
-
-
- def standardization_error(self, session, d4x, D4x, t = 0):
- '''
- Compute standardization error for a given session and
- (δ47, Δ47) composition.
- '''
- a = self.sessions[session]['a']
- b = self.sessions[session]['b']
- c = self.sessions[session]['c']
- a2 = self.sessions[session]['a2']
- b2 = self.sessions[session]['b2']
- c2 = self.sessions[session]['c2']
- CM = self.sessions[session]['CM']
-
- x, y = D4x, d4x
- z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
-# x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
- dxdy = -(b+b2*t) / (a+a2*t)
- dxdz = 1. / (a+a2*t)
- dxda = -x / (a+a2*t)
- dxdb = -y / (a+a2*t)
- dxdc = -1. / (a+a2*t)
- dxda2 = -x * a2 / (a+a2*t)
- dxdb2 = -y * t / (a+a2*t)
- dxdc2 = -t / (a+a2*t)
- V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
- sx = (V @ CM @ V.T) ** .5
- return sx
-
-
- @make_verbal
- def summary(self,
- dir = 'output',
- filename = None,
- save_to_file = True,
- print_out = True,
- ):
- '''
- 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
- '''
-
- out = []
- out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
- 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])})"]]
- out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
- out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
- out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
- out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
- out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
- out += [['Model degrees of freedom', f"{self.Nf}"]]
- out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
- out += [['Standardization method', self.standardization_method]]
-
- if save_to_file:
- if not os.path.exists(dir):
- os.makedirs(dir)
- if filename is None:
- filename = f'D{self._4x}_summary.csv'
- with open(f'{dir}/{filename}', 'w') as fid:
- fid.write(make_csv(out))
- if print_out:
- self.msg('\n' + pretty_table(out, header = 0))
-
-
- @make_verbal
- def table_of_sessions(self,
- dir = 'output',
- filename = None,
- save_to_file = True,
- print_out = True,
- output = None,
- ):
- '''
- 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']]`)
- '''
- include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
- include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
- include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
-
- 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']]
- if include_a2:
- out[-1] += ['a2 ± SE']
- if include_b2:
- out[-1] += ['b2 ± SE']
- if include_c2:
- out[-1] += ['c2 ± SE']
- for session in self.sessions:
- out += [[
- session,
- f"{self.sessions[session]['Na']}",
- f"{self.sessions[session]['Nu']}",
- f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
- f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
- f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
- f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
- f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
- f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
- f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
- f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
- ]]
- if include_a2:
- if self.sessions[session]['scrambling_drift']:
- out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
- else:
- out[-1] += ['']
- if include_b2:
- if self.sessions[session]['slope_drift']:
- out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
- else:
- out[-1] += ['']
- if include_c2:
- if self.sessions[session]['wg_drift']:
- out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
- else:
- out[-1] += ['']
-
- if save_to_file:
- if not os.path.exists(dir):
- os.makedirs(dir)
- if filename is None:
- filename = f'D{self._4x}_sessions.csv'
- with open(f'{dir}/{filename}', 'w') as fid:
- fid.write(make_csv(out))
- if print_out:
- self.msg('\n' + pretty_table(out))
- if output == 'raw':
- return out
- elif output == 'pretty':
- return pretty_table(out)
-
-
- @make_verbal
- def table_of_analyses(
- self,
- dir = 'output',
- filename = None,
- save_to_file = True,
- print_out = True,
- output = None,
- ):
- '''
- 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']]`)
- '''
-
- out = [['UID','Session','Sample']]
- 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}]
- for f in extra_fields:
- out[-1] += [f[0]]
- out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
- for r in self:
- out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
- for f in extra_fields:
- out[-1] += [f"{r[f[0]]:{f[1]}}"]
- out[-1] += [
- f"{r['d13Cwg_VPDB']:.3f}",
- f"{r['d18Owg_VSMOW']:.3f}",
- f"{r['d45']:.6f}",
- f"{r['d46']:.6f}",
- f"{r['d47']:.6f}",
- f"{r['d48']:.6f}",
- f"{r['d49']:.6f}",
- f"{r['d13C_VPDB']:.6f}",
- f"{r['d18O_VSMOW']:.6f}",
- f"{r['D47raw']:.6f}",
- f"{r['D48raw']:.6f}",
- f"{r['D49raw']:.6f}",
- f"{r[f'D{self._4x}']:.6f}"
- ]
- if save_to_file:
- if not os.path.exists(dir):
- os.makedirs(dir)
- if filename is None:
- filename = f'D{self._4x}_analyses.csv'
- with open(f'{dir}/{filename}', 'w') as fid:
- fid.write(make_csv(out))
- if print_out:
- self.msg('\n' + pretty_table(out))
- return out
-
- @make_verbal
- def covar_table(
- self,
- correl = False,
- dir = 'output',
- filename = None,
- save_to_file = True,
- print_out = True,
- output = None,
- ):
- '''
- 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']]`)
- '''
- samples = sorted([u for u in self.unknowns])
- out = [[''] + samples]
- for s1 in samples:
- out.append([s1])
- for s2 in samples:
- if correl:
- out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
- else:
- out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
-
- if save_to_file:
- if not os.path.exists(dir):
- os.makedirs(dir)
- if filename is None:
- if correl:
- filename = f'D{self._4x}_correl.csv'
- else:
- filename = f'D{self._4x}_covar.csv'
- with open(f'{dir}/{filename}', 'w') as fid:
- fid.write(make_csv(out))
- if print_out:
- self.msg('\n'+pretty_table(out))
- if output == 'raw':
- return out
- elif output == 'pretty':
- return pretty_table(out)
-
- @make_verbal
- def table_of_samples(
- self,
- dir = 'output',
- filename = None,
- save_to_file = True,
- print_out = True,
- output = None,
- ):
- '''
- 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']]`)
- '''
-
- out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
- for sample in self.anchors:
- out += [[
- f"{sample}",
- f"{self.samples[sample]['N']}",
- f"{self.samples[sample]['d13C_VPDB']:.2f}",
- f"{self.samples[sample]['d18O_VSMOW']:.2f}",
- f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
- f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
- ]]
- for sample in self.unknowns:
- out += [[
- f"{sample}",
- f"{self.samples[sample]['N']}",
- f"{self.samples[sample]['d13C_VPDB']:.2f}",
- f"{self.samples[sample]['d18O_VSMOW']:.2f}",
- f"{self.samples[sample][f'D{self._4x}']:.4f}",
- f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
- f"± {self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
- f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
- f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
- ]]
- if save_to_file:
- if not os.path.exists(dir):
- os.makedirs(dir)
- if filename is None:
- filename = f'D{self._4x}_samples.csv'
- with open(f'{dir}/{filename}', 'w') as fid:
- fid.write(make_csv(out))
- if print_out:
- self.msg('\n'+pretty_table(out))
- if output == 'raw':
- return out
- elif output == 'pretty':
- return pretty_table(out)
-
-
- def plot_sessions(self, dir = 'output', figsize = (8,8)):
- '''
- 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
- '''
- if not os.path.exists(dir):
- os.makedirs(dir)
-
- for session in self.sessions:
- sp = self.plot_single_session(session, xylimits = 'constant')
- ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
- ppl.close(sp.fig)
-
-
- @make_verbal
- def consolidate_samples(self):
- '''
- 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 δ13C_VPDB value for this sample
- + `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
- + `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_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`.
- '''
- D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
- for sample in self.samples:
- self.samples[sample]['N'] = len(self.samples[sample]['data'])
- if self.samples[sample]['N'] > 1:
- self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
-
- self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
- self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
-
- D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
- if len(D4x_pop) > 2:
- self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
-
- if self.standardization_method == 'pooled':
- for sample in self.anchors:
- self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
- self.samples[sample][f'SE_D{self._4x}'] = 0.
- for sample in self.unknowns:
- self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
- try:
- self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
- except ValueError:
- # when `sample` is constrained by self.standardize(constraints = {...}),
- # it is no longer listed in self.standardization.var_names.
- # Temporary fix: define SE as zero for now
- self.samples[sample][f'SE_D4{self._4x}'] = 0.
-
- elif self.standardization_method == 'indep_sessions':
- for sample in self.anchors:
- self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
- self.samples[sample][f'SE_D{self._4x}'] = 0.
- for sample in self.unknowns:
- self.msg(f'Consolidating sample {sample}')
- self.unknowns[sample][f'session_D{self._4x}'] = {}
- session_avg = []
- for session in self.sessions:
- sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
- if sdata:
- self.msg(f'{sample} found in session {session}')
- avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
- avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
- # !! TODO: sigma_s below does not account for temporal changes in standardization error
- sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
- sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
- session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
- self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
- self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
- weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
- wsum = sum([weights[s] for s in weights])
- for s in weights:
- self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
-
-
- def consolidate_sessions(self):
- '''
- 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`: δ13C_VPDB repeatability of analyses within the session
- + `r_d18O_VSMOW`: δ18O_VSMOW 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`: δ13C_VPDB of WG
- + `d18Owg_VSMOW`: δ18O_VSMOW of WG
- '''
- for session in self.sessions:
- if 'd13Cwg_VPDB' not in self.sessions[session]:
- self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
- if 'd18Owg_VSMOW' not in self.sessions[session]:
- self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
- self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
- self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
-
- self.msg(f'Computing repeatabilities for session {session}')
- self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
- self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
- self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
-
- if self.standardization_method == 'pooled':
- for session in self.sessions:
-
- self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
- i = self.standardization.var_names.index(f'a_{pf(session)}')
- self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
-
- self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
- i = self.standardization.var_names.index(f'b_{pf(session)}')
- self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
-
- self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
- i = self.standardization.var_names.index(f'c_{pf(session)}')
- self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
-
- self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
- if self.sessions[session]['scrambling_drift']:
- i = self.standardization.var_names.index(f'a2_{pf(session)}')
- self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
- else:
- self.sessions[session]['SE_a2'] = 0.
-
- self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
- if self.sessions[session]['slope_drift']:
- i = self.standardization.var_names.index(f'b2_{pf(session)}')
- self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
- else:
- self.sessions[session]['SE_b2'] = 0.
-
- self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
- if self.sessions[session]['wg_drift']:
- i = self.standardization.var_names.index(f'c2_{pf(session)}')
- self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
- else:
- self.sessions[session]['SE_c2'] = 0.
-
- i = self.standardization.var_names.index(f'a_{pf(session)}')
- j = self.standardization.var_names.index(f'b_{pf(session)}')
- k = self.standardization.var_names.index(f'c_{pf(session)}')
- CM = np.zeros((6,6))
- CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
- try:
- i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
- CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
- CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
- try:
- j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
- CM[3,4] = self.standardization.covar[i2,j2]
- CM[4,3] = self.standardization.covar[j2,i2]
- except ValueError:
- pass
- try:
- k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
- CM[3,5] = self.standardization.covar[i2,k2]
- CM[5,3] = self.standardization.covar[k2,i2]
- except ValueError:
- pass
- except ValueError:
- pass
- try:
- j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
- CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
- CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
- try:
- k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
- CM[4,5] = self.standardization.covar[j2,k2]
- CM[5,4] = self.standardization.covar[k2,j2]
- except ValueError:
- pass
- except ValueError:
- pass
- try:
- k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
- CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
- CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
- except ValueError:
- pass
-
- self.sessions[session]['CM'] = CM
-
- elif self.standardization_method == 'indep_sessions':
- pass # Not implemented yet
-
-
- @make_verbal
- def repeatabilities(self):
- '''
- Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
- (for all samples, for anchors, and for unknowns).
- '''
- self.msg('Computing reproducibilities for all sessions')
-
- self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
- self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
- self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
- self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
- self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
-
-
- @make_verbal
- def consolidate(self, tables = True, plots = True):
- '''
- Collect information about samples, sessions and repeatabilities.
- '''
- self.consolidate_samples()
- self.consolidate_sessions()
- self.repeatabilities()
-
- if tables:
- self.summary()
- self.table_of_sessions()
- self.table_of_analyses()
- self.table_of_samples()
-
- if plots:
- self.plot_sessions()
-
-
- @make_verbal
- def rmswd(self,
- samples = 'all samples',
- sessions = 'all sessions',
- ):
- '''
- 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'`.
- '''
- if samples == 'all samples':
- mysamples = [k for k in self.samples]
- elif samples == 'anchors':
- mysamples = [k for k in self.anchors]
- elif samples == 'unknowns':
- mysamples = [k for k in self.unknowns]
- else:
- mysamples = samples
-
- if sessions == 'all sessions':
- sessions = [k for k in self.sessions]
-
- chisq, Nf = 0, 0
- for sample in mysamples :
- G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
- if len(G) > 1 :
- X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
- Nf += (len(G) - 1)
- chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
- r = (chisq / Nf)**.5 if Nf > 0 else 0
- self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
- return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
-
-
- @make_verbal
- def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
- '''
- Compute the repeatability of `[r[key] for r in self]`
- '''
- # NB: it's debatable whether rD47 should be computed
- # with Nf = len(self)-len(self.samples) instead of
- # Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
-
- if samples == 'all samples':
- mysamples = [k for k in self.samples]
- elif samples == 'anchors':
- mysamples = [k for k in self.anchors]
- elif samples == 'unknowns':
- mysamples = [k for k in self.unknowns]
- else:
- mysamples = samples
-
- if sessions == 'all sessions':
- sessions = [k for k in self.sessions]
-
- if key in ['D47', 'D48']:
- chisq, Nf = 0, 0
- for sample in mysamples :
- X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
- if len(X) > 1 :
- chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
- if sample in self.unknowns:
- Nf += len(X) - 1
- else:
- Nf += len(X)
- if samples in ['anchors', 'all samples']:
- Nf -= sum([self.sessions[s]['Np'] for s in sessions])
- r = (chisq / Nf)**.5 if Nf > 0 else 0
-
- else: # if key not in ['D47', 'D48']
- chisq, Nf = 0, 0
- for sample in mysamples :
- X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
- if len(X) > 1 :
- Nf += len(X) - 1
- chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
- r = (chisq / Nf)**.5 if Nf > 0 else 0
-
- self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
- return r
-
- def sample_average(self, samples, weights = 'equal', normalize = True):
- '''
- 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**
-
- ```python
- 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.
-
- ```python
- self.sample_average(['X','Y'], [1, -1], normalize = False)
- ```
-
- returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
- '''
- if weights == 'equal':
- weights = [1/len(samples)] * len(samples)
-
- if normalize:
- s = sum(weights)
- if s:
- weights = [w/s for w in weights]
-
- try:
-# indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
-# C = self.standardization.covar[indices,:][:,indices]
- C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
- X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
- return correlated_sum(X, C, weights)
- except ValueError:
- return (0., 0.)
-
-
- def sample_D4x_covar(self, sample1, sample2 = None):
- '''
- 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.
- '''
- if sample2 is None:
- sample2 = sample1
- if self.standardization_method == 'pooled':
- i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
- j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
- return self.standardization.covar[i, j]
- elif self.standardization_method == 'indep_sessions':
- if sample1 == sample2:
- return self.samples[sample1][f'SE_D{self._4x}']**2
- else:
- c = 0
- for session in self.sessions:
- sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
- sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
- if sdata1 and sdata2:
- a = self.sessions[session]['a']
- # !! TODO: CM below does not account for temporal changes in standardization parameters
- CM = self.sessions[session]['CM'][:3,:3]
- avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
- avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
- avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
- avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
- c += (
- self.unknowns[sample1][f'session_D{self._4x}'][session][2]
- * self.unknowns[sample2][f'session_D{self._4x}'][session][2]
- * np.array([[avg_D4x_1, avg_d4x_1, 1]])
- @ CM
- @ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
- ) / a**2
- return float(c)
-
- def sample_D4x_correl(self, sample1, sample2 = None):
- '''
- Correlation between Δ4x errors of samples
-
- Returns the error correlation between the average Δ4x values of two samples.
- '''
- if sample2 is None or sample2 == sample1:
- return 1.
- return (
- self.sample_D4x_covar(sample1, sample2)
- / self.unknowns[sample1][f'SE_D{self._4x}']
- / self.unknowns[sample2][f'SE_D{self._4x}']
- )
-
- def plot_single_session(self,
- session,
- kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
- kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
- kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
- kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
- kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
- xylimits = 'free', # | 'constant'
- x_label = None,
- y_label = None,
- error_contour_interval = 'auto',
- fig = 'new',
- ):
- '''
- Generate plot for a single session
- '''
- if x_label is None:
- x_label = f'δ$_{{{self._4x}}}$ (‰)'
- if y_label is None:
- y_label = f'Δ$_{{{self._4x}}}$ (‰)'
-
- out = _SessionPlot()
- anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
- unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
-
- if fig == 'new':
- out.fig = ppl.figure(figsize = (6,6))
- ppl.subplots_adjust(.1,.1,.9,.9)
-
- out.anchor_analyses, = ppl.plot(
- [r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
- [r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
- **kw_plot_anchors)
- out.unknown_analyses, = ppl.plot(
- [r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
- [r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
- **kw_plot_unknowns)
- out.anchor_avg = ppl.plot(
- np.array([ np.array([
- np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
- np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
- ]) for sample in anchors]).T,
- np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
- **kw_plot_anchor_avg)
- out.unknown_avg = ppl.plot(
- np.array([ np.array([
- np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
- np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
- ]) for sample in unknowns]).T,
- np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
- **kw_plot_unknown_avg)
- if xylimits == 'constant':
- x = [r[f'd{self._4x}'] for r in self]
- y = [r[f'D{self._4x}'] for r in self]
- x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
- w, h = x2-x1, y2-y1
- x1 -= w/20
- x2 += w/20
- y1 -= h/20
- y2 += h/20
- ppl.axis([x1, x2, y1, y2])
- elif xylimits == 'free':
- x1, x2, y1, y2 = ppl.axis()
- else:
- x1, x2, y1, y2 = ppl.axis(xylimits)
-
- if error_contour_interval != 'none':
- xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
- XI,YI = np.meshgrid(xi, yi)
- SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
- if error_contour_interval == 'auto':
- rng = np.max(SI) - np.min(SI)
- if rng <= 0.01:
- cinterval = 0.001
- elif rng <= 0.03:
- cinterval = 0.004
- elif rng <= 0.1:
- cinterval = 0.01
- elif rng <= 0.3:
- cinterval = 0.03
- elif rng <= 1.:
- cinterval = 0.1
- else:
- cinterval = 0.5
- else:
- cinterval = error_contour_interval
-
- cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
- out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
- out.clabel = ppl.clabel(out.contour)
-
- ppl.xlabel(x_label)
- ppl.ylabel(y_label)
- ppl.title(session, weight = 'bold')
- ppl.grid(alpha = .2)
- out.ax = ppl.gca()
-
- return out
-
- def plot_residuals(
- self,
- hist = False,
- binwidth = 2/3,
- dir = 'output',
- filename = None,
- highlight = [],
- colors = None,
- figsize = None,
- ):
- '''
- Plot residuals of each analysis as a function of time (actually, as a function of
- the order of analyses in the `D4xdata` object)
-
- + `hist`: whether to add a histogram of residuals
- + `histbins`: specify bin edges for the histogram
- + `dir`: the directory in which to save the plot
- + `highlight`: a list of samples to highlight
- + `colors`: a dict of `{<sample>: <color>}` for all samples
- + `figsize`: (width, height) of figure
- '''
- # Layout
- fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
- if hist:
- ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
- ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
- else:
- ppl.subplots_adjust(.08,.05,.78,.8)
- ax1 = ppl.subplot(111)
-
- # Colors
- N = len(self.anchors)
- if colors is None:
- if len(highlight) > 0:
- Nh = len(highlight)
- if Nh == 1:
- colors = {highlight[0]: (0,0,0)}
- elif Nh == 3:
- colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
- elif Nh == 4:
- colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
- else:
- colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
- else:
- if N == 3:
- colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
- elif N == 4:
- colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
- else:
- colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
-
- ppl.sca(ax1)
-
- ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
-
- session = self[0]['Session']
- x1 = 0
-# ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
- x_sessions = {}
- one_or_more_singlets = False
- one_or_more_multiplets = False
- for k,r in enumerate(self):
- if r['Session'] != session:
- x2 = k-1
- x_sessions[session] = (x1+x2)/2
- ppl.axvline(k - 0.5, color = 'k', lw = .5)
- session = r['Session']
- x1 = k
- singlet = len(self.samples[r['Sample']]['data']) == 1
- if r['Sample'] in self.unknowns:
- if singlet:
- one_or_more_singlets = True
- else:
- one_or_more_multiplets = True
- kw = dict(
- marker = 'x' if singlet else '+',
- ms = 4 if singlet else 5,
- ls = 'None',
- mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
- mew = 1,
- alpha = 0.2 if singlet else 1,
- )
- if highlight and r['Sample'] not in highlight:
- kw['alpha'] = 0.2
- ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
- x2 = k
- x_sessions[session] = (x1+x2)/2
-
- ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
- ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
- if not hist:
- ppl.text(len(self), self.repeatability['r_D47']*1000, f" SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
- ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f" 95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')
-
- xmin, xmax, ymin, ymax = ppl.axis()
- for s in x_sessions:
- ppl.text(
- x_sessions[s],
- ymax +1,
- s,
- va = 'bottom',
- **(
- dict(ha = 'center')
- if len(self.sessions[s]['data']) > (0.15 * len(self))
- else dict(ha = 'left', rotation = 45)
- )
- )
-
- if hist:
- ppl.sca(ax2)
-
- for s in colors:
- kw['marker'] = '+'
- kw['ms'] = 5
- kw['mec'] = colors[s]
- kw['label'] = s
- kw['alpha'] = 1
- ppl.plot([], [], **kw)
-
- kw['mec'] = (0,0,0)
-
- if one_or_more_singlets:
- kw['marker'] = 'x'
- kw['ms'] = 4
- kw['alpha'] = .2
- kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
- ppl.plot([], [], **kw)
-
- if one_or_more_multiplets:
- kw['marker'] = '+'
- kw['ms'] = 4
- kw['alpha'] = 1
- kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
- ppl.plot([], [], **kw)
-
- if hist:
- leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
- else:
- leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
- leg.set_zorder(-1000)
-
- ppl.sca(ax1)
-
- ppl.ylabel('Δ$_{47}$ residuals (ppm)')
- ppl.xticks([])
- ppl.axis([-1, len(self), None, None])
-
- if hist:
- ppl.sca(ax2)
- X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self]
- ppl.hist(
- X,
- orientation = 'horizontal',
- histtype = 'stepfilled',
- ec = [.4]*3,
- fc = [.25]*3,
- alpha = .25,
- bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
- )
- ppl.axis([None, None, ymin, ymax])
- ppl.text(0, 0,
- f" SD = {self.repeatability['r_D47']*1000:.1f} ppm\n 95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
- size = 8,
- alpha = 1,
- va = 'center',
- ha = 'left',
- )
-
- ppl.xticks([])
- ppl.yticks([])
-# ax2.spines['left'].set_visible(False)
- ax2.spines['right'].set_visible(False)
- ax2.spines['top'].set_visible(False)
- ax2.spines['bottom'].set_visible(False)
-
-
- if not os.path.exists(dir):
- os.makedirs(dir)
- if filename is None:
- return fig
- elif filename == '':
- filename = f'D{self._4x}_residuals.pdf'
- ppl.savefig(f'{dir}/{filename}')
- ppl.close(fig)
-
-
- def simulate(self, *args, **kwargs):
- '''
- Legacy function with warning message pointing to `virtual_data()`
- '''
- raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
-
- def plot_distribution_of_analyses(self, dir = 'output', filename = None, vs_time = False, output = None):
- '''
- Plot temporal distribution of all analyses in the data set.
-
- **Parameters**
-
- + `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
- '''
-
- asamples = [s for s in self.anchors]
- usamples = [s for s in self.unknowns]
- if output is None or output == 'fig':
- fig = ppl.figure(figsize = (6,4))
- ppl.subplots_adjust(0.02, 0.03, 0.9, 0.8)
- Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
- for k, s in enumerate(asamples + usamples):
- if vs_time:
- X = [r['TimeTag'] for r in self if r['Sample'] == s]
- else:
- X = [x for x,r in enumerate(self) if r['Sample'] == s]
- Y = [k for x in X]
- ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .5)
- ppl.axhline(k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
- ppl.text(Xmax, k, f' {s}', va = 'center', ha = 'left', size = 7)
- if vs_time:
- t = [r['TimeTag'] for r in self]
- t1, t2 = min(t), max(t)
- tspan = t2 - t1
- t1 -= tspan / len(self)
- t2 += tspan / len(self)
- ppl.axis([t1, t2, -1, k+1])
- else:
- ppl.axis([-1, len(self), -1, k+1])
-
-
- x2 = 0
- for session in self.sessions:
- x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
- if vs_time:
- ppl.axvline(x1, color = 'k', lw = .75)
- if k:
- if vs_time:
- ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .2)
- else:
- ppl.axvline((x1+x2)/2, color = 'k', lw = .75)
- x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
-# from xlrd import xldate_as_datetime
-# print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
- if vs_time:
- ppl.axvline(x2, color = 'k', lw = .75)
- ppl.text((2*x1+x2)/3, k+1, session, ha = 'left', va = 'bottom', rotation = 45, size = 8)
-
- ppl.xticks([])
- ppl.yticks([])
-
- if output is None:
- if not os.path.exists(dir):
- os.makedirs(dir)
- if filename == None:
- filename = f'D{self._4x}_distribution_of_analyses.pdf'
- ppl.savefig(f'{dir}/{filename}')
- ppl.close(fig)
- elif output == 'ax':
- return ppl.gca()
- elif output == 'fig':
- return fig
-
-
-class D47data(D4xdata):
- '''
- Store and process data for a large set of Δ47 analyses,
- usually comprising more than one analytical session.
- '''
-
- 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,
- } # I-CDES (Bernasconi et al., 2021)
- '''
- 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)](https://doi.org/10.1029/2020GC009588)):
- ```py
- {
- '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,
- }
- ```
- '''
-
-
- @property
- def Nominal_D47(self):
- return self.Nominal_D4x
-
-
- @Nominal_D47.setter
- def Nominal_D47(self, new):
- self.Nominal_D4x = dict(**new)
- self.refresh()
-
-
- def __init__(self, l = [], **kwargs):
- '''
- **Parameters:** same as `D4xdata.__init__()`
- '''
- D4xdata.__init__(self, l = l, mass = '47', **kwargs)
-
-
- def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
- '''
- 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)](https://doi.org/10.1029/2018GC008127);
- `wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
- + `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.
- '''
- f = {
- 'petersen': fCO2eqD47_Petersen,
- 'wang': fCO2eqD47_Wang,
- }[fCo2eqD47]
- foo = {}
- for r in self:
- if 'Teq' in r:
- if r['Sample'] in foo:
- assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
- else:
- foo[r['Sample']] = f(r['Teq'])
- else:
- assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
-
- if priority == 'replace':
- self.Nominal_D47 = {}
- for s in foo:
- if priority != 'old' or s not in self.Nominal_D47:
- self.Nominal_D47[s] = foo[s]
-
-
-
-
-class D48data(D4xdata):
- '''
- Store and process data for a large set of Δ48 analyses,
- usually comprising more than one analytical session.
- '''
-
- Nominal_D4x = {
- 'ETH-1': 0.138,
- 'ETH-2': 0.138,
- 'ETH-3': 0.270,
- 'ETH-4': 0.223,
- 'GU-1': -0.419,
- } # (Fiebig et al., 2019, 2021)
- '''
- 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)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
- Fiebig et al. (in press)):
-
- ```py
- {
- 'ETH-1' : 0.138,
- 'ETH-2' : 0.138,
- 'ETH-3' : 0.270,
- 'ETH-4' : 0.223,
- 'GU-1' : -0.419,
- }
- ```
- '''
-
-
- @property
- def Nominal_D48(self):
- return self.Nominal_D4x
-
-
- @Nominal_D48.setter
- def Nominal_D48(self, new):
- self.Nominal_D4x = dict(**new)
- self.refresh()
-
-
- def __init__(self, l = [], **kwargs):
- '''
- **Parameters:** same as `D4xdata.__init__()`
- '''
- D4xdata.__init__(self, l = l, mass = '48', **kwargs)
-
-
-class _SessionPlot():
- '''
- Simple placeholder class
- '''
- def __init__(self):
- pass
-
-
-
+
+
+
+
+ 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
+ 16__docformat__ = "restructuredtext"
+ 17__author__ = 'Mathieu Daëron'
+ 18__contact__ = 'daeron@lsce.ipsl.fr'
+ 19__copyright__ = 'Copyright (c) 2023 Mathieu Daëron'
+ 20__license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause'
+ 21__date__ = '2023-05-11'
+ 22__version__ = '2.0.4'
+ 23
+ 24import os
+ 25import numpy as np
+ 26from statistics import stdev
+ 27from scipy.stats import t as tstudent
+ 28from scipy.stats import levene
+ 29from scipy.interpolate import interp1d
+ 30from numpy import linalg
+ 31from lmfit import Minimizer, Parameters, report_fit
+ 32from matplotlib import pyplot as ppl
+ 33from datetime import datetime as dt
+ 34from functools import wraps
+ 35from colorsys import hls_to_rgb
+ 36from matplotlib import rcParams
+ 37
+ 38rcParams['font.family'] = 'sans-serif'
+ 39rcParams['font.sans-serif'] = 'Helvetica'
+ 40rcParams['font.size'] = 10
+ 41rcParams['mathtext.fontset'] = 'custom'
+ 42rcParams['mathtext.rm'] = 'sans'
+ 43rcParams['mathtext.bf'] = 'sans:bold'
+ 44rcParams['mathtext.it'] = 'sans:italic'
+ 45rcParams['mathtext.cal'] = 'sans:italic'
+ 46rcParams['mathtext.default'] = 'rm'
+ 47rcParams['xtick.major.size'] = 4
+ 48rcParams['xtick.major.width'] = 1
+ 49rcParams['ytick.major.size'] = 4
+ 50rcParams['ytick.major.width'] = 1
+ 51rcParams['axes.grid'] = False
+ 52rcParams['axes.linewidth'] = 1
+ 53rcParams['grid.linewidth'] = .75
+ 54rcParams['grid.linestyle'] = '-'
+ 55rcParams['grid.alpha'] = .15
+ 56rcParams['savefig.dpi'] = 150
+ 57
+ 58Petersen_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]])
+ 59_fCO2eqD47_Petersen = interp1d(Petersen_etal_CO2eqD47[:,0], Petersen_etal_CO2eqD47[:,1])
+ 60def fCO2eqD47_Petersen(T):
+ 61 '''
+ 62 CO2 equilibrium Δ47 value as a function of T (in degrees C)
+ 63 according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).
+ 64
+ 65 '''
+ 66 return float(_fCO2eqD47_Petersen(T))
+ 67
+ 68
+ 69Wang_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]])
+ 70_fCO2eqD47_Wang = interp1d(Wang_etal_CO2eqD47[:,0] - 0.15, Wang_etal_CO2eqD47[:,1])
+ 71def fCO2eqD47_Wang(T):
+ 72 '''
+ 73 CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
+ 74 according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
+ 75 (supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
+ 76 '''
+ 77 return float(_fCO2eqD47_Wang(T))
+ 78
+ 79
+ 80def correlated_sum(X, C, w = None):
+ 81 '''
+ 82 Compute covariance-aware linear combinations
+ 83
+ 84 **Parameters**
+ 85
+ 86 + `X`: list or 1-D array of values to sum
+ 87 + `C`: covariance matrix for the elements of `X`
+ 88 + `w`: list or 1-D array of weights to apply to the elements of `X`
+ 89 (all equal to 1 by default)
+ 90
+ 91 Return the sum (and its SE) of the elements of `X`, with optional weights equal
+ 92 to the elements of `w`, accounting for covariances between the elements of `X`.
+ 93 '''
+ 94 if w is None:
+ 95 w = [1 for x in X]
+ 96 return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5
+ 97
+ 98
+ 99def make_csv(x, hsep = ',', vsep = '\n'):
+ 100 '''
+ 101 Formats a list of lists of strings as a CSV
+ 102
+ 103 **Parameters**
+ 104
+ 105 + `x`: the list of lists of strings to format
+ 106 + `hsep`: the field separator (`,` by default)
+ 107 + `vsep`: the line-ending convention to use (`\\n` by default)
+ 108
+ 109 **Example**
+ 110
+ 111 ```py
+ 112 print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']]))
+ 113 ```
+ 114
+ 115 outputs:
+ 116
+ 117 ```py
+ 118 a,b,c
+ 119 d,e,f
+ 120 ```
+ 121 '''
+ 122 return vsep.join([hsep.join(l) for l in x])
+ 123
+ 124
+ 125def pf(txt):
+ 126 '''
+ 127 Modify string `txt` to follow `lmfit.Parameter()` naming rules.
+ 128 '''
+ 129 return txt.replace('-','_').replace('.','_').replace(' ','_')
+ 130
+ 131
+ 132def smart_type(x):
+ 133 '''
+ 134 Tries to convert string `x` to a float if it includes a decimal point, or
+ 135 to an integer if it does not. If both attempts fail, return the original
+ 136 string unchanged.
+ 137 '''
+ 138 try:
+ 139 y = float(x)
+ 140 except ValueError:
+ 141 return x
+ 142 if '.' not in x:
+ 143 return int(y)
+ 144 return y
+ 145
+ 146
+ 147def pretty_table(x, header = 1, hsep = ' ', vsep = '–', align = '<'):
+ 148 '''
+ 149 Reads a list of lists of strings and outputs an ascii table
+ 150
+ 151 **Parameters**
+ 152
+ 153 + `x`: a list of lists of strings
+ 154 + `header`: the number of lines to treat as header lines
+ 155 + `hsep`: the horizontal separator between columns
+ 156 + `vsep`: the character to use as vertical separator
+ 157 + `align`: string of left (`<`) or right (`>`) alignment characters.
+ 158
+ 159 **Example**
+ 160
+ 161 ```py
+ 162 x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']]
+ 163 print(pretty_table(x))
+ 164 ```
+ 165 yields:
+ 166 ```
+ 167 -- ------ ---
+ 168 A B C
+ 169 -- ------ ---
+ 170 1 1.9999 foo
+ 171 10 x bar
+ 172 -- ------ ---
+ 173 ```
+ 174
+ 175 '''
+ 176 txt = []
+ 177 widths = [np.max([len(e) for e in c]) for c in zip(*x)]
+ 178
+ 179 if len(widths) > len(align):
+ 180 align += '>' * (len(widths)-len(align))
+ 181 sepline = hsep.join([vsep*w for w in widths])
+ 182 txt += [sepline]
+ 183 for k,l in enumerate(x):
+ 184 if k and k == header:
+ 185 txt += [sepline]
+ 186 txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])]
+ 187 txt += [sepline]
+ 188 txt += ['']
+ 189 return '\n'.join(txt)
+ 190
+ 191
+ 192def transpose_table(x):
+ 193 '''
+ 194 Transpose a list if lists
+ 195
+ 196 **Parameters**
+ 197
+ 198 + `x`: a list of lists
+ 199
+ 200 **Example**
+ 201
+ 202 ```py
+ 203 x = [[1, 2], [3, 4]]
+ 204 print(transpose_table(x)) # yields: [[1, 3], [2, 4]]
+ 205 ```
+ 206 '''
+ 207 return [[e for e in c] for c in zip(*x)]
+ 208
+ 209
+ 210def w_avg(X, sX) :
+ 211 '''
+ 212 Compute variance-weighted average
+ 213
+ 214 Returns the value and SE of the weighted average of the elements of `X`,
+ 215 with relative weights equal to their inverse variances (`1/sX**2`).
+ 216
+ 217 **Parameters**
+ 218
+ 219 + `X`: array-like of elements to average
+ 220 + `sX`: array-like of the corresponding SE values
+ 221
+ 222 **Tip**
+ 223
+ 224 If `X` and `sX` are initially arranged as a list of `(x, sx)` doublets,
+ 225 they may be rearranged using `zip()`:
+ 226
+ 227 ```python
+ 228 foo = [(0, 1), (1, 0.5), (2, 0.5)]
+ 229 print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333)
+ 230 ```
+ 231 '''
+ 232 X = [ x for x in X ]
+ 233 sX = [ sx for sx in sX ]
+ 234 W = [ sx**-2 for sx in sX ]
+ 235 W = [ w/sum(W) for w in W ]
+ 236 Xavg = sum([ w*x for w,x in zip(W,X) ])
+ 237 sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5
+ 238 return Xavg, sXavg
+ 239
+ 240
+ 241def read_csv(filename, sep = ''):
+ 242 '''
+ 243 Read contents of `filename` in csv format and return a list of dictionaries.
+ 244
+ 245 In the csv string, spaces before and after field separators (`','` by default)
+ 246 are optional.
+ 247
+ 248 **Parameters**
+ 249
+ 250 + `filename`: the csv file to read
+ 251 + `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
+ 252 whichever appers most often in the contents of `filename`.
+ 253 '''
+ 254 with open(filename) as fid:
+ 255 txt = fid.read()
+ 256
+ 257 if sep == '':
+ 258 sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
+ 259 txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
+ 260 return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]]
+ 261
+ 262
+ 263def simulate_single_analysis(
+ 264 sample = 'MYSAMPLE',
+ 265 d13Cwg_VPDB = -4., d18Owg_VSMOW = 26.,
+ 266 d13C_VPDB = None, d18O_VPDB = None,
+ 267 D47 = None, D48 = None, D49 = 0., D17O = 0.,
+ 268 a47 = 1., b47 = 0., c47 = -0.9,
+ 269 a48 = 1., b48 = 0., c48 = -0.45,
+ 270 Nominal_D47 = None,
+ 271 Nominal_D48 = None,
+ 272 Nominal_d13C_VPDB = None,
+ 273 Nominal_d18O_VPDB = None,
+ 274 ALPHA_18O_ACID_REACTION = None,
+ 275 R13_VPDB = None,
+ 276 R17_VSMOW = None,
+ 277 R18_VSMOW = None,
+ 278 LAMBDA_17 = None,
+ 279 R18_VPDB = None,
+ 280 ):
+ 281 '''
+ 282 Compute working-gas delta values for a single analysis, assuming a stochastic working
+ 283 gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values).
+ 284
+ 285 **Parameters**
+ 286
+ 287 + `sample`: sample name
+ 288 + `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
+ 289 (respectively –4 and +26 ‰ by default)
+ 290 + `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
+ 291 + `D47`, `D48`, `D49`, `D17O`: clumped-isotope and oxygen-17 anomalies
+ 292 of the carbonate sample
+ 293 + `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and
+ 294 Δ48 values if `D47` or `D48` are not specified
+ 295 + `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
+ 296 δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
+ 297 + `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
+ 298 + `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
+ 299 correction parameters (by default equal to the `D4xdata` default values)
+ 300
+ 301 Returns a dictionary with fields
+ 302 `['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49']`.
+ 303 '''
+ 304
+ 305 if Nominal_d13C_VPDB is None:
+ 306 Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB
+ 307
+ 308 if Nominal_d18O_VPDB is None:
+ 309 Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB
+ 310
+ 311 if ALPHA_18O_ACID_REACTION is None:
+ 312 ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION
+ 313
+ 314 if R13_VPDB is None:
+ 315 R13_VPDB = D4xdata().R13_VPDB
+ 316
+ 317 if R17_VSMOW is None:
+ 318 R17_VSMOW = D4xdata().R17_VSMOW
+ 319
+ 320 if R18_VSMOW is None:
+ 321 R18_VSMOW = D4xdata().R18_VSMOW
+ 322
+ 323 if LAMBDA_17 is None:
+ 324 LAMBDA_17 = D4xdata().LAMBDA_17
+ 325
+ 326 if R18_VPDB is None:
+ 327 R18_VPDB = D4xdata().R18_VPDB
+ 328
+ 329 R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17
+ 330
+ 331 if Nominal_D47 is None:
+ 332 Nominal_D47 = D47data().Nominal_D47
+ 333
+ 334 if Nominal_D48 is None:
+ 335 Nominal_D48 = D48data().Nominal_D48
+ 336
+ 337 if d13C_VPDB is None:
+ 338 if sample in Nominal_d13C_VPDB:
+ 339 d13C_VPDB = Nominal_d13C_VPDB[sample]
+ 340 else:
+ 341 raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.")
+ 342
+ 343 if d18O_VPDB is None:
+ 344 if sample in Nominal_d18O_VPDB:
+ 345 d18O_VPDB = Nominal_d18O_VPDB[sample]
+ 346 else:
+ 347 raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.")
+ 348
+ 349 if D47 is None:
+ 350 if sample in Nominal_D47:
+ 351 D47 = Nominal_D47[sample]
+ 352 else:
+ 353 raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.")
+ 354
+ 355 if D48 is None:
+ 356 if sample in Nominal_D48:
+ 357 D48 = Nominal_D48[sample]
+ 358 else:
+ 359 raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.")
+ 360
+ 361 X = D4xdata()
+ 362 X.R13_VPDB = R13_VPDB
+ 363 X.R17_VSMOW = R17_VSMOW
+ 364 X.R18_VSMOW = R18_VSMOW
+ 365 X.LAMBDA_17 = LAMBDA_17
+ 366 X.R18_VPDB = R18_VPDB
+ 367 X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17
+ 368
+ 369 R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios(
+ 370 R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000),
+ 371 R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000),
+ 372 )
+ 373 R45, R46, R47, R48, R49 = X.compute_isobar_ratios(
+ 374 R13 = R13_VPDB * (1 + d13C_VPDB/1000),
+ 375 R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
+ 376 D17O=D17O, D47=D47, D48=D48, D49=D49,
+ 377 )
+ 378 R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios(
+ 379 R13 = R13_VPDB * (1 + d13C_VPDB/1000),
+ 380 R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION,
+ 381 D17O=D17O,
+ 382 )
+ 383
+ 384 d45 = 1000 * (R45/R45wg - 1)
+ 385 d46 = 1000 * (R46/R46wg - 1)
+ 386 d47 = 1000 * (R47/R47wg - 1)
+ 387 d48 = 1000 * (R48/R48wg - 1)
+ 388 d49 = 1000 * (R49/R49wg - 1)
+ 389
+ 390 for k in range(3): # dumb iteration to adjust for small changes in d47
+ 391 R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch
+ 392 R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch
+ 393 d47 = 1000 * (R47raw/R47wg - 1)
+ 394 d48 = 1000 * (R48raw/R48wg - 1)
+ 395
+ 396 return dict(
+ 397 Sample = sample,
+ 398 D17O = D17O,
+ 399 d13Cwg_VPDB = d13Cwg_VPDB,
+ 400 d18Owg_VSMOW = d18Owg_VSMOW,
+ 401 d45 = d45,
+ 402 d46 = d46,
+ 403 d47 = d47,
+ 404 d48 = d48,
+ 405 d49 = d49,
+ 406 )
+ 407
+ 408
+ 409def virtual_data(
+ 410 samples = [],
+ 411 a47 = 1., b47 = 0., c47 = -0.9,
+ 412 a48 = 1., b48 = 0., c48 = -0.45,
+ 413 rD47 = 0.015, rD48 = 0.045,
+ 414 d13Cwg_VPDB = None, d18Owg_VSMOW = None,
+ 415 session = None,
+ 416 Nominal_D47 = None, Nominal_D48 = None,
+ 417 Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
+ 418 ALPHA_18O_ACID_REACTION = None,
+ 419 R13_VPDB = None,
+ 420 R17_VSMOW = None,
+ 421 R18_VSMOW = None,
+ 422 LAMBDA_17 = None,
+ 423 R18_VPDB = None,
+ 424 seed = 0,
+ 425 ):
+ 426 '''
+ 427 Return list with simulated analyses from a single session.
+ 428
+ 429 **Parameters**
+ 430
+ 431 + `samples`: a list of entries; each entry is a dictionary with the following fields:
+ 432 * `Sample`: the name of the sample
+ 433 * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
+ 434 * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
+ 435 * `N`: how many analyses to generate for this sample
+ 436 + `a47`: scrambling factor for Δ47
+ 437 + `b47`: compositional nonlinearity for Δ47
+ 438 + `c47`: working gas offset for Δ47
+ 439 + `a48`: scrambling factor for Δ48
+ 440 + `b48`: compositional nonlinearity for Δ48
+ 441 + `c48`: working gas offset for Δ48
+ 442 + `rD47`: analytical repeatability of Δ47
+ 443 + `rD48`: analytical repeatability of Δ48
+ 444 + `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
+ 445 (by default equal to the `simulate_single_analysis` default values)
+ 446 + `session`: name of the session (no name by default)
+ 447 + `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
+ 448 if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
+ 449 + `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
+ 450 δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified
+ 451 (by default equal to the `simulate_single_analysis` defaults)
+ 452 + `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
+ 453 (by default equal to the `simulate_single_analysis` defaults)
+ 454 + `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
+ 455 correction parameters (by default equal to the `simulate_single_analysis` default)
+ 456 + `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations
+ 457
+ 458
+ 459 Here is an example of using this method to generate an arbitrary combination of
+ 460 anchors and unknowns for a bunch of sessions:
+ 461
+ 462 ```py
+ 463 args = dict(
+ 464 samples = [
+ 465 dict(Sample = 'ETH-1', N = 4),
+ 466 dict(Sample = 'ETH-2', N = 5),
+ 467 dict(Sample = 'ETH-3', N = 6),
+ 468 dict(Sample = 'FOO', N = 2,
+ 469 d13C_VPDB = -5., d18O_VPDB = -10.,
+ 470 D47 = 0.3, D48 = 0.15),
+ 471 ], rD47 = 0.010, rD48 = 0.030)
+ 472
+ 473 session1 = virtual_data(session = 'Session_01', **args, seed = 123)
+ 474 session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
+ 475 session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
+ 476 session4 = virtual_data(session = 'Session_04', **args, seed = 123456)
+ 477
+ 478 D = D47data(session1 + session2 + session3 + session4)
+ 479
+ 480 D.crunch()
+ 481 D.standardize()
+ 482
+ 483 D.table_of_sessions(verbose = True, save_to_file = False)
+ 484 D.table_of_samples(verbose = True, save_to_file = False)
+ 485 D.table_of_analyses(verbose = True, save_to_file = False)
+ 486 ```
+ 487
+ 488 This should output something like:
+ 489
+ 490 ```
+ 491 [table_of_sessions]
+ 492 –––––––––– –– –– ––––––––––– –––––––––––– –––––– –––––– –––––– ––––––––––––– –––––––––––––– ––––––––––––––
+ 493 Session Na Nu d13Cwg_VPDB d18Owg_VSMOW r_d13C r_d18O r_D47 a ± SE 1e3 x b ± SE c ± SE
+ 494 –––––––––– –– –– ––––––––––– –––––––––––– –––––– –––––– –––––– ––––––––––––– –––––––––––––– ––––––––––––––
+ 495 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
+ 496 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
+ 497 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
+ 498 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
+ 499 –––––––––– –– –– ––––––––––– –––––––––––– –––––– –––––– –––––– ––––––––––––– –––––––––––––– ––––––––––––––
+ 500
+ 501 [table_of_samples]
+ 502 –––––– –– ––––––––– –––––––––– –––––– –––––– –––––––– –––––– ––––––––
+ 503 Sample N d13C_VPDB d18O_VSMOW D47 SE 95% CL SD p_Levene
+ 504 –––––– –– ––––––––– –––––––––– –––––– –––––– –––––––– –––––– ––––––––
+ 505 ETH-1 16 2.02 37.02 0.2052 0.0079
+ 506 ETH-2 20 -10.17 19.88 0.2085 0.0100
+ 507 ETH-3 24 1.71 37.45 0.6132 0.0105
+ 508 FOO 8 -5.00 28.91 0.2989 0.0040 ± 0.0080 0.0101 0.638
+ 509 –––––– –– ––––––––– –––––––––– –––––– –––––– –––––––– –––––– ––––––––
+ 510
+ 511 [table_of_analyses]
+ 512 ––– –––––––––– –––––– ––––––––––– –––––––––––– ––––––––– ––––––––– –––––––––– –––––––––– –––––––––– –––––––––– –––––––––– ––––––––– ––––––––– ––––––––– ––––––––
+ 513 UID Session Sample d13Cwg_VPDB d18Owg_VSMOW d45 d46 d47 d48 d49 d13C_VPDB d18O_VSMOW D47raw D48raw D49raw D47
+ 514 ––– –––––––––– –––––– ––––––––––– –––––––––––– ––––––––– ––––––––– –––––––––– –––––––––– –––––––––– –––––––––– –––––––––– ––––––––– ––––––––– ––––––––– ––––––––
+ 515 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
+ 516 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
+ 517 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
+ 518 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
+ 519 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
+ 520 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
+ 521 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
+ 522 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
+ 523 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
+ 524 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
+ 525 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
+ 526 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
+ 527 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
+ 528 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
+ 529 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
+ 530 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
+ 531 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
+ 532 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
+ 533 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
+ 534 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
+ 535 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
+ 536 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
+ 537 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
+ 538 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
+ 539 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
+ 540 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
+ 541 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
+ 542 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
+ 543 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
+ 544 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
+ 545 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
+ 546 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
+ 547 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
+ 548 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
+ 549 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
+ 550 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
+ 551 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
+ 552 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
+ 553 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
+ 554 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
+ 555 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
+ 556 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
+ 557 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
+ 558 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
+ 559 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
+ 560 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
+ 561 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
+ 562 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
+ 563 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
+ 564 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
+ 565 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
+ 566 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
+ 567 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
+ 568 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
+ 569 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
+ 570 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
+ 571 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
+ 572 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
+ 573 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
+ 574 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
+ 575 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
+ 576 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
+ 577 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
+ 578 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
+ 579 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
+ 580 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
+ 581 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
+ 582 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
+ 583 ––– –––––––––– –––––– ––––––––––– –––––––––––– ––––––––– ––––––––– –––––––––– –––––––––– –––––––––– –––––––––– –––––––––– ––––––––– ––––––––– ––––––––– ––––––––
+ 584 ```
+ 585 '''
+ 586
+ 587 kwargs = locals().copy()
+ 588
+ 589 from numpy import random as nprandom
+ 590 if seed:
+ 591 rng = nprandom.default_rng(seed)
+ 592 else:
+ 593 rng = nprandom.default_rng()
+ 594
+ 595 N = sum([s['N'] for s in samples])
+ 596 errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
+ 597 errors47 *= rD47 / stdev(errors47) # scale errors to rD47
+ 598 errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
+ 599 errors48 *= rD48 / stdev(errors48) # scale errors to rD48
+ 600
+ 601 k = 0
+ 602 out = []
+ 603 for s in samples:
+ 604 kw = {}
+ 605 kw['sample'] = s['Sample']
+ 606 kw = {
+ 607 **kw,
+ 608 **{var: kwargs[var]
+ 609 for var in [
+ 610 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
+ 611 'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
+ 612 'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
+ 613 'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
+ 614 ]
+ 615 if kwargs[var] is not None},
+ 616 **{var: s[var]
+ 617 for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
+ 618 if var in s},
+ 619 }
+ 620
+ 621 sN = s['N']
+ 622 while sN:
+ 623 out.append(simulate_single_analysis(**kw))
+ 624 out[-1]['d47'] += errors47[k] * a47
+ 625 out[-1]['d48'] += errors48[k] * a48
+ 626 sN -= 1
+ 627 k += 1
+ 628
+ 629 if session is not None:
+ 630 for r in out:
+ 631 r['Session'] = session
+ 632 return out
+ 633
+ 634def table_of_samples(
+ 635 data47 = None,
+ 636 data48 = None,
+ 637 dir = 'output',
+ 638 filename = None,
+ 639 save_to_file = True,
+ 640 print_out = True,
+ 641 output = None,
+ 642 ):
+ 643 '''
+ 644 Print out, save to disk and/or return a combined table of samples
+ 645 for a pair of `D47data` and `D48data` objects.
+ 646
+ 647 **Parameters**
+ 648
+ 649 + `data47`: `D47data` instance
+ 650 + `data48`: `D48data` instance
+ 651 + `dir`: the directory in which to save the table
+ 652 + `filename`: the name to the csv file to write to
+ 653 + `save_to_file`: whether to save the table to disk
+ 654 + `print_out`: whether to print out the table
+ 655 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
+ 656 if set to `'raw'`: return a list of list of strings
+ 657 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
+ 658 '''
+ 659 if data47 is None:
+ 660 if data48 is None:
+ 661 raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
+ 662 else:
+ 663 return data48.table_of_samples(
+ 664 dir = dir,
+ 665 filename = filename,
+ 666 save_to_file = save_to_file,
+ 667 print_out = print_out,
+ 668 output = output
+ 669 )
+ 670 else:
+ 671 if data48 is None:
+ 672 return data47.table_of_samples(
+ 673 dir = dir,
+ 674 filename = filename,
+ 675 save_to_file = save_to_file,
+ 676 print_out = print_out,
+ 677 output = output
+ 678 )
+ 679 else:
+ 680 out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
+ 681 out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw')
+ 682 out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:])
+ 683
+ 684 if save_to_file:
+ 685 if not os.path.exists(dir):
+ 686 os.makedirs(dir)
+ 687 if filename is None:
+ 688 filename = f'D47D48_samples.csv'
+ 689 with open(f'{dir}/{filename}', 'w') as fid:
+ 690 fid.write(make_csv(out))
+ 691 if print_out:
+ 692 print('\n'+pretty_table(out))
+ 693 if output == 'raw':
+ 694 return out
+ 695 elif output == 'pretty':
+ 696 return pretty_table(out)
+ 697
+ 698
+ 699def table_of_sessions(
+ 700 data47 = None,
+ 701 data48 = None,
+ 702 dir = 'output',
+ 703 filename = None,
+ 704 save_to_file = True,
+ 705 print_out = True,
+ 706 output = None,
+ 707 ):
+ 708 '''
+ 709 Print out, save to disk and/or return a combined table of sessions
+ 710 for a pair of `D47data` and `D48data` objects.
+ 711 ***Only applicable if the sessions in `data47` and those in `data48`
+ 712 consist of the exact same sets of analyses.***
+ 713
+ 714 **Parameters**
+ 715
+ 716 + `data47`: `D47data` instance
+ 717 + `data48`: `D48data` instance
+ 718 + `dir`: the directory in which to save the table
+ 719 + `filename`: the name to the csv file to write to
+ 720 + `save_to_file`: whether to save the table to disk
+ 721 + `print_out`: whether to print out the table
+ 722 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
+ 723 if set to `'raw'`: return a list of list of strings
+ 724 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
+ 725 '''
+ 726 if data47 is None:
+ 727 if data48 is None:
+ 728 raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
+ 729 else:
+ 730 return data48.table_of_sessions(
+ 731 dir = dir,
+ 732 filename = filename,
+ 733 save_to_file = save_to_file,
+ 734 print_out = print_out,
+ 735 output = output
+ 736 )
+ 737 else:
+ 738 if data48 is None:
+ 739 return data47.table_of_sessions(
+ 740 dir = dir,
+ 741 filename = filename,
+ 742 save_to_file = save_to_file,
+ 743 print_out = print_out,
+ 744 output = output
+ 745 )
+ 746 else:
+ 747 out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
+ 748 out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw')
+ 749 for k,x in enumerate(out47[0]):
+ 750 if k>7:
+ 751 out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47')
+ 752 out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48')
+ 753 out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:])
+ 754
+ 755 if save_to_file:
+ 756 if not os.path.exists(dir):
+ 757 os.makedirs(dir)
+ 758 if filename is None:
+ 759 filename = f'D47D48_sessions.csv'
+ 760 with open(f'{dir}/{filename}', 'w') as fid:
+ 761 fid.write(make_csv(out))
+ 762 if print_out:
+ 763 print('\n'+pretty_table(out))
+ 764 if output == 'raw':
+ 765 return out
+ 766 elif output == 'pretty':
+ 767 return pretty_table(out)
+ 768
+ 769
+ 770def table_of_analyses(
+ 771 data47 = None,
+ 772 data48 = None,
+ 773 dir = 'output',
+ 774 filename = None,
+ 775 save_to_file = True,
+ 776 print_out = True,
+ 777 output = None,
+ 778 ):
+ 779 '''
+ 780 Print out, save to disk and/or return a combined table of analyses
+ 781 for a pair of `D47data` and `D48data` objects.
+ 782
+ 783 If the sessions in `data47` and those in `data48` do not consist of
+ 784 the exact same sets of analyses, the table will have two columns
+ 785 `Session_47` and `Session_48` instead of a single `Session` column.
+ 786
+ 787 **Parameters**
+ 788
+ 789 + `data47`: `D47data` instance
+ 790 + `data48`: `D48data` instance
+ 791 + `dir`: the directory in which to save the table
+ 792 + `filename`: the name to the csv file to write to
+ 793 + `save_to_file`: whether to save the table to disk
+ 794 + `print_out`: whether to print out the table
+ 795 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
+ 796 if set to `'raw'`: return a list of list of strings
+ 797 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
+ 798 '''
+ 799 if data47 is None:
+ 800 if data48 is None:
+ 801 raise TypeError("Arguments must include at least one D47data() or D48data() instance.")
+ 802 else:
+ 803 return data48.table_of_analyses(
+ 804 dir = dir,
+ 805 filename = filename,
+ 806 save_to_file = save_to_file,
+ 807 print_out = print_out,
+ 808 output = output
+ 809 )
+ 810 else:
+ 811 if data48 is None:
+ 812 return data47.table_of_analyses(
+ 813 dir = dir,
+ 814 filename = filename,
+ 815 save_to_file = save_to_file,
+ 816 print_out = print_out,
+ 817 output = output
+ 818 )
+ 819 else:
+ 820 out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
+ 821 out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw')
+ 822
+ 823 if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical
+ 824 out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:])
+ 825 else:
+ 826 out47[0][1] = 'Session_47'
+ 827 out48[0][1] = 'Session_48'
+ 828 out47 = transpose_table(out47)
+ 829 out48 = transpose_table(out48)
+ 830 out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:])
+ 831
+ 832 if save_to_file:
+ 833 if not os.path.exists(dir):
+ 834 os.makedirs(dir)
+ 835 if filename is None:
+ 836 filename = f'D47D48_sessions.csv'
+ 837 with open(f'{dir}/{filename}', 'w') as fid:
+ 838 fid.write(make_csv(out))
+ 839 if print_out:
+ 840 print('\n'+pretty_table(out))
+ 841 if output == 'raw':
+ 842 return out
+ 843 elif output == 'pretty':
+ 844 return pretty_table(out)
+ 845
+ 846
+ 847class D4xdata(list):
+ 848 '''
+ 849 Store and process data for a large set of Δ47 and/or Δ48
+ 850 analyses, usually comprising more than one analytical session.
+ 851 '''
+ 852
+ 853 ### 17O CORRECTION PARAMETERS
+ 854 R13_VPDB = 0.01118 # (Chang & Li, 1990)
+ 855 '''
+ 856 Absolute (13C/12C) ratio of VPDB.
+ 857 By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm))
+ 858 '''
+ 859
+ 860 R18_VSMOW = 0.0020052 # (Baertschi, 1976)
+ 861 '''
+ 862 Absolute (18O/16C) ratio of VSMOW.
+ 863 By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1))
+ 864 '''
+ 865
+ 866 LAMBDA_17 = 0.528 # (Barkan & Luz, 2005)
+ 867 '''
+ 868 Mass-dependent exponent for triple oxygen isotopes.
+ 869 By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250))
+ 870 '''
+ 871
+ 872 R17_VSMOW = 0.00038475 # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB)
+ 873 '''
+ 874 Absolute (17O/16C) ratio of VSMOW.
+ 875 By default equal to 0.00038475
+ 876 ([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011),
+ 877 rescaled to `R13_VPDB`)
+ 878 '''
+ 879
+ 880 R18_VPDB = R18_VSMOW * 1.03092
+ 881 '''
+ 882 Absolute (18O/16C) ratio of VPDB.
+ 883 By definition equal to `R18_VSMOW * 1.03092`.
+ 884 '''
+ 885
+ 886 R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17
+ 887 '''
+ 888 Absolute (17O/16C) ratio of VPDB.
+ 889 By definition equal to `R17_VSMOW * 1.03092 ** LAMBDA_17`.
+ 890 '''
+ 891
+ 892 LEVENE_REF_SAMPLE = 'ETH-3'
+ 893 '''
+ 894 After the Δ4x standardization step, each sample is tested to
+ 895 assess whether the Δ4x variance within all analyses for that
+ 896 sample differs significantly from that observed for a given reference
+ 897 sample (using [Levene's test](https://en.wikipedia.org/wiki/Levene%27s_test),
+ 898 which yields a p-value corresponding to the null hypothesis that the
+ 899 underlying variances are equal).
+ 900
+ 901 `LEVENE_REF_SAMPLE` (by default equal to `'ETH-3'`) specifies which
+ 902 sample should be used as a reference for this test.
+ 903 '''
+ 904
+ 905 ALPHA_18O_ACID_REACTION = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6) # (Kim et al., 2007, calcite)
+ 906 '''
+ 907 Specifies the 18O/16O fractionation factor generally applicable
+ 908 to acid reactions in the dataset. Currently used by `D4xdata.wg()`,
+ 909 `D4xdata.standardize_d13C`, and `D4xdata.standardize_d18O`.
+ 910
+ 911 By default equal to 1.008129 (calcite reacted at 90 °C,
+ 912 [Kim et al., 2007](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)).
+ 913 '''
+ 914
+ 915 Nominal_d13C_VPDB = {
+ 916 'ETH-1': 2.02,
+ 917 'ETH-2': -10.17,
+ 918 'ETH-3': 1.71,
+ 919 } # (Bernasconi et al., 2018)
+ 920 '''
+ 921 Nominal δ13C_VPDB values assigned to carbonate standards, used by
+ 922 `D4xdata.standardize_d13C()`.
+ 923
+ 924 By default equal to `{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}` after
+ 925 [Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
+ 926 '''
+ 927
+ 928 Nominal_d18O_VPDB = {
+ 929 'ETH-1': -2.19,
+ 930 'ETH-2': -18.69,
+ 931 'ETH-3': -1.78,
+ 932 } # (Bernasconi et al., 2018)
+ 933 '''
+ 934 Nominal δ18O_VPDB values assigned to carbonate standards, used by
+ 935 `D4xdata.standardize_d18O()`.
+ 936
+ 937 By default equal to `{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}` after
+ 938 [Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385).
+ 939 '''
+ 940
+ 941 d13C_STANDARDIZATION_METHOD = '2pt'
+ 942 '''
+ 943 Method by which to standardize δ13C values:
+ 944
+ 945 + `none`: do not apply any δ13C standardization.
+ 946 + `'1pt'`: within each session, offset all initial δ13C values so as to
+ 947 minimize the difference between final δ13C_VPDB values and
+ 948 `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined).
+ 949 + `'2pt'`: within each session, apply a affine trasformation to all δ13C
+ 950 values so as to minimize the difference between final δ13C_VPDB
+ 951 values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB`
+ 952 is defined).
+ 953 '''
+ 954
+ 955 d18O_STANDARDIZATION_METHOD = '2pt'
+ 956 '''
+ 957 Method by which to standardize δ18O values:
+ 958
+ 959 + `none`: do not apply any δ18O standardization.
+ 960 + `'1pt'`: within each session, offset all initial δ18O values so as to
+ 961 minimize the difference between final δ18O_VPDB values and
+ 962 `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined).
+ 963 + `'2pt'`: within each session, apply a affine trasformation to all δ18O
+ 964 values so as to minimize the difference between final δ18O_VPDB
+ 965 values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB`
+ 966 is defined).
+ 967 '''
+ 968
+ 969 def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
+ 970 '''
+ 971 **Parameters**
+ 972
+ 973 + `l`: a list of dictionaries, with each dictionary including at least the keys
+ 974 `Sample`, `d45`, `d46`, and `d47` or `d48`.
+ 975 + `mass`: `'47'` or `'48'`
+ 976 + `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
+ 977 + `session`: define session name for analyses without a `Session` key
+ 978 + `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
+ 979
+ 980 Returns a `D4xdata` object derived from `list`.
+ 981 '''
+ 982 self._4x = mass
+ 983 self.verbose = verbose
+ 984 self.prefix = 'D4xdata'
+ 985 self.logfile = logfile
+ 986 list.__init__(self, l)
+ 987 self.Nf = None
+ 988 self.repeatability = {}
+ 989 self.refresh(session = session)
+ 990
+ 991
+ 992 def make_verbal(oldfun):
+ 993 '''
+ 994 Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
+ 995 '''
+ 996 @wraps(oldfun)
+ 997 def newfun(*args, verbose = '', **kwargs):
+ 998 myself = args[0]
+ 999 oldprefix = myself.prefix
+1000 myself.prefix = oldfun.__name__
+1001 if verbose != '':
+1002 oldverbose = myself.verbose
+1003 myself.verbose = verbose
+1004 out = oldfun(*args, **kwargs)
+1005 myself.prefix = oldprefix
+1006 if verbose != '':
+1007 myself.verbose = oldverbose
+1008 return out
+1009 return newfun
+1010
+1011
+1012 def msg(self, txt):
+1013 '''
+1014 Log a message to `self.logfile`, and print it out if `verbose = True`
+1015 '''
+1016 self.log(txt)
+1017 if self.verbose:
+1018 print(f'{f"[{self.prefix}]":<16} {txt}')
+1019
+1020
+1021 def vmsg(self, txt):
+1022 '''
+1023 Log a message to `self.logfile` and print it out
+1024 '''
+1025 self.log(txt)
+1026 print(txt)
+1027
+1028
+1029 def log(self, *txts):
+1030 '''
+1031 Log a message to `self.logfile`
+1032 '''
+1033 if self.logfile:
+1034 with open(self.logfile, 'a') as fid:
+1035 for txt in txts:
+1036 fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
+1037
+1038
+1039 def refresh(self, session = 'mySession'):
+1040 '''
+1041 Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
+1042 '''
+1043 self.fill_in_missing_info(session = session)
+1044 self.refresh_sessions()
+1045 self.refresh_samples()
+1046
+1047
+1048 def refresh_sessions(self):
+1049 '''
+1050 Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
+1051 to `False` for all sessions.
+1052 '''
+1053 self.sessions = {
+1054 s: {'data': [r for r in self if r['Session'] == s]}
+1055 for s in sorted({r['Session'] for r in self})
+1056 }
+1057 for s in self.sessions:
+1058 self.sessions[s]['scrambling_drift'] = False
+1059 self.sessions[s]['slope_drift'] = False
+1060 self.sessions[s]['wg_drift'] = False
+1061 self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
+1062 self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
+1063
+1064
+1065 def refresh_samples(self):
+1066 '''
+1067 Define `self.samples`, `self.anchors`, and `self.unknowns`.
+1068 '''
+1069 self.samples = {
+1070 s: {'data': [r for r in self if r['Sample'] == s]}
+1071 for s in sorted({r['Sample'] for r in self})
+1072 }
+1073 self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
+1074 self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
+1075
+1076
+1077 def read(self, filename, sep = '', session = ''):
+1078 '''
+1079 Read file in csv format to load data into a `D47data` object.
+1080
+1081 In the csv file, spaces before and after field separators (`','` by default)
+1082 are optional. Each line corresponds to a single analysis.
+1083
+1084 The required fields are:
+1085
+1086 + `UID`: a unique identifier
+1087 + `Session`: an identifier for the analytical session
+1088 + `Sample`: a sample identifier
+1089 + `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
+1090
+1091 Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
+1092 VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
+1093 and `d49` are optional, and set to NaN by default.
+1094
+1095 **Parameters**
+1096
+1097 + `fileneme`: the path of the file to read
+1098 + `sep`: csv separator delimiting the fields
+1099 + `session`: set `Session` field to this string for all analyses
+1100 '''
+1101 with open(filename) as fid:
+1102 self.input(fid.read(), sep = sep, session = session)
+1103
+1104
+1105 def input(self, txt, sep = '', session = ''):
+1106 '''
+1107 Read `txt` string in csv format to load analysis data into a `D47data` object.
+1108
+1109 In the csv string, spaces before and after field separators (`','` by default)
+1110 are optional. Each line corresponds to a single analysis.
+1111
+1112 The required fields are:
+1113
+1114 + `UID`: a unique identifier
+1115 + `Session`: an identifier for the analytical session
+1116 + `Sample`: a sample identifier
+1117 + `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
+1118
+1119 Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
+1120 VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
+1121 and `d49` are optional, and set to NaN by default.
+1122
+1123 **Parameters**
+1124
+1125 + `txt`: the csv string to read
+1126 + `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
+1127 whichever appers most often in `txt`.
+1128 + `session`: set `Session` field to this string for all analyses
+1129 '''
+1130 if sep == '':
+1131 sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
+1132 txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
+1133 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:]]
+1134
+1135 if session != '':
+1136 for r in data:
+1137 r['Session'] = session
+1138
+1139 self += data
+1140 self.refresh()
+1141
+1142
+1143 @make_verbal
+1144 def wg(self, samples = None, a18_acid = None):
+1145 '''
+1146 Compute bulk composition of the working gas for each session based on
+1147 the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
+1148 `self.Nominal_d18O_VPDB`.
+1149 '''
+1150
+1151 self.msg('Computing WG composition:')
+1152
+1153 if a18_acid is None:
+1154 a18_acid = self.ALPHA_18O_ACID_REACTION
+1155 if samples is None:
+1156 samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
+1157
+1158 assert a18_acid, f'Acid fractionation factor should not be zero.'
+1159
+1160 samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
+1161 R45R46_standards = {}
+1162 for sample in samples:
+1163 d13C_vpdb = self.Nominal_d13C_VPDB[sample]
+1164 d18O_vpdb = self.Nominal_d18O_VPDB[sample]
+1165 R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
+1166 R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
+1167 R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
+1168
+1169 C12_s = 1 / (1 + R13_s)
+1170 C13_s = R13_s / (1 + R13_s)
+1171 C16_s = 1 / (1 + R17_s + R18_s)
+1172 C17_s = R17_s / (1 + R17_s + R18_s)
+1173 C18_s = R18_s / (1 + R17_s + R18_s)
+1174
+1175 C626_s = C12_s * C16_s ** 2
+1176 C627_s = 2 * C12_s * C16_s * C17_s
+1177 C628_s = 2 * C12_s * C16_s * C18_s
+1178 C636_s = C13_s * C16_s ** 2
+1179 C637_s = 2 * C13_s * C16_s * C17_s
+1180 C727_s = C12_s * C17_s ** 2
+1181
+1182 R45_s = (C627_s + C636_s) / C626_s
+1183 R46_s = (C628_s + C637_s + C727_s) / C626_s
+1184 R45R46_standards[sample] = (R45_s, R46_s)
+1185
+1186 for s in self.sessions:
+1187 db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
+1188 assert db, f'No sample from {samples} found in session "{s}".'
+1189# dbsamples = sorted({r['Sample'] for r in db})
+1190
+1191 X = [r['d45'] for r in db]
+1192 Y = [R45R46_standards[r['Sample']][0] for r in db]
+1193 x1, x2 = np.min(X), np.max(X)
+1194
+1195 if x1 < x2:
+1196 wgcoord = x1/(x1-x2)
+1197 else:
+1198 wgcoord = 999
+1199
+1200 if wgcoord < -.5 or wgcoord > 1.5:
+1201 # unreasonable to extrapolate to d45 = 0
+1202 R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
+1203 else :
+1204 # d45 = 0 is reasonably well bracketed
+1205 R45_wg = np.polyfit(X, Y, 1)[1]
+1206
+1207 X = [r['d46'] for r in db]
+1208 Y = [R45R46_standards[r['Sample']][1] for r in db]
+1209 x1, x2 = np.min(X), np.max(X)
+1210
+1211 if x1 < x2:
+1212 wgcoord = x1/(x1-x2)
+1213 else:
+1214 wgcoord = 999
+1215
+1216 if wgcoord < -.5 or wgcoord > 1.5:
+1217 # unreasonable to extrapolate to d46 = 0
+1218 R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
+1219 else :
+1220 # d46 = 0 is reasonably well bracketed
+1221 R46_wg = np.polyfit(X, Y, 1)[1]
+1222
+1223 d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
+1224
+1225 self.msg(f'Session {s} WG: δ13C_VPDB = {d13Cwg_VPDB:.3f} δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
+1226
+1227 self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
+1228 self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
+1229 for r in self.sessions[s]['data']:
+1230 r['d13Cwg_VPDB'] = d13Cwg_VPDB
+1231 r['d18Owg_VSMOW'] = d18Owg_VSMOW
+1232
+1233
+1234 def compute_bulk_delta(self, R45, R46, D17O = 0):
+1235 '''
+1236 Compute δ13C_VPDB and δ18O_VSMOW,
+1237 by solving the generalized form of equation (17) from
+1238 [Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
+1239 assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
+1240 solving the corresponding second-order Taylor polynomial.
+1241 (Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
+1242 '''
+1243
+1244 K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
+1245
+1246 A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
+1247 B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
+1248 C = 2 * self.R18_VSMOW
+1249 D = -R46
+1250
+1251 aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
+1252 bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
+1253 cc = A + B + C + D
+1254
+1255 d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
+1256
+1257 R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
+1258 R17 = K * R18 ** self.LAMBDA_17
+1259 R13 = R45 - 2 * R17
+1260
+1261 d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
+1262
+1263 return d13C_VPDB, d18O_VSMOW
+1264
+1265
+1266 @make_verbal
+1267 def crunch(self, verbose = ''):
+1268 '''
+1269 Compute bulk composition and raw clumped isotope anomalies for all analyses.
+1270 '''
+1271 for r in self:
+1272 self.compute_bulk_and_clumping_deltas(r)
+1273 self.standardize_d13C()
+1274 self.standardize_d18O()
+1275 self.msg(f"Crunched {len(self)} analyses.")
+1276
+1277
+1278 def fill_in_missing_info(self, session = 'mySession'):
+1279 '''
+1280 Fill in optional fields with default values
+1281 '''
+1282 for i,r in enumerate(self):
+1283 if 'D17O' not in r:
+1284 r['D17O'] = 0.
+1285 if 'UID' not in r:
+1286 r['UID'] = f'{i+1}'
+1287 if 'Session' not in r:
+1288 r['Session'] = session
+1289 for k in ['d47', 'd48', 'd49']:
+1290 if k not in r:
+1291 r[k] = np.nan
+1292
+1293
+1294 def standardize_d13C(self):
+1295 '''
+1296 Perform δ13C standadization within each session `s` according to
+1297 `self.sessions[s]['d13C_standardization_method']`, which is defined by default
+1298 by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
+1299 may be redefined abitrarily at a later stage.
+1300 '''
+1301 for s in self.sessions:
+1302 if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
+1303 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]
+1304 X,Y = zip(*XY)
+1305 if self.sessions[s]['d13C_standardization_method'] == '1pt':
+1306 offset = np.mean(Y) - np.mean(X)
+1307 for r in self.sessions[s]['data']:
+1308 r['d13C_VPDB'] += offset
+1309 elif self.sessions[s]['d13C_standardization_method'] == '2pt':
+1310 a,b = np.polyfit(X,Y,1)
+1311 for r in self.sessions[s]['data']:
+1312 r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
+1313
+1314 def standardize_d18O(self):
+1315 '''
+1316 Perform δ18O standadization within each session `s` according to
+1317 `self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
+1318 which is defined by default by `D47data.refresh_sessions()`as equal to
+1319 `self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
+1320 '''
+1321 for s in self.sessions:
+1322 if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
+1323 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]
+1324 X,Y = zip(*XY)
+1325 Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
+1326 if self.sessions[s]['d18O_standardization_method'] == '1pt':
+1327 offset = np.mean(Y) - np.mean(X)
+1328 for r in self.sessions[s]['data']:
+1329 r['d18O_VSMOW'] += offset
+1330 elif self.sessions[s]['d18O_standardization_method'] == '2pt':
+1331 a,b = np.polyfit(X,Y,1)
+1332 for r in self.sessions[s]['data']:
+1333 r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
+1334
+1335
+1336 def compute_bulk_and_clumping_deltas(self, r):
+1337 '''
+1338 Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
+1339 '''
+1340
+1341 # Compute working gas R13, R18, and isobar ratios
+1342 R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
+1343 R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
+1344 R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
+1345
+1346 # Compute analyte isobar ratios
+1347 R45 = (1 + r['d45'] / 1000) * R45_wg
+1348 R46 = (1 + r['d46'] / 1000) * R46_wg
+1349 R47 = (1 + r['d47'] / 1000) * R47_wg
+1350 R48 = (1 + r['d48'] / 1000) * R48_wg
+1351 R49 = (1 + r['d49'] / 1000) * R49_wg
+1352
+1353 r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
+1354 R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
+1355 R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
+1356
+1357 # Compute stochastic isobar ratios of the analyte
+1358 R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
+1359 R13, R18, D17O = r['D17O']
+1360 )
+1361
+1362 # Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
+1363 # and raise a warning if the corresponding anomalies exceed 0.02 ppm.
+1364 if (R45 / R45stoch - 1) > 5e-8:
+1365 self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
+1366 if (R46 / R46stoch - 1) > 5e-8:
+1367 self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
+1368
+1369 # Compute raw clumped isotope anomalies
+1370 r['D47raw'] = 1000 * (R47 / R47stoch - 1)
+1371 r['D48raw'] = 1000 * (R48 / R48stoch - 1)
+1372 r['D49raw'] = 1000 * (R49 / R49stoch - 1)
+1373
+1374
+1375 def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
+1376 '''
+1377 Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
+1378 optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
+1379 anomalies (`D47`, `D48`, `D49`), all expressed in permil.
+1380 '''
+1381
+1382 # Compute R17
+1383 R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
+1384
+1385 # Compute isotope concentrations
+1386 C12 = (1 + R13) ** -1
+1387 C13 = C12 * R13
+1388 C16 = (1 + R17 + R18) ** -1
+1389 C17 = C16 * R17
+1390 C18 = C16 * R18
+1391
+1392 # Compute stochastic isotopologue concentrations
+1393 C626 = C16 * C12 * C16
+1394 C627 = C16 * C12 * C17 * 2
+1395 C628 = C16 * C12 * C18 * 2
+1396 C636 = C16 * C13 * C16
+1397 C637 = C16 * C13 * C17 * 2
+1398 C638 = C16 * C13 * C18 * 2
+1399 C727 = C17 * C12 * C17
+1400 C728 = C17 * C12 * C18 * 2
+1401 C737 = C17 * C13 * C17
+1402 C738 = C17 * C13 * C18 * 2
+1403 C828 = C18 * C12 * C18
+1404 C838 = C18 * C13 * C18
+1405
+1406 # Compute stochastic isobar ratios
+1407 R45 = (C636 + C627) / C626
+1408 R46 = (C628 + C637 + C727) / C626
+1409 R47 = (C638 + C728 + C737) / C626
+1410 R48 = (C738 + C828) / C626
+1411 R49 = C838 / C626
+1412
+1413 # Account for stochastic anomalies
+1414 R47 *= 1 + D47 / 1000
+1415 R48 *= 1 + D48 / 1000
+1416 R49 *= 1 + D49 / 1000
+1417
+1418 # Return isobar ratios
+1419 return R45, R46, R47, R48, R49
+1420
+1421
+1422 def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
+1423 '''
+1424 Split unknown samples by UID (treat all analyses as different samples)
+1425 or by session (treat analyses of a given sample in different sessions as
+1426 different samples).
+1427
+1428 **Parameters**
+1429
+1430 + `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
+1431 + `grouping`: `by_uid` | `by_session`
+1432 '''
+1433 if samples_to_split == 'all':
+1434 samples_to_split = [s for s in self.unknowns]
+1435 gkeys = {'by_uid':'UID', 'by_session':'Session'}
+1436 self.grouping = grouping.lower()
+1437 if self.grouping in gkeys:
+1438 gkey = gkeys[self.grouping]
+1439 for r in self:
+1440 if r['Sample'] in samples_to_split:
+1441 r['Sample_original'] = r['Sample']
+1442 r['Sample'] = f"{r['Sample']}__{r[gkey]}"
+1443 elif r['Sample'] in self.unknowns:
+1444 r['Sample_original'] = r['Sample']
+1445 self.refresh_samples()
+1446
+1447
+1448 def unsplit_samples(self, tables = False):
+1449 '''
+1450 Reverse the effects of `D47data.split_samples()`.
+1451
+1452 This should only be used after `D4xdata.standardize()` with `method='pooled'`.
+1453
+1454 After `D4xdata.standardize()` with `method='indep_sessions'`, one should
+1455 probably use `D4xdata.combine_samples()` instead to reverse the effects of
+1456 `D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
+1457 effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
+1458 that case session-averaged Δ4x values are statistically independent).
+1459 '''
+1460 unknowns_old = sorted({s for s in self.unknowns})
+1461 CM_old = self.standardization.covar[:,:]
+1462 VD_old = self.standardization.params.valuesdict().copy()
+1463 vars_old = self.standardization.var_names
+1464
+1465 unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
+1466
+1467 Ns = len(vars_old) - len(unknowns_old)
+1468 vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
+1469 VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
+1470
+1471 W = np.zeros((len(vars_new), len(vars_old)))
+1472 W[:Ns,:Ns] = np.eye(Ns)
+1473 for u in unknowns_new:
+1474 splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
+1475 if self.grouping == 'by_session':
+1476 weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
+1477 elif self.grouping == 'by_uid':
+1478 weights = [1 for s in splits]
+1479 sw = sum(weights)
+1480 weights = [w/sw for w in weights]
+1481 W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
+1482
+1483 CM_new = W @ CM_old @ W.T
+1484 V = W @ np.array([[VD_old[k]] for k in vars_old])
+1485 VD_new = {k:v[0] for k,v in zip(vars_new, V)}
+1486
+1487 self.standardization.covar = CM_new
+1488 self.standardization.params.valuesdict = lambda : VD_new
+1489 self.standardization.var_names = vars_new
+1490
+1491 for r in self:
+1492 if r['Sample'] in self.unknowns:
+1493 r['Sample_split'] = r['Sample']
+1494 r['Sample'] = r['Sample_original']
+1495
+1496 self.refresh_samples()
+1497 self.consolidate_samples()
+1498 self.repeatabilities()
+1499
+1500 if tables:
+1501 self.table_of_analyses()
+1502 self.table_of_samples()
+1503
+1504 def assign_timestamps(self):
+1505 '''
+1506 Assign a time field `t` of type `float` to each analysis.
+1507
+1508 If `TimeTag` is one of the data fields, `t` is equal within a given session
+1509 to `TimeTag` minus the mean value of `TimeTag` for that session.
+1510 Otherwise, `TimeTag` is by default equal to the index of each analysis
+1511 in the dataset and `t` is defined as above.
+1512 '''
+1513 for session in self.sessions:
+1514 sdata = self.sessions[session]['data']
+1515 try:
+1516 t0 = np.mean([r['TimeTag'] for r in sdata])
+1517 for r in sdata:
+1518 r['t'] = r['TimeTag'] - t0
+1519 except KeyError:
+1520 t0 = (len(sdata)-1)/2
+1521 for t,r in enumerate(sdata):
+1522 r['t'] = t - t0
+1523
+1524
+1525 def report(self):
+1526 '''
+1527 Prints a report on the standardization fit.
+1528 Only applicable after `D4xdata.standardize(method='pooled')`.
+1529 '''
+1530 report_fit(self.standardization)
+1531
+1532
+1533 def combine_samples(self, sample_groups):
+1534 '''
+1535 Combine analyses of different samples to compute weighted average Δ4x
+1536 and new error (co)variances corresponding to the groups defined by the `sample_groups`
+1537 dictionary.
+1538
+1539 Caution: samples are weighted by number of replicate analyses, which is a
+1540 reasonable default behavior but is not always optimal (e.g., in the case of strongly
+1541 correlated analytical errors for one or more samples).
+1542
+1543 Returns a tuplet of:
+1544
+1545 + the list of group names
+1546 + an array of the corresponding Δ4x values
+1547 + the corresponding (co)variance matrix
+1548
+1549 **Parameters**
+1550
+1551 + `sample_groups`: a dictionary of the form:
+1552 ```py
+1553 {'group1': ['sample_1', 'sample_2'],
+1554 'group2': ['sample_3', 'sample_4', 'sample_5']}
+1555 ```
+1556 '''
+1557
+1558 samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
+1559 groups = sorted(sample_groups.keys())
+1560 group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
+1561 D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
+1562 CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
+1563 W = np.array([
+1564 [self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
+1565 for j in groups])
+1566 D4x_new = W @ D4x_old
+1567 CM_new = W @ CM_old @ W.T
+1568
+1569 return groups, D4x_new[:,0], CM_new
+1570
+1571
+1572 @make_verbal
+1573 def standardize(self,
+1574 method = 'pooled',
+1575 weighted_sessions = [],
+1576 consolidate = True,
+1577 consolidate_tables = False,
+1578 consolidate_plots = False,
+1579 constraints = {},
+1580 ):
+1581 '''
+1582 Compute absolute Δ4x values for all replicate analyses and for sample averages.
+1583 If `method` argument is set to `'pooled'`, the standardization processes all sessions
+1584 in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous,
+1585 i.e. that their true Δ4x value does not change between sessions,
+1586 ([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to
+1587 `'indep_sessions'`, the standardization processes each session independently, based only
+1588 on anchors analyses.
+1589 '''
+1590
+1591 self.standardization_method = method
+1592 self.assign_timestamps()
+1593
+1594 if method == 'pooled':
+1595 if weighted_sessions:
+1596 for session_group in weighted_sessions:
+1597 if self._4x == '47':
+1598 X = D47data([r for r in self if r['Session'] in session_group])
+1599 elif self._4x == '48':
+1600 X = D48data([r for r in self if r['Session'] in session_group])
+1601 X.Nominal_D4x = self.Nominal_D4x.copy()
+1602 X.refresh()
+1603 result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False)
+1604 w = np.sqrt(result.redchi)
+1605 self.msg(f'Session group {session_group} MRSWD = {w:.4f}')
+1606 for r in X:
+1607 r[f'wD{self._4x}raw'] *= w
+1608 else:
+1609 self.msg(f'All D{self._4x}raw weights set to 1 ‰')
+1610 for r in self:
+1611 r[f'wD{self._4x}raw'] = 1.
+1612
+1613 params = Parameters()
+1614 for k,session in enumerate(self.sessions):
+1615 self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.")
+1616 self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.")
+1617 self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.")
+1618 s = pf(session)
+1619 params.add(f'a_{s}', value = 0.9)
+1620 params.add(f'b_{s}', value = 0.)
+1621 params.add(f'c_{s}', value = -0.9)
+1622 params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift'])
+1623 params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift'])
+1624 params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift'])
+1625 for sample in self.unknowns:
+1626 params.add(f'D{self._4x}_{pf(sample)}', value = 0.5)
+1627
+1628 for k in constraints:
+1629 params[k].expr = constraints[k]
+1630
+1631 def residuals(p):
+1632 R = []
+1633 for r in self:
+1634 session = pf(r['Session'])
+1635 sample = pf(r['Sample'])
+1636 if r['Sample'] in self.Nominal_D4x:
+1637 R += [ (
+1638 r[f'D{self._4x}raw'] - (
+1639 p[f'a_{session}'] * self.Nominal_D4x[r['Sample']]
+1640 + p[f'b_{session}'] * r[f'd{self._4x}']
+1641 + p[f'c_{session}']
+1642 + r['t'] * (
+1643 p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']]
+1644 + p[f'b2_{session}'] * r[f'd{self._4x}']
+1645 + p[f'c2_{session}']
+1646 )
+1647 )
+1648 ) / r[f'wD{self._4x}raw'] ]
+1649 else:
+1650 R += [ (
+1651 r[f'D{self._4x}raw'] - (
+1652 p[f'a_{session}'] * p[f'D{self._4x}_{sample}']
+1653 + p[f'b_{session}'] * r[f'd{self._4x}']
+1654 + p[f'c_{session}']
+1655 + r['t'] * (
+1656 p[f'a2_{session}'] * p[f'D{self._4x}_{sample}']
+1657 + p[f'b2_{session}'] * r[f'd{self._4x}']
+1658 + p[f'c2_{session}']
+1659 )
+1660 )
+1661 ) / r[f'wD{self._4x}raw'] ]
+1662 return R
+1663
+1664 M = Minimizer(residuals, params)
+1665 result = M.least_squares()
+1666 self.Nf = result.nfree
+1667 self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
+1668# if self.verbose:
+1669# report_fit(result)
+1670
+1671 for r in self:
+1672 s = pf(r["Session"])
+1673 a = result.params.valuesdict()[f'a_{s}']
+1674 b = result.params.valuesdict()[f'b_{s}']
+1675 c = result.params.valuesdict()[f'c_{s}']
+1676 a2 = result.params.valuesdict()[f'a2_{s}']
+1677 b2 = result.params.valuesdict()[f'b2_{s}']
+1678 c2 = result.params.valuesdict()[f'c2_{s}']
+1679 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'])
+1680
+1681 self.standardization = result
+1682
+1683 for session in self.sessions:
+1684 self.sessions[session]['Np'] = 3
+1685 for k in ['scrambling', 'slope', 'wg']:
+1686 if self.sessions[session][f'{k}_drift']:
+1687 self.sessions[session]['Np'] += 1
+1688
+1689 if consolidate:
+1690 self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
+1691 return result
+1692
+1693
+1694 elif method == 'indep_sessions':
+1695
+1696 if weighted_sessions:
+1697 for session_group in weighted_sessions:
+1698 X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x)
+1699 X.Nominal_D4x = self.Nominal_D4x.copy()
+1700 X.refresh()
+1701 # This is only done to assign r['wD47raw'] for r in X:
+1702 X.standardize(method = method, weighted_sessions = [], consolidate = False)
+1703 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}')
+1704 else:
+1705 self.msg('All weights set to 1 ‰')
+1706 for r in self:
+1707 r[f'wD{self._4x}raw'] = 1
+1708
+1709 for session in self.sessions:
+1710 s = self.sessions[session]
+1711 p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2']
+1712 p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']]
+1713 s['Np'] = sum(p_active)
+1714 sdata = s['data']
+1715
+1716 A = np.array([
+1717 [
+1718 self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'],
+1719 r[f'd{self._4x}'] / r[f'wD{self._4x}raw'],
+1720 1 / r[f'wD{self._4x}raw'],
+1721 self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'],
+1722 r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'],
+1723 r['t'] / r[f'wD{self._4x}raw']
+1724 ]
+1725 for r in sdata if r['Sample'] in self.anchors
+1726 ])[:,p_active] # only keep columns for the active parameters
+1727 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])
+1728 s['Na'] = Y.size
+1729 CM = linalg.inv(A.T @ A)
+1730 bf = (CM @ A.T @ Y).T[0,:]
+1731 k = 0
+1732 for n,a in zip(p_names, p_active):
+1733 if a:
+1734 s[n] = bf[k]
+1735# self.msg(f'{n} = {bf[k]}')
+1736 k += 1
+1737 else:
+1738 s[n] = 0.
+1739# self.msg(f'{n} = 0.0')
+1740
+1741 for r in sdata :
+1742 a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2']
+1743 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'])
+1744 r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t'])
+1745
+1746 s['CM'] = np.zeros((6,6))
+1747 i = 0
+1748 k_active = [j for j,a in enumerate(p_active) if a]
+1749 for j,a in enumerate(p_active):
+1750 if a:
+1751 s['CM'][j,k_active] = CM[i,:]
+1752 i += 1
+1753
+1754 if not weighted_sessions:
+1755 w = self.rmswd()['rmswd']
+1756 for r in self:
+1757 r[f'wD{self._4x}'] *= w
+1758 r[f'wD{self._4x}raw'] *= w
+1759 for session in self.sessions:
+1760 self.sessions[session]['CM'] *= w**2
+1761
+1762 for session in self.sessions:
+1763 s = self.sessions[session]
+1764 s['SE_a'] = s['CM'][0,0]**.5
+1765 s['SE_b'] = s['CM'][1,1]**.5
+1766 s['SE_c'] = s['CM'][2,2]**.5
+1767 s['SE_a2'] = s['CM'][3,3]**.5
+1768 s['SE_b2'] = s['CM'][4,4]**.5
+1769 s['SE_c2'] = s['CM'][5,5]**.5
+1770
+1771 if not weighted_sessions:
+1772 self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions])
+1773 else:
+1774 self.Nf = 0
+1775 for sg in weighted_sessions:
+1776 self.Nf += self.rmswd(sessions = sg)['Nf']
+1777
+1778 self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf)
+1779
+1780 avgD4x = {
+1781 sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample])
+1782 for sample in self.samples
+1783 }
+1784 chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self])
+1785 rD4x = (chi2/self.Nf)**.5
+1786 self.repeatability[f'sigma_{self._4x}'] = rD4x
+1787
+1788 if consolidate:
+1789 self.consolidate(tables = consolidate_tables, plots = consolidate_plots)
+1790
+1791
+1792 def standardization_error(self, session, d4x, D4x, t = 0):
+1793 '''
+1794 Compute standardization error for a given session and
+1795 (δ47, Δ47) composition.
+1796 '''
+1797 a = self.sessions[session]['a']
+1798 b = self.sessions[session]['b']
+1799 c = self.sessions[session]['c']
+1800 a2 = self.sessions[session]['a2']
+1801 b2 = self.sessions[session]['b2']
+1802 c2 = self.sessions[session]['c2']
+1803 CM = self.sessions[session]['CM']
+1804
+1805 x, y = D4x, d4x
+1806 z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
+1807# x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
+1808 dxdy = -(b+b2*t) / (a+a2*t)
+1809 dxdz = 1. / (a+a2*t)
+1810 dxda = -x / (a+a2*t)
+1811 dxdb = -y / (a+a2*t)
+1812 dxdc = -1. / (a+a2*t)
+1813 dxda2 = -x * a2 / (a+a2*t)
+1814 dxdb2 = -y * t / (a+a2*t)
+1815 dxdc2 = -t / (a+a2*t)
+1816 V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
+1817 sx = (V @ CM @ V.T) ** .5
+1818 return sx
+1819
+1820
+1821 @make_verbal
+1822 def summary(self,
+1823 dir = 'output',
+1824 filename = None,
+1825 save_to_file = True,
+1826 print_out = True,
+1827 ):
+1828 '''
+1829 Print out an/or save to disk a summary of the standardization results.
+1830
+1831 **Parameters**
+1832
+1833 + `dir`: the directory in which to save the table
+1834 + `filename`: the name to the csv file to write to
+1835 + `save_to_file`: whether to save the table to disk
+1836 + `print_out`: whether to print out the table
+1837 '''
+1838
+1839 out = []
+1840 out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
+1841 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])})"]]
+1842 out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
+1843 out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
+1844 out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
+1845 out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
+1846 out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
+1847 out += [['Model degrees of freedom', f"{self.Nf}"]]
+1848 out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
+1849 out += [['Standardization method', self.standardization_method]]
+1850
+1851 if save_to_file:
+1852 if not os.path.exists(dir):
+1853 os.makedirs(dir)
+1854 if filename is None:
+1855 filename = f'D{self._4x}_summary.csv'
+1856 with open(f'{dir}/{filename}', 'w') as fid:
+1857 fid.write(make_csv(out))
+1858 if print_out:
+1859 self.msg('\n' + pretty_table(out, header = 0))
+1860
+1861
+1862 @make_verbal
+1863 def table_of_sessions(self,
+1864 dir = 'output',
+1865 filename = None,
+1866 save_to_file = True,
+1867 print_out = True,
+1868 output = None,
+1869 ):
+1870 '''
+1871 Print out an/or save to disk a table of sessions.
+1872
+1873 **Parameters**
+1874
+1875 + `dir`: the directory in which to save the table
+1876 + `filename`: the name to the csv file to write to
+1877 + `save_to_file`: whether to save the table to disk
+1878 + `print_out`: whether to print out the table
+1879 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
+1880 if set to `'raw'`: return a list of list of strings
+1881 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
+1882 '''
+1883 include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
+1884 include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
+1885 include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
+1886
+1887 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']]
+1888 if include_a2:
+1889 out[-1] += ['a2 ± SE']
+1890 if include_b2:
+1891 out[-1] += ['b2 ± SE']
+1892 if include_c2:
+1893 out[-1] += ['c2 ± SE']
+1894 for session in self.sessions:
+1895 out += [[
+1896 session,
+1897 f"{self.sessions[session]['Na']}",
+1898 f"{self.sessions[session]['Nu']}",
+1899 f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
+1900 f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
+1901 f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
+1902 f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
+1903 f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
+1904 f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
+1905 f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
+1906 f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
+1907 ]]
+1908 if include_a2:
+1909 if self.sessions[session]['scrambling_drift']:
+1910 out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
+1911 else:
+1912 out[-1] += ['']
+1913 if include_b2:
+1914 if self.sessions[session]['slope_drift']:
+1915 out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
+1916 else:
+1917 out[-1] += ['']
+1918 if include_c2:
+1919 if self.sessions[session]['wg_drift']:
+1920 out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
+1921 else:
+1922 out[-1] += ['']
+1923
+1924 if save_to_file:
+1925 if not os.path.exists(dir):
+1926 os.makedirs(dir)
+1927 if filename is None:
+1928 filename = f'D{self._4x}_sessions.csv'
+1929 with open(f'{dir}/{filename}', 'w') as fid:
+1930 fid.write(make_csv(out))
+1931 if print_out:
+1932 self.msg('\n' + pretty_table(out))
+1933 if output == 'raw':
+1934 return out
+1935 elif output == 'pretty':
+1936 return pretty_table(out)
+1937
+1938
+1939 @make_verbal
+1940 def table_of_analyses(
+1941 self,
+1942 dir = 'output',
+1943 filename = None,
+1944 save_to_file = True,
+1945 print_out = True,
+1946 output = None,
+1947 ):
+1948 '''
+1949 Print out an/or save to disk a table of analyses.
+1950
+1951 **Parameters**
+1952
+1953 + `dir`: the directory in which to save the table
+1954 + `filename`: the name to the csv file to write to
+1955 + `save_to_file`: whether to save the table to disk
+1956 + `print_out`: whether to print out the table
+1957 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
+1958 if set to `'raw'`: return a list of list of strings
+1959 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
+1960 '''
+1961
+1962 out = [['UID','Session','Sample']]
+1963 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}]
+1964 for f in extra_fields:
+1965 out[-1] += [f[0]]
+1966 out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
+1967 for r in self:
+1968 out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
+1969 for f in extra_fields:
+1970 out[-1] += [f"{r[f[0]]:{f[1]}}"]
+1971 out[-1] += [
+1972 f"{r['d13Cwg_VPDB']:.3f}",
+1973 f"{r['d18Owg_VSMOW']:.3f}",
+1974 f"{r['d45']:.6f}",
+1975 f"{r['d46']:.6f}",
+1976 f"{r['d47']:.6f}",
+1977 f"{r['d48']:.6f}",
+1978 f"{r['d49']:.6f}",
+1979 f"{r['d13C_VPDB']:.6f}",
+1980 f"{r['d18O_VSMOW']:.6f}",
+1981 f"{r['D47raw']:.6f}",
+1982 f"{r['D48raw']:.6f}",
+1983 f"{r['D49raw']:.6f}",
+1984 f"{r[f'D{self._4x}']:.6f}"
+1985 ]
+1986 if save_to_file:
+1987 if not os.path.exists(dir):
+1988 os.makedirs(dir)
+1989 if filename is None:
+1990 filename = f'D{self._4x}_analyses.csv'
+1991 with open(f'{dir}/{filename}', 'w') as fid:
+1992 fid.write(make_csv(out))
+1993 if print_out:
+1994 self.msg('\n' + pretty_table(out))
+1995 return out
+1996
+1997 @make_verbal
+1998 def covar_table(
+1999 self,
+2000 correl = False,
+2001 dir = 'output',
+2002 filename = None,
+2003 save_to_file = True,
+2004 print_out = True,
+2005 output = None,
+2006 ):
+2007 '''
+2008 Print out, save to disk and/or return the variance-covariance matrix of D4x
+2009 for all unknown samples.
+2010
+2011 **Parameters**
+2012
+2013 + `dir`: the directory in which to save the csv
+2014 + `filename`: the name of the csv file to write to
+2015 + `save_to_file`: whether to save the csv
+2016 + `print_out`: whether to print out the matrix
+2017 + `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
+2018 if set to `'raw'`: return a list of list of strings
+2019 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
+2020 '''
+2021 samples = sorted([u for u in self.unknowns])
+2022 out = [[''] + samples]
+2023 for s1 in samples:
+2024 out.append([s1])
+2025 for s2 in samples:
+2026 if correl:
+2027 out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
+2028 else:
+2029 out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
+2030
+2031 if save_to_file:
+2032 if not os.path.exists(dir):
+2033 os.makedirs(dir)
+2034 if filename is None:
+2035 if correl:
+2036 filename = f'D{self._4x}_correl.csv'
+2037 else:
+2038 filename = f'D{self._4x}_covar.csv'
+2039 with open(f'{dir}/{filename}', 'w') as fid:
+2040 fid.write(make_csv(out))
+2041 if print_out:
+2042 self.msg('\n'+pretty_table(out))
+2043 if output == 'raw':
+2044 return out
+2045 elif output == 'pretty':
+2046 return pretty_table(out)
+2047
+2048 @make_verbal
+2049 def table_of_samples(
+2050 self,
+2051 dir = 'output',
+2052 filename = None,
+2053 save_to_file = True,
+2054 print_out = True,
+2055 output = None,
+2056 ):
+2057 '''
+2058 Print out, save to disk and/or return a table of samples.
+2059
+2060 **Parameters**
+2061
+2062 + `dir`: the directory in which to save the csv
+2063 + `filename`: the name of the csv file to write to
+2064 + `save_to_file`: whether to save the csv
+2065 + `print_out`: whether to print out the table
+2066 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
+2067 if set to `'raw'`: return a list of list of strings
+2068 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
+2069 '''
+2070
+2071 out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
+2072 for sample in self.anchors:
+2073 out += [[
+2074 f"{sample}",
+2075 f"{self.samples[sample]['N']}",
+2076 f"{self.samples[sample]['d13C_VPDB']:.2f}",
+2077 f"{self.samples[sample]['d18O_VSMOW']:.2f}",
+2078 f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
+2079 f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
+2080 ]]
+2081 for sample in self.unknowns:
+2082 out += [[
+2083 f"{sample}",
+2084 f"{self.samples[sample]['N']}",
+2085 f"{self.samples[sample]['d13C_VPDB']:.2f}",
+2086 f"{self.samples[sample]['d18O_VSMOW']:.2f}",
+2087 f"{self.samples[sample][f'D{self._4x}']:.4f}",
+2088 f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
+2089 f"± {self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
+2090 f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
+2091 f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
+2092 ]]
+2093 if save_to_file:
+2094 if not os.path.exists(dir):
+2095 os.makedirs(dir)
+2096 if filename is None:
+2097 filename = f'D{self._4x}_samples.csv'
+2098 with open(f'{dir}/{filename}', 'w') as fid:
+2099 fid.write(make_csv(out))
+2100 if print_out:
+2101 self.msg('\n'+pretty_table(out))
+2102 if output == 'raw':
+2103 return out
+2104 elif output == 'pretty':
+2105 return pretty_table(out)
+2106
+2107
+2108 def plot_sessions(self, dir = 'output', figsize = (8,8)):
+2109 '''
+2110 Generate session plots and save them to disk.
+2111
+2112 **Parameters**
+2113
+2114 + `dir`: the directory in which to save the plots
+2115 + `figsize`: the width and height (in inches) of each plot
+2116 '''
+2117 if not os.path.exists(dir):
+2118 os.makedirs(dir)
+2119
+2120 for session in self.sessions:
+2121 sp = self.plot_single_session(session, xylimits = 'constant')
+2122 ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
+2123 ppl.close(sp.fig)
+2124
+2125
+2126 @make_verbal
+2127 def consolidate_samples(self):
+2128 '''
+2129 Compile various statistics for each sample.
+2130
+2131 For each anchor sample:
+2132
+2133 + `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
+2134 + `SE_D47` or `SE_D48`: set to zero by definition
+2135
+2136 For each unknown sample:
+2137
+2138 + `D47` or `D48`: the standardized Δ4x value for this unknown
+2139 + `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
+2140
+2141 For each anchor and unknown:
+2142
+2143 + `N`: the total number of analyses of this sample
+2144 + `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
+2145 + `d13C_VPDB`: the average δ13C_VPDB value for this sample
+2146 + `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
+2147 + `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
+2148 variance, indicating whether the Δ4x repeatability this sample differs significantly from
+2149 that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
+2150 '''
+2151 D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
+2152 for sample in self.samples:
+2153 self.samples[sample]['N'] = len(self.samples[sample]['data'])
+2154 if self.samples[sample]['N'] > 1:
+2155 self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
+2156
+2157 self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
+2158 self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
+2159
+2160 D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
+2161 if len(D4x_pop) > 2:
+2162 self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
+2163
+2164 if self.standardization_method == 'pooled':
+2165 for sample in self.anchors:
+2166 self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
+2167 self.samples[sample][f'SE_D{self._4x}'] = 0.
+2168 for sample in self.unknowns:
+2169 self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
+2170 try:
+2171 self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
+2172 except ValueError:
+2173 # when `sample` is constrained by self.standardize(constraints = {...}),
+2174 # it is no longer listed in self.standardization.var_names.
+2175 # Temporary fix: define SE as zero for now
+2176 self.samples[sample][f'SE_D4{self._4x}'] = 0.
+2177
+2178 elif self.standardization_method == 'indep_sessions':
+2179 for sample in self.anchors:
+2180 self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
+2181 self.samples[sample][f'SE_D{self._4x}'] = 0.
+2182 for sample in self.unknowns:
+2183 self.msg(f'Consolidating sample {sample}')
+2184 self.unknowns[sample][f'session_D{self._4x}'] = {}
+2185 session_avg = []
+2186 for session in self.sessions:
+2187 sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
+2188 if sdata:
+2189 self.msg(f'{sample} found in session {session}')
+2190 avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
+2191 avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
+2192 # !! TODO: sigma_s below does not account for temporal changes in standardization error
+2193 sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
+2194 sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
+2195 session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
+2196 self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
+2197 self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
+2198 weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
+2199 wsum = sum([weights[s] for s in weights])
+2200 for s in weights:
+2201 self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
+2202
+2203
+2204 def consolidate_sessions(self):
+2205 '''
+2206 Compute various statistics for each session.
+2207
+2208 + `Na`: Number of anchor analyses in the session
+2209 + `Nu`: Number of unknown analyses in the session
+2210 + `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
+2211 + `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
+2212 + `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
+2213 + `a`: scrambling factor
+2214 + `b`: compositional slope
+2215 + `c`: WG offset
+2216 + `SE_a`: Model stadard erorr of `a`
+2217 + `SE_b`: Model stadard erorr of `b`
+2218 + `SE_c`: Model stadard erorr of `c`
+2219 + `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
+2220 + `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
+2221 + `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
+2222 + `a2`: scrambling factor drift
+2223 + `b2`: compositional slope drift
+2224 + `c2`: WG offset drift
+2225 + `Np`: Number of standardization parameters to fit
+2226 + `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
+2227 + `d13Cwg_VPDB`: δ13C_VPDB of WG
+2228 + `d18Owg_VSMOW`: δ18O_VSMOW of WG
+2229 '''
+2230 for session in self.sessions:
+2231 if 'd13Cwg_VPDB' not in self.sessions[session]:
+2232 self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
+2233 if 'd18Owg_VSMOW' not in self.sessions[session]:
+2234 self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
+2235 self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
+2236 self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
+2237
+2238 self.msg(f'Computing repeatabilities for session {session}')
+2239 self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
+2240 self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
+2241 self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
+2242
+2243 if self.standardization_method == 'pooled':
+2244 for session in self.sessions:
+2245
+2246 self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
+2247 i = self.standardization.var_names.index(f'a_{pf(session)}')
+2248 self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
+2249
+2250 self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
+2251 i = self.standardization.var_names.index(f'b_{pf(session)}')
+2252 self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
+2253
+2254 self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
+2255 i = self.standardization.var_names.index(f'c_{pf(session)}')
+2256 self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
+2257
+2258 self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
+2259 if self.sessions[session]['scrambling_drift']:
+2260 i = self.standardization.var_names.index(f'a2_{pf(session)}')
+2261 self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
+2262 else:
+2263 self.sessions[session]['SE_a2'] = 0.
+2264
+2265 self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
+2266 if self.sessions[session]['slope_drift']:
+2267 i = self.standardization.var_names.index(f'b2_{pf(session)}')
+2268 self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
+2269 else:
+2270 self.sessions[session]['SE_b2'] = 0.
+2271
+2272 self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
+2273 if self.sessions[session]['wg_drift']:
+2274 i = self.standardization.var_names.index(f'c2_{pf(session)}')
+2275 self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
+2276 else:
+2277 self.sessions[session]['SE_c2'] = 0.
+2278
+2279 i = self.standardization.var_names.index(f'a_{pf(session)}')
+2280 j = self.standardization.var_names.index(f'b_{pf(session)}')
+2281 k = self.standardization.var_names.index(f'c_{pf(session)}')
+2282 CM = np.zeros((6,6))
+2283 CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
+2284 try:
+2285 i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
+2286 CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
+2287 CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
+2288 try:
+2289 j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
+2290 CM[3,4] = self.standardization.covar[i2,j2]
+2291 CM[4,3] = self.standardization.covar[j2,i2]
+2292 except ValueError:
+2293 pass
+2294 try:
+2295 k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
+2296 CM[3,5] = self.standardization.covar[i2,k2]
+2297 CM[5,3] = self.standardization.covar[k2,i2]
+2298 except ValueError:
+2299 pass
+2300 except ValueError:
+2301 pass
+2302 try:
+2303 j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
+2304 CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
+2305 CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
+2306 try:
+2307 k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
+2308 CM[4,5] = self.standardization.covar[j2,k2]
+2309 CM[5,4] = self.standardization.covar[k2,j2]
+2310 except ValueError:
+2311 pass
+2312 except ValueError:
+2313 pass
+2314 try:
+2315 k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
+2316 CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
+2317 CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
+2318 except ValueError:
+2319 pass
+2320
+2321 self.sessions[session]['CM'] = CM
+2322
+2323 elif self.standardization_method == 'indep_sessions':
+2324 pass # Not implemented yet
+2325
+2326
+2327 @make_verbal
+2328 def repeatabilities(self):
+2329 '''
+2330 Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
+2331 (for all samples, for anchors, and for unknowns).
+2332 '''
+2333 self.msg('Computing reproducibilities for all sessions')
+2334
+2335 self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
+2336 self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
+2337 self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
+2338 self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
+2339 self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
+2340
+2341
+2342 @make_verbal
+2343 def consolidate(self, tables = True, plots = True):
+2344 '''
+2345 Collect information about samples, sessions and repeatabilities.
+2346 '''
+2347 self.consolidate_samples()
+2348 self.consolidate_sessions()
+2349 self.repeatabilities()
+2350
+2351 if tables:
+2352 self.summary()
+2353 self.table_of_sessions()
+2354 self.table_of_analyses()
+2355 self.table_of_samples()
+2356
+2357 if plots:
+2358 self.plot_sessions()
+2359
+2360
+2361 @make_verbal
+2362 def rmswd(self,
+2363 samples = 'all samples',
+2364 sessions = 'all sessions',
+2365 ):
+2366 '''
+2367 Compute the χ2, root mean squared weighted deviation
+2368 (i.e. reduced χ2), and corresponding degrees of freedom of the
+2369 Δ4x values for samples in `samples` and sessions in `sessions`.
+2370
+2371 Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
+2372 '''
+2373 if samples == 'all samples':
+2374 mysamples = [k for k in self.samples]
+2375 elif samples == 'anchors':
+2376 mysamples = [k for k in self.anchors]
+2377 elif samples == 'unknowns':
+2378 mysamples = [k for k in self.unknowns]
+2379 else:
+2380 mysamples = samples
+2381
+2382 if sessions == 'all sessions':
+2383 sessions = [k for k in self.sessions]
+2384
+2385 chisq, Nf = 0, 0
+2386 for sample in mysamples :
+2387 G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
+2388 if len(G) > 1 :
+2389 X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
+2390 Nf += (len(G) - 1)
+2391 chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
+2392 r = (chisq / Nf)**.5 if Nf > 0 else 0
+2393 self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
+2394 return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
+2395
+2396
+2397 @make_verbal
+2398 def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
+2399 '''
+2400 Compute the repeatability of `[r[key] for r in self]`
+2401 '''
+2402 # NB: it's debatable whether rD47 should be computed
+2403 # with Nf = len(self)-len(self.samples) instead of
+2404 # Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
+2405
+2406 if samples == 'all samples':
+2407 mysamples = [k for k in self.samples]
+2408 elif samples == 'anchors':
+2409 mysamples = [k for k in self.anchors]
+2410 elif samples == 'unknowns':
+2411 mysamples = [k for k in self.unknowns]
+2412 else:
+2413 mysamples = samples
+2414
+2415 if sessions == 'all sessions':
+2416 sessions = [k for k in self.sessions]
+2417
+2418 if key in ['D47', 'D48']:
+2419 chisq, Nf = 0, 0
+2420 for sample in mysamples :
+2421 X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
+2422 if len(X) > 1 :
+2423 chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
+2424 if sample in self.unknowns:
+2425 Nf += len(X) - 1
+2426 else:
+2427 Nf += len(X)
+2428 if samples in ['anchors', 'all samples']:
+2429 Nf -= sum([self.sessions[s]['Np'] for s in sessions])
+2430 r = (chisq / Nf)**.5 if Nf > 0 else 0
+2431
+2432 else: # if key not in ['D47', 'D48']
+2433 chisq, Nf = 0, 0
+2434 for sample in mysamples :
+2435 X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
+2436 if len(X) > 1 :
+2437 Nf += len(X) - 1
+2438 chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
+2439 r = (chisq / Nf)**.5 if Nf > 0 else 0
+2440
+2441 self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
+2442 return r
+2443
+2444 def sample_average(self, samples, weights = 'equal', normalize = True):
+2445 '''
+2446 Weighted average Δ4x value of a group of samples, accounting for covariance.
+2447
+2448 Returns the weighed average Δ4x value and associated SE
+2449 of a group of samples. Weights are equal by default. If `normalize` is
+2450 true, `weights` will be rescaled so that their sum equals 1.
+2451
+2452 **Examples**
+2453
+2454 ```python
+2455 self.sample_average(['X','Y'], [1, 2])
+2456 ```
+2457
+2458 returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
+2459 where Δ4x(X) and Δ4x(Y) are the average Δ4x
+2460 values of samples X and Y, respectively.
+2461
+2462 ```python
+2463 self.sample_average(['X','Y'], [1, -1], normalize = False)
+2464 ```
+2465
+2466 returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
+2467 '''
+2468 if weights == 'equal':
+2469 weights = [1/len(samples)] * len(samples)
+2470
+2471 if normalize:
+2472 s = sum(weights)
+2473 if s:
+2474 weights = [w/s for w in weights]
+2475
+2476 try:
+2477# indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
+2478# C = self.standardization.covar[indices,:][:,indices]
+2479 C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
+2480 X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
+2481 return correlated_sum(X, C, weights)
+2482 except ValueError:
+2483 return (0., 0.)
+2484
+2485
+2486 def sample_D4x_covar(self, sample1, sample2 = None):
+2487 '''
+2488 Covariance between Δ4x values of samples
+2489
+2490 Returns the error covariance between the average Δ4x values of two
+2491 samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
+2492 returns the Δ4x variance for that sample.
+2493 '''
+2494 if sample2 is None:
+2495 sample2 = sample1
+2496 if self.standardization_method == 'pooled':
+2497 i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
+2498 j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
+2499 return self.standardization.covar[i, j]
+2500 elif self.standardization_method == 'indep_sessions':
+2501 if sample1 == sample2:
+2502 return self.samples[sample1][f'SE_D{self._4x}']**2
+2503 else:
+2504 c = 0
+2505 for session in self.sessions:
+2506 sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
+2507 sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
+2508 if sdata1 and sdata2:
+2509 a = self.sessions[session]['a']
+2510 # !! TODO: CM below does not account for temporal changes in standardization parameters
+2511 CM = self.sessions[session]['CM'][:3,:3]
+2512 avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
+2513 avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
+2514 avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
+2515 avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
+2516 c += (
+2517 self.unknowns[sample1][f'session_D{self._4x}'][session][2]
+2518 * self.unknowns[sample2][f'session_D{self._4x}'][session][2]
+2519 * np.array([[avg_D4x_1, avg_d4x_1, 1]])
+2520 @ CM
+2521 @ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
+2522 ) / a**2
+2523 return float(c)
+2524
+2525 def sample_D4x_correl(self, sample1, sample2 = None):
+2526 '''
+2527 Correlation between Δ4x errors of samples
+2528
+2529 Returns the error correlation between the average Δ4x values of two samples.
+2530 '''
+2531 if sample2 is None or sample2 == sample1:
+2532 return 1.
+2533 return (
+2534 self.sample_D4x_covar(sample1, sample2)
+2535 / self.unknowns[sample1][f'SE_D{self._4x}']
+2536 / self.unknowns[sample2][f'SE_D{self._4x}']
+2537 )
+2538
+2539 def plot_single_session(self,
+2540 session,
+2541 kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
+2542 kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
+2543 kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
+2544 kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
+2545 kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
+2546 xylimits = 'free', # | 'constant'
+2547 x_label = None,
+2548 y_label = None,
+2549 error_contour_interval = 'auto',
+2550 fig = 'new',
+2551 ):
+2552 '''
+2553 Generate plot for a single session
+2554 '''
+2555 if x_label is None:
+2556 x_label = f'δ$_{{{self._4x}}}$ (‰)'
+2557 if y_label is None:
+2558 y_label = f'Δ$_{{{self._4x}}}$ (‰)'
+2559
+2560 out = _SessionPlot()
+2561 anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
+2562 unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
+2563
+2564 if fig == 'new':
+2565 out.fig = ppl.figure(figsize = (6,6))
+2566 ppl.subplots_adjust(.1,.1,.9,.9)
+2567
+2568 out.anchor_analyses, = ppl.plot(
+2569 [r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
+2570 [r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
+2571 **kw_plot_anchors)
+2572 out.unknown_analyses, = ppl.plot(
+2573 [r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
+2574 [r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
+2575 **kw_plot_unknowns)
+2576 out.anchor_avg = ppl.plot(
+2577 np.array([ np.array([
+2578 np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
+2579 np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
+2580 ]) for sample in anchors]).T,
+2581 np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
+2582 **kw_plot_anchor_avg)
+2583 out.unknown_avg = ppl.plot(
+2584 np.array([ np.array([
+2585 np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
+2586 np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
+2587 ]) for sample in unknowns]).T,
+2588 np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
+2589 **kw_plot_unknown_avg)
+2590 if xylimits == 'constant':
+2591 x = [r[f'd{self._4x}'] for r in self]
+2592 y = [r[f'D{self._4x}'] for r in self]
+2593 x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
+2594 w, h = x2-x1, y2-y1
+2595 x1 -= w/20
+2596 x2 += w/20
+2597 y1 -= h/20
+2598 y2 += h/20
+2599 ppl.axis([x1, x2, y1, y2])
+2600 elif xylimits == 'free':
+2601 x1, x2, y1, y2 = ppl.axis()
+2602 else:
+2603 x1, x2, y1, y2 = ppl.axis(xylimits)
+2604
+2605 if error_contour_interval != 'none':
+2606 xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
+2607 XI,YI = np.meshgrid(xi, yi)
+2608 SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
+2609 if error_contour_interval == 'auto':
+2610 rng = np.max(SI) - np.min(SI)
+2611 if rng <= 0.01:
+2612 cinterval = 0.001
+2613 elif rng <= 0.03:
+2614 cinterval = 0.004
+2615 elif rng <= 0.1:
+2616 cinterval = 0.01
+2617 elif rng <= 0.3:
+2618 cinterval = 0.03
+2619 elif rng <= 1.:
+2620 cinterval = 0.1
+2621 else:
+2622 cinterval = 0.5
+2623 else:
+2624 cinterval = error_contour_interval
+2625
+2626 cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
+2627 out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
+2628 out.clabel = ppl.clabel(out.contour)
+2629
+2630 ppl.xlabel(x_label)
+2631 ppl.ylabel(y_label)
+2632 ppl.title(session, weight = 'bold')
+2633 ppl.grid(alpha = .2)
+2634 out.ax = ppl.gca()
+2635
+2636 return out
+2637
+2638 def plot_residuals(
+2639 self,
+2640 hist = False,
+2641 binwidth = 2/3,
+2642 dir = 'output',
+2643 filename = None,
+2644 highlight = [],
+2645 colors = None,
+2646 figsize = None,
+2647 ):
+2648 '''
+2649 Plot residuals of each analysis as a function of time (actually, as a function of
+2650 the order of analyses in the `D4xdata` object)
+2651
+2652 + `hist`: whether to add a histogram of residuals
+2653 + `histbins`: specify bin edges for the histogram
+2654 + `dir`: the directory in which to save the plot
+2655 + `highlight`: a list of samples to highlight
+2656 + `colors`: a dict of `{<sample>: <color>}` for all samples
+2657 + `figsize`: (width, height) of figure
+2658 '''
+2659 # Layout
+2660 fig = ppl.figure(figsize = (8,4) if figsize is None else figsize)
+2661 if hist:
+2662 ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72)
+2663 ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15)
+2664 else:
+2665 ppl.subplots_adjust(.08,.05,.78,.8)
+2666 ax1 = ppl.subplot(111)
+2667
+2668 # Colors
+2669 N = len(self.anchors)
+2670 if colors is None:
+2671 if len(highlight) > 0:
+2672 Nh = len(highlight)
+2673 if Nh == 1:
+2674 colors = {highlight[0]: (0,0,0)}
+2675 elif Nh == 3:
+2676 colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])}
+2677 elif Nh == 4:
+2678 colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
+2679 else:
+2680 colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)}
+2681 else:
+2682 if N == 3:
+2683 colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])}
+2684 elif N == 4:
+2685 colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])}
+2686 else:
+2687 colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)}
+2688
+2689 ppl.sca(ax1)
+2690
+2691 ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75)
+2692
+2693 session = self[0]['Session']
+2694 x1 = 0
+2695# ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self])
+2696 x_sessions = {}
+2697 one_or_more_singlets = False
+2698 one_or_more_multiplets = False
+2699 multiplets = set()
+2700 for k,r in enumerate(self):
+2701 if r['Session'] != session:
+2702 x2 = k-1
+2703 x_sessions[session] = (x1+x2)/2
+2704 ppl.axvline(k - 0.5, color = 'k', lw = .5)
+2705 session = r['Session']
+2706 x1 = k
+2707 singlet = len(self.samples[r['Sample']]['data']) == 1
+2708 if not singlet:
+2709 multiplets.add(r['Sample'])
+2710 if r['Sample'] in self.unknowns:
+2711 if singlet:
+2712 one_or_more_singlets = True
+2713 else:
+2714 one_or_more_multiplets = True
+2715 kw = dict(
+2716 marker = 'x' if singlet else '+',
+2717 ms = 4 if singlet else 5,
+2718 ls = 'None',
+2719 mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0),
+2720 mew = 1,
+2721 alpha = 0.2 if singlet else 1,
+2722 )
+2723 if highlight and r['Sample'] not in highlight:
+2724 kw['alpha'] = 0.2
+2725 ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw)
+2726 x2 = k
+2727 x_sessions[session] = (x1+x2)/2
+2728
+2729 ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1)
+2730 ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1)
+2731 if not hist:
+2732 ppl.text(len(self), self.repeatability['r_D47']*1000, f" SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center')
+2733 ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f" 95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center')
+2734
+2735 xmin, xmax, ymin, ymax = ppl.axis()
+2736 for s in x_sessions:
+2737 ppl.text(
+2738 x_sessions[s],
+2739 ymax +1,
+2740 s,
+2741 va = 'bottom',
+2742 **(
+2743 dict(ha = 'center')
+2744 if len(self.sessions[s]['data']) > (0.15 * len(self))
+2745 else dict(ha = 'left', rotation = 45)
+2746 )
+2747 )
+2748
+2749 if hist:
+2750 ppl.sca(ax2)
+2751
+2752 for s in colors:
+2753 kw['marker'] = '+'
+2754 kw['ms'] = 5
+2755 kw['mec'] = colors[s]
+2756 kw['label'] = s
+2757 kw['alpha'] = 1
+2758 ppl.plot([], [], **kw)
+2759
+2760 kw['mec'] = (0,0,0)
+2761
+2762 if one_or_more_singlets:
+2763 kw['marker'] = 'x'
+2764 kw['ms'] = 4
+2765 kw['alpha'] = .2
+2766 kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other'
+2767 ppl.plot([], [], **kw)
+2768
+2769 if one_or_more_multiplets:
+2770 kw['marker'] = '+'
+2771 kw['ms'] = 4
+2772 kw['alpha'] = 1
+2773 kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other'
+2774 ppl.plot([], [], **kw)
+2775
+2776 if hist:
+2777 leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9)
+2778 else:
+2779 leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5)
+2780 leg.set_zorder(-1000)
+2781
+2782 ppl.sca(ax1)
+2783
+2784 ppl.ylabel('Δ$_{47}$ residuals (ppm)')
+2785 ppl.xticks([])
+2786 ppl.axis([-1, len(self), None, None])
+2787
+2788 if hist:
+2789 ppl.sca(ax2)
+2790 X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets]
+2791 ppl.hist(
+2792 X,
+2793 orientation = 'horizontal',
+2794 histtype = 'stepfilled',
+2795 ec = [.4]*3,
+2796 fc = [.25]*3,
+2797 alpha = .25,
+2798 bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)),
+2799 )
+2800 ppl.axis([None, None, ymin, ymax])
+2801 ppl.text(0, 0,
+2802 f" SD = {self.repeatability['r_D47']*1000:.1f} ppm\n 95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm",
+2803 size = 8,
+2804 alpha = 1,
+2805 va = 'center',
+2806 ha = 'left',
+2807 )
+2808
+2809 ppl.xticks([])
+2810 ppl.yticks([])
+2811# ax2.spines['left'].set_visible(False)
+2812 ax2.spines['right'].set_visible(False)
+2813 ax2.spines['top'].set_visible(False)
+2814 ax2.spines['bottom'].set_visible(False)
+2815
+2816
+2817 if not os.path.exists(dir):
+2818 os.makedirs(dir)
+2819 if filename is None:
+2820 return fig
+2821 elif filename == '':
+2822 filename = f'D{self._4x}_residuals.pdf'
+2823 ppl.savefig(f'{dir}/{filename}')
+2824 ppl.close(fig)
+2825
+2826
+2827 def simulate(self, *args, **kwargs):
+2828 '''
+2829 Legacy function with warning message pointing to `virtual_data()`
+2830 '''
+2831 raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
+2832
+2833 def plot_distribution_of_analyses(
+2834 self,
+2835 dir = 'output',
+2836 filename = None,
+2837 vs_time = False,
+2838 figsize = (6,4),
+2839 subplots_adjust = (0.02, 0.13, 0.85, 0.8),
+2840 output = None,
+2841 ):
+2842 '''
+2843 Plot temporal distribution of all analyses in the data set.
+2844
+2845 **Parameters**
+2846
+2847 + `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
+2848 '''
+2849
+2850 asamples = [s for s in self.anchors]
+2851 usamples = [s for s in self.unknowns]
+2852 if output is None or output == 'fig':
+2853 fig = ppl.figure(figsize = figsize)
+2854 ppl.subplots_adjust(*subplots_adjust)
+2855 Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
+2856 Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
+2857 Xmax += (Xmax-Xmin)/40
+2858 Xmin -= (Xmax-Xmin)/41
+2859 for k, s in enumerate(asamples + usamples):
+2860 if vs_time:
+2861 X = [r['TimeTag'] for r in self if r['Sample'] == s]
+2862 else:
+2863 X = [x for x,r in enumerate(self) if r['Sample'] == s]
+2864 Y = [-k for x in X]
+2865 ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
+2866 ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
+2867 ppl.text(Xmax, -k, f' {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
+2868 ppl.axis([Xmin, Xmax, -k-1, 1])
+2869 ppl.xlabel('\ntime')
+2870 ppl.gca().annotate('',
+2871 xy = (0.6, -0.02),
+2872 xycoords = 'axes fraction',
+2873 xytext = (.4, -0.02),
+2874 arrowprops = dict(arrowstyle = "->", color = 'k'),
+2875 )
+2876
+2877
+2878 x2 = -1
+2879 for session in self.sessions:
+2880 x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
+2881 if vs_time:
+2882 ppl.axvline(x1, color = 'k', lw = .75)
+2883 if x2 > -1:
+2884 if not vs_time:
+2885 ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
+2886 x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
+2887# from xlrd import xldate_as_datetime
+2888# print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
+2889 if vs_time:
+2890 ppl.axvline(x2, color = 'k', lw = .75)
+2891 ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
+2892 ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
+2893
+2894 ppl.xticks([])
+2895 ppl.yticks([])
+2896
+2897 if output is None:
+2898 if not os.path.exists(dir):
+2899 os.makedirs(dir)
+2900 if filename == None:
+2901 filename = f'D{self._4x}_distribution_of_analyses.pdf'
+2902 ppl.savefig(f'{dir}/{filename}')
+2903 ppl.close(fig)
+2904 elif output == 'ax':
+2905 return ppl.gca()
+2906 elif output == 'fig':
+2907 return fig
+2908
+2909
+2910class D47data(D4xdata):
+2911 '''
+2912 Store and process data for a large set of Δ47 analyses,
+2913 usually comprising more than one analytical session.
+2914 '''
+2915
+2916 Nominal_D4x = {
+2917 'ETH-1': 0.2052,
+2918 'ETH-2': 0.2085,
+2919 'ETH-3': 0.6132,
+2920 'ETH-4': 0.4511,
+2921 'IAEA-C1': 0.3018,
+2922 'IAEA-C2': 0.6409,
+2923 'MERCK': 0.5135,
+2924 } # I-CDES (Bernasconi et al., 2021)
+2925 '''
+2926 Nominal Δ47 values assigned to the Δ47 anchor samples, used by
+2927 `D47data.standardize()` to normalize unknown samples to an absolute Δ47
+2928 reference frame.
+2929
+2930 By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
+2931 ```py
+2932 {
+2933 'ETH-1' : 0.2052,
+2934 'ETH-2' : 0.2085,
+2935 'ETH-3' : 0.6132,
+2936 'ETH-4' : 0.4511,
+2937 'IAEA-C1' : 0.3018,
+2938 'IAEA-C2' : 0.6409,
+2939 'MERCK' : 0.5135,
+2940 }
+2941 ```
+2942 '''
+2943
+2944
+2945 @property
+2946 def Nominal_D47(self):
+2947 return self.Nominal_D4x
+2948
+2949
+2950 @Nominal_D47.setter
+2951 def Nominal_D47(self, new):
+2952 self.Nominal_D4x = dict(**new)
+2953 self.refresh()
+2954
+2955
+2956 def __init__(self, l = [], **kwargs):
+2957 '''
+2958 **Parameters:** same as `D4xdata.__init__()`
+2959 '''
+2960 D4xdata.__init__(self, l = l, mass = '47', **kwargs)
+2961
+2962
+2963 def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
+2964 '''
+2965 Find all samples for which `Teq` is specified, compute equilibrium Δ47
+2966 value for that temperature, and add treat these samples as additional anchors.
+2967
+2968 **Parameters**
+2969
+2970 + `fCo2eqD47`: Which CO2 equilibrium law to use
+2971 (`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
+2972 `wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
+2973 + `priority`: if `replace`: forget old anchors and only use the new ones;
+2974 if `new`: keep pre-existing anchors but update them in case of conflict
+2975 between old and new Δ47 values;
+2976 if `old`: keep pre-existing anchors but preserve their original Δ47
+2977 values in case of conflict.
+2978 '''
+2979 f = {
+2980 'petersen': fCO2eqD47_Petersen,
+2981 'wang': fCO2eqD47_Wang,
+2982 }[fCo2eqD47]
+2983 foo = {}
+2984 for r in self:
+2985 if 'Teq' in r:
+2986 if r['Sample'] in foo:
+2987 assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
+2988 else:
+2989 foo[r['Sample']] = f(r['Teq'])
+2990 else:
+2991 assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
+2992
+2993 if priority == 'replace':
+2994 self.Nominal_D47 = {}
+2995 for s in foo:
+2996 if priority != 'old' or s not in self.Nominal_D47:
+2997 self.Nominal_D47[s] = foo[s]
+2998
+2999
+3000
+3001
+3002class D48data(D4xdata):
+3003 '''
+3004 Store and process data for a large set of Δ48 analyses,
+3005 usually comprising more than one analytical session.
+3006 '''
+3007
+3008 Nominal_D4x = {
+3009 'ETH-1': 0.138,
+3010 'ETH-2': 0.138,
+3011 'ETH-3': 0.270,
+3012 'ETH-4': 0.223,
+3013 'GU-1': -0.419,
+3014 } # (Fiebig et al., 2019, 2021)
+3015 '''
+3016 Nominal Δ48 values assigned to the Δ48 anchor samples, used by
+3017 `D48data.standardize()` to normalize unknown samples to an absolute Δ48
+3018 reference frame.
+3019
+3020 By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
+3021 Fiebig et al. (in press)):
+3022
+3023 ```py
+3024 {
+3025 'ETH-1' : 0.138,
+3026 'ETH-2' : 0.138,
+3027 'ETH-3' : 0.270,
+3028 'ETH-4' : 0.223,
+3029 'GU-1' : -0.419,
+3030 }
+3031 ```
+3032 '''
+3033
+3034
+3035 @property
+3036 def Nominal_D48(self):
+3037 return self.Nominal_D4x
+3038
+3039
+3040 @Nominal_D48.setter
+3041 def Nominal_D48(self, new):
+3042 self.Nominal_D4x = dict(**new)
+3043 self.refresh()
+3044
+3045
+3046 def __init__(self, l = [], **kwargs):
+3047 '''
+3048 **Parameters:** same as `D4xdata.__init__()`
+3049 '''
+3050 D4xdata.__init__(self, l = l, mass = '48', **kwargs)
+3051
+3052
+3053class _SessionPlot():
+3054 '''
+3055 Simple placeholder class
+3056 '''
+3057 def __init__(self):
+3058 pass
+
+
View Source
-''' -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](https://doi.org/10.1029/2020GC009592)). - -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. - -.. include:: ../docs/tutorial.md -.. include:: ../docs/howto.md -''' - -__docformat__ = "restructuredtext" -__author__ = 'Mathieu Daëron' -__contact__ = 'daeron@lsce.ipsl.fr' -__copyright__ = 'Copyright (c) 2022 Mathieu Daëron' -__license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause' -__date__ = '2022-02-27' -__version__ = '2.0.3' - -import os -import numpy as np -from statistics import stdev -from scipy.stats import t as tstudent -from scipy.stats import levene -from scipy.interpolate import interp1d -from numpy import linalg -from lmfit import Minimizer, Parameters, report_fit -from matplotlib import pyplot as ppl -from datetime import datetime as dt -from functools import wraps -from colorsys import hls_to_rgb -from matplotlib import rcParams - -rcParams['font.family'] = 'sans-serif' -rcParams['font.sans-serif'] = 'Helvetica' -rcParams['font.size'] = 10 -rcParams['mathtext.fontset'] = 'custom' -rcParams['mathtext.rm'] = 'sans' -rcParams['mathtext.bf'] = 'sans:bold' -rcParams['mathtext.it'] = 'sans:italic' -rcParams['mathtext.cal'] = 'sans:italic' -rcParams['mathtext.default'] = 'rm' -rcParams['xtick.major.size'] = 4 -rcParams['xtick.major.width'] = 1 -rcParams['ytick.major.size'] = 4 -rcParams['ytick.major.width'] = 1 -rcParams['axes.grid'] = False -rcParams['axes.linewidth'] = 1 -rcParams['grid.linewidth'] = .75 -rcParams['grid.linestyle'] = '-' -rcParams['grid.alpha'] = .15 -rcParams['savefig.dpi'] = 150 - -Petersen_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]]) -_fCO2eqD47_Petersen = interp1d(Petersen_etal_CO2eqD47[:,0], Petersen_etal_CO2eqD47[:,1]) -def fCO2eqD47_Petersen(T): - ''' - CO2 equilibrium Δ47 value as a function of T (in degrees C) - according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127). - - ''' - return float(_fCO2eqD47_Petersen(T)) - - -Wang_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]]) -_fCO2eqD47_Wang = interp1d(Wang_etal_CO2eqD47[:,0] - 0.15, Wang_etal_CO2eqD47[:,1]) -def fCO2eqD47_Wang(T): - ''' - CO2 equilibrium Δ47 value as a function of `T` (in degrees C) - according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039) - (supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)). - ''' - return float(_fCO2eqD47_Wang(T)) - - -def correlated_sum(X, C, w = None): - ''' - 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`. - ''' - if w is None: - w = [1 for x in X] - return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5 - - -def make_csv(x, hsep = ',', vsep = '\n'): - ''' - 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** - - ```py - print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']])) - ``` - - outputs: - - ```py - a,b,c - d,e,f - ``` - ''' - return vsep.join([hsep.join(l) for l in x]) - - -def pf(txt): - ''' - Modify string `txt` to follow `lmfit.Parameter()` naming rules. - ''' - return txt.replace('-','_').replace('.','_').replace(' ','_') - - -def smart_type(x): - ''' - 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. - ''' - try: - y = float(x) - except ValueError: - return x - if '.' not in x: - return int(y) - return y - - -def pretty_table(x, header = 1, hsep = ' ', vsep = '–', align = '<'): - ''' - 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** - - ```py - 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 - -- ------ --- - ``` - - ''' - txt = [] - widths = [np.max([len(e) for e in c]) for c in zip(*x)] - - if len(widths) > len(align): - align += '>' * (len(widths)-len(align)) - sepline = hsep.join([vsep*w for w in widths]) - txt += [sepline] - for k,l in enumerate(x): - if k and k == header: - txt += [sepline] - txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])] - txt += [sepline] - txt += [''] - return '\n'.join(txt) - - -def transpose_table(x): - ''' - Transpose a list if lists - - **Parameters** - - + `x`: a list of lists - - **Example** - - ```py - x = [[1, 2], [3, 4]] - print(transpose_table(x)) # yields: [[1, 3], [2, 4]] - ``` - ''' - return [[e for e in c] for c in zip(*x)] - - -def w_avg(X, sX) : - ''' - 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()`: - - ```python - foo = [(0, 1), (1, 0.5), (2, 0.5)] - print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333) - ``` - ''' - X = [ x for x in X ] - sX = [ sx for sx in sX ] - W = [ sx**-2 for sx in sX ] - W = [ w/sum(W) for w in W ] - Xavg = sum([ w*x for w,x in zip(W,X) ]) - sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5 - return Xavg, sXavg - - -def read_csv(filename, sep = ''): - ''' - 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 `\t`, - whichever appers most often in the contents of `filename`. - ''' - with open(filename) as fid: - txt = fid.read() - - if sep == '': - sep = sorted(',;\t', key = lambda x: - txt.count(x))[0] - txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()] - return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]] - - -def simulate_single_analysis( - sample = 'MYSAMPLE', - d13Cwg_VPDB = -4., d18Owg_VSMOW = 26., - d13C_VPDB = None, d18O_VPDB = None, - D47 = None, D48 = None, D49 = 0., D17O = 0., - a47 = 1., b47 = 0., c47 = -0.9, - a48 = 1., b48 = 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, - ): - ''' - 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']`. - ''' - - if Nominal_d13C_VPDB is None: - Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB - - if Nominal_d18O_VPDB is None: - Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB - - if ALPHA_18O_ACID_REACTION is None: - ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION - - if R13_VPDB is None: - R13_VPDB = D4xdata().R13_VPDB - - if R17_VSMOW is None: - R17_VSMOW = D4xdata().R17_VSMOW - - if R18_VSMOW is None: - R18_VSMOW = D4xdata().R18_VSMOW - - if LAMBDA_17 is None: - LAMBDA_17 = D4xdata().LAMBDA_17 - - if R18_VPDB is None: - R18_VPDB = D4xdata().R18_VPDB - - R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17 - - if Nominal_D47 is None: - Nominal_D47 = D47data().Nominal_D47 - - if Nominal_D48 is None: - Nominal_D48 = D48data().Nominal_D48 - - if d13C_VPDB is None: - if sample in Nominal_d13C_VPDB: - d13C_VPDB = Nominal_d13C_VPDB[sample] - else: - raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.") - - if d18O_VPDB is None: - if sample in Nominal_d18O_VPDB: - d18O_VPDB = Nominal_d18O_VPDB[sample] - else: - raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.") - - if D47 is None: - if sample in Nominal_D47: - D47 = Nominal_D47[sample] - else: - raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.") - - if D48 is None: - if sample in Nominal_D48: - D48 = Nominal_D48[sample] - else: - raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.") - - X = D4xdata() - X.R13_VPDB = R13_VPDB - X.R17_VSMOW = R17_VSMOW - X.R18_VSMOW = R18_VSMOW - X.LAMBDA_17 = LAMBDA_17 - X.R18_VPDB = R18_VPDB - X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17 - - R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios( - R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000), - R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000), - ) - R45, R46, R47, R48, R49 = X.compute_isobar_ratios( - R13 = R13_VPDB * (1 + d13C_VPDB/1000), - R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION, - D17O=D17O, D47=D47, D48=D48, D49=D49, - ) - R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios( - R13 = R13_VPDB * (1 + d13C_VPDB/1000), - R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION, - D17O=D17O, - ) - - d45 = 1000 * (R45/R45wg - 1) - d46 = 1000 * (R46/R46wg - 1) - d47 = 1000 * (R47/R47wg - 1) - d48 = 1000 * (R48/R48wg - 1) - d49 = 1000 * (R49/R49wg - 1) - - for k in range(3): # dumb iteration to adjust for small changes in d47 - R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch - R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch - d47 = 1000 * (R47raw/R47wg - 1) - d48 = 1000 * (R48raw/R48wg - 1) - - return dict( - Sample = sample, - D17O = D17O, - d13Cwg_VPDB = d13Cwg_VPDB, - d18Owg_VSMOW = d18Owg_VSMOW, - d45 = d45, - d46 = d46, - d47 = d47, - d48 = d48, - d49 = d49, - ) - - -def virtual_data( - samples = [], - a47 = 1., b47 = 0., c47 = -0.9, - a48 = 1., b48 = 0., c48 = -0.45, - rD47 = 0.015, rD48 = 0.045, - d13Cwg_VPDB = None, d18Owg_VSMOW = None, - session = None, - Nominal_D47 = None, Nominal_D48 = None, - Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None, - ALPHA_18O_ACID_REACTION = None, - R13_VPDB = None, - R17_VSMOW = None, - R18_VSMOW = None, - LAMBDA_17 = None, - R18_VPDB = None, - seed = 0, - ): - ''' - Return list with simulated analyses from a single session. - - **Parameters** - - + `samples`: a list of entries; each entry is a dictionary with the following fields: - * `Sample`: the name of the sample - * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample - * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample - * `N`: how many analyses to generate for this sample - + `a47`: scrambling factor for Δ47 - + `b47`: compositional nonlinearity for Δ47 - + `c47`: working gas offset for Δ47 - + `a48`: scrambling factor for Δ48 - + `b48`: compositional nonlinearity for Δ48 - + `c48`: working gas offset for Δ48 - + `rD47`: analytical repeatability of Δ47 - + `rD48`: analytical repeatability of Δ48 - + `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas - (by default equal to the `simulate_single_analysis` default values) - + `session`: name of the session (no name by default) - + `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values - if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults) - + `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and - δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified - (by default equal to the `simulate_single_analysis` defaults) - + `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor - (by default equal to the `simulate_single_analysis` defaults) - + `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17 - correction parameters (by default equal to the `simulate_single_analysis` default) - + `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations - - - Here is an example of using this method to generate an arbitrary combination of - anchors and unknowns for a bunch of sessions: - - ```py - 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 - ––– –––––––––– –––––– ––––––––––– –––––––––––– ––––––––– ––––––––– –––––––––– –––––––––– –––––––––– –––––––––– –––––––––– ––––––––– ––––––––– ––––––––– –––––––– - ``` - ''' - - kwargs = locals().copy() - - from numpy import random as nprandom - if seed: - rng = nprandom.default_rng(seed) - else: - rng = nprandom.default_rng() - - N = sum([s['N'] for s in samples]) - errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors - errors47 *= rD47 / stdev(errors47) # scale errors to rD47 - errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors - errors48 *= rD48 / stdev(errors48) # scale errors to rD48 - - k = 0 - out = [] - for s in samples: - kw = {} - kw['sample'] = s['Sample'] - kw = { - **kw, - **{var: kwargs[var] - for var in [ - 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION', - 'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB', - 'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB', - 'a47', 'b47', 'c47', 'a48', 'b48', 'c48', - ] - if kwargs[var] is not None}, - **{var: s[var] - for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O'] - if var in s}, - } - - sN = s['N'] - while sN: - out.append(simulate_single_analysis(**kw)) - out[-1]['d47'] += errors47[k] * a47 - out[-1]['d48'] += errors48[k] * a48 - sN -= 1 - k += 1 - - if session is not None: - for r in out: - r['Session'] = session - return out - -def table_of_samples( - data47 = None, - data48 = None, - dir = 'output', - filename = None, - save_to_file = True, - print_out = True, - output = None, - ): - ''' - 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']]`) - ''' - if data47 is None: - if data48 is None: - raise TypeError("Arguments must include at least one D47data() or D48data() instance.") - else: - return data48.table_of_samples( - dir = dir, - filename = filename, - save_to_file = save_to_file, - print_out = print_out, - output = output - ) - else: - if data48 is None: - return data47.table_of_samples( - dir = dir, - filename = filename, - save_to_file = save_to_file, - print_out = print_out, - output = output - ) - else: - out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw') - out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw') - out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:]) - - if save_to_file: - if not os.path.exists(dir): - os.makedirs(dir) - if filename is None: - filename = f'D47D48_samples.csv' - with open(f'{dir}/{filename}', 'w') as fid: - fid.write(make_csv(out)) - if print_out: - print('\n'+pretty_table(out)) - if output == 'raw': - return out - elif output == 'pretty': - return pretty_table(out) - - -def table_of_sessions( - data47 = None, - data48 = None, - dir = 'output', - filename = None, - save_to_file = True, - print_out = True, - output = None, - ): - ''' - 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']]`) - ''' - if data47 is None: - if data48 is None: - raise TypeError("Arguments must include at least one D47data() or D48data() instance.") - else: - return data48.table_of_sessions( - dir = dir, - filename = filename, - save_to_file = save_to_file, - print_out = print_out, - output = output - ) - else: - if data48 is None: - return data47.table_of_sessions( - dir = dir, - filename = filename, - save_to_file = save_to_file, - print_out = print_out, - output = output - ) - else: - out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw') - out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw') - for k,x in enumerate(out47[0]): - if k>7: - out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47') - out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48') - out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:]) - - if save_to_file: - if not os.path.exists(dir): - os.makedirs(dir) - if filename is None: - filename = f'D47D48_sessions.csv' - with open(f'{dir}/{filename}', 'w') as fid: - fid.write(make_csv(out)) - if print_out: - print('\n'+pretty_table(out)) - if output == 'raw': - return out - elif output == 'pretty': - return pretty_table(out) - - -def table_of_analyses( - data47 = None, - data48 = None, - dir = 'output', - filename = None, - save_to_file = True, - print_out = True, - output = None, - ): - ''' - 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']]`) - ''' - if data47 is None: - if data48 is None: - raise TypeError("Arguments must include at least one D47data() or D48data() instance.") - else: - return data48.table_of_analyses( - dir = dir, - filename = filename, - save_to_file = save_to_file, - print_out = print_out, - output = output - ) - else: - if data48 is None: - return data47.table_of_analyses( - dir = dir, - filename = filename, - save_to_file = save_to_file, - print_out = print_out, - output = output - ) - else: - out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw') - out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw') - - if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical - out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:]) - else: - out47[0][1] = 'Session_47' - out48[0][1] = 'Session_48' - out47 = transpose_table(out47) - out48 = transpose_table(out48) - out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:]) - - if save_to_file: - if not os.path.exists(dir): - os.makedirs(dir) - if filename is None: - filename = f'D47D48_sessions.csv' - with open(f'{dir}/{filename}', 'w') as fid: - fid.write(make_csv(out)) - if print_out: - print('\n'+pretty_table(out)) - if output == 'raw': - return out - elif output == 'pretty': - return pretty_table(out) - - -class D4xdata(list): - ''' - Store and process data for a large set of Δ47 and/or Δ48 - analyses, usually comprising more than one analytical session. - ''' - - ### 17O CORRECTION PARAMETERS - R13_VPDB = 0.01118 # (Chang & Li, 1990) - ''' - Absolute (13C/12C) ratio of VPDB. - By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm)) - ''' - - R18_VSMOW = 0.0020052 # (Baertschi, 1976) - ''' - Absolute (18O/16C) ratio of VSMOW. - By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1)) - ''' - - LAMBDA_17 = 0.528 # (Barkan & Luz, 2005) - ''' - Mass-dependent exponent for triple oxygen isotopes. - By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250)) - ''' - - R17_VSMOW = 0.00038475 # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB) - ''' - Absolute (17O/16C) ratio of VSMOW. - By default equal to 0.00038475 - ([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011), - rescaled to `R13_VPDB`) - ''' - - R18_VPDB = R18_VSMOW * 1.03092 - ''' - Absolute (18O/16C) ratio of VPDB. - By definition equal to `R18_VSMOW * 1.03092`. - ''' - - R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17 - ''' - 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](https://en.wikipedia.org/wiki/Levene%27s_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 = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6) # (Kim et al., 2007, calcite) - ''' - 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](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)). - ''' - - Nominal_d13C_VPDB = { - 'ETH-1': 2.02, - 'ETH-2': -10.17, - 'ETH-3': 1.71, - } # (Bernasconi et al., 2018) - ''' - Nominal δ13C_VPDB 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)](https://doi.org/10.1029/2017GC007385). - ''' - - Nominal_d18O_VPDB = { - 'ETH-1': -2.19, - 'ETH-2': -18.69, - 'ETH-3': -1.78, - } # (Bernasconi et al., 2018) - ''' - Nominal δ18O_VPDB 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)](https://doi.org/10.1029/2017GC007385). - ''' - - 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 δ13C_VPDB 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 δ13C_VPDB - 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 δ18O_VPDB 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 δ18O_VPDB - values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` - is defined). - ''' - - def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False): - ''' - **Parameters** - - + `l`: a list of dictionaries, with each dictionary including at least the keys - `Sample`, `d45`, `d46`, and `d47` or `d48`. - + `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`. - ''' - self._4x = mass - self.verbose = verbose - self.prefix = 'D4xdata' - self.logfile = logfile - list.__init__(self, l) - self.Nf = None - self.repeatability = {} - self.refresh(session = session) - - - def make_verbal(oldfun): - ''' - Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`. - ''' - @wraps(oldfun) - def newfun(*args, verbose = '', **kwargs): - myself = args[0] - oldprefix = myself.prefix - myself.prefix = oldfun.__name__ - if verbose != '': - oldverbose = myself.verbose - myself.verbose = verbose - out = oldfun(*args, **kwargs) - myself.prefix = oldprefix - if verbose != '': - myself.verbose = oldverbose - return out - return newfun - - - def msg(self, txt): - ''' - Log a message to `self.logfile`, and print it out if `verbose = True` - ''' - self.log(txt) - if self.verbose: - print(f'{f"[{self.prefix}]":<16} {txt}') - - - def vmsg(self, txt): - ''' - Log a message to `self.logfile` and print it out - ''' - self.log(txt) - print(txt) - - - def log(self, *txts): - ''' - Log a message to `self.logfile` - ''' - if self.logfile: - with open(self.logfile, 'a') as fid: - for txt in txts: - fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}') - - - def refresh(self, session = 'mySession'): - ''' - Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`. - ''' - self.fill_in_missing_info(session = session) - self.refresh_sessions() - self.refresh_samples() - - - def refresh_sessions(self): - ''' - Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift` - to `False` for all sessions. - ''' - self.sessions = { - s: {'data': [r for r in self if r['Session'] == s]} - for s in sorted({r['Session'] for r in self}) - } - for s in self.sessions: - self.sessions[s]['scrambling_drift'] = False - self.sessions[s]['slope_drift'] = False - self.sessions[s]['wg_drift'] = False - self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD - self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD - - - def refresh_samples(self): - ''' - Define `self.samples`, `self.anchors`, and `self.unknowns`. - ''' - self.samples = { - s: {'data': [r for r in self if r['Sample'] == s]} - for s in sorted({r['Sample'] for r in self}) - } - self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x} - self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x} - - - def read(self, filename, sep = '', 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 - ''' - with open(filename) as fid: - self.input(fid.read(), sep = sep, session = session) - - - def input(self, txt, sep = '', session = ''): - ''' - 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 `\t`, - whichever appers most often in `txt`. - + `session`: set `Session` field to this string for all analyses - ''' - if sep == '': - sep = sorted(',;\t', key = lambda x: - txt.count(x))[0] - txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()] - 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:]] - - if session != '': - for r in data: - r['Session'] = session - - self += data - self.refresh() - - - @make_verbal - def wg(self, samples = None, a18_acid = None): - ''' - 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`. - ''' - - self.msg('Computing WG composition:') - - if a18_acid is None: - a18_acid = self.ALPHA_18O_ACID_REACTION - if samples is None: - samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB] - - assert a18_acid, f'Acid fractionation factor should not be zero.' - - samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB] - R45R46_standards = {} - for sample in samples: - d13C_vpdb = self.Nominal_d13C_VPDB[sample] - d18O_vpdb = self.Nominal_d18O_VPDB[sample] - R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000) - R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17 - R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid - - C12_s = 1 / (1 + R13_s) - C13_s = R13_s / (1 + R13_s) - C16_s = 1 / (1 + R17_s + R18_s) - C17_s = R17_s / (1 + R17_s + R18_s) - C18_s = R18_s / (1 + R17_s + R18_s) - - C626_s = C12_s * C16_s ** 2 - C627_s = 2 * C12_s * C16_s * C17_s - C628_s = 2 * C12_s * C16_s * C18_s - C636_s = C13_s * C16_s ** 2 - C637_s = 2 * C13_s * C16_s * C17_s - C727_s = C12_s * C17_s ** 2 - - R45_s = (C627_s + C636_s) / C626_s - R46_s = (C628_s + C637_s + C727_s) / C626_s - R45R46_standards[sample] = (R45_s, R46_s) - - for s in self.sessions: - db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples] - assert db, f'No sample from {samples} found in session "{s}".' -# dbsamples = sorted({r['Sample'] for r in db}) - - X = [r['d45'] for r in db] - Y = [R45R46_standards[r['Sample']][0] for r in db] - x1, x2 = np.min(X), np.max(X) - - if x1 < x2: - wgcoord = x1/(x1-x2) - else: - wgcoord = 999 - - if wgcoord < -.5 or wgcoord > 1.5: - # unreasonable to extrapolate to d45 = 0 - R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)]) - else : - # d45 = 0 is reasonably well bracketed - R45_wg = np.polyfit(X, Y, 1)[1] - - X = [r['d46'] for r in db] - Y = [R45R46_standards[r['Sample']][1] for r in db] - x1, x2 = np.min(X), np.max(X) - - if x1 < x2: - wgcoord = x1/(x1-x2) - else: - wgcoord = 999 - - if wgcoord < -.5 or wgcoord > 1.5: - # unreasonable to extrapolate to d46 = 0 - R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)]) - else : - # d46 = 0 is reasonably well bracketed - R46_wg = np.polyfit(X, Y, 1)[1] - - d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg) - - self.msg(f'Session {s} WG: δ13C_VPDB = {d13Cwg_VPDB:.3f} δ18O_VSMOW = {d18Owg_VSMOW:.3f}') - - self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB - self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW - for r in self.sessions[s]['data']: - r['d13Cwg_VPDB'] = d13Cwg_VPDB - r['d18Owg_VSMOW'] = d18Owg_VSMOW - - - def compute_bulk_delta(self, R45, R46, D17O = 0): - ''' - Compute δ13C_VPDB and δ18O_VSMOW, - by solving the generalized form of equation (17) from - [Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05), - assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and - solving the corresponding second-order Taylor polynomial. - (Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014)) - ''' - - K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17 - - A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17) - B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17 - C = 2 * self.R18_VSMOW - D = -R46 - - aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2 - bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C - cc = A + B + C + D - - d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa) - - R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW - R17 = K * R18 ** self.LAMBDA_17 - R13 = R45 - 2 * R17 - - d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1) - - return d13C_VPDB, d18O_VSMOW - - - @make_verbal - def crunch(self, verbose = ''): - ''' - Compute bulk composition and raw clumped isotope anomalies for all analyses. - ''' - for r in self: - self.compute_bulk_and_clumping_deltas(r) - self.standardize_d13C() - self.standardize_d18O() - self.msg(f"Crunched {len(self)} analyses.") - - - def fill_in_missing_info(self, session = 'mySession'): - ''' - Fill in optional fields with default values - ''' - for i,r in enumerate(self): - if 'D17O' not in r: - r['D17O'] = 0. - if 'UID' not in r: - r['UID'] = f'{i+1}' - if 'Session' not in r: - r['Session'] = session - for k in ['d47', 'd48', 'd49']: - if k not in r: - r[k] = np.nan - - - def standardize_d13C(self): - ''' - 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. - ''' - for s in self.sessions: - if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']: - 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] - X,Y = zip(*XY) - if self.sessions[s]['d13C_standardization_method'] == '1pt': - offset = np.mean(Y) - np.mean(X) - for r in self.sessions[s]['data']: - r['d13C_VPDB'] += offset - elif self.sessions[s]['d13C_standardization_method'] == '2pt': - a,b = np.polyfit(X,Y,1) - for r in self.sessions[s]['data']: - r['d13C_VPDB'] = a * r['d13C_VPDB'] + b - - def standardize_d18O(self): - ''' - 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. - ''' - for s in self.sessions: - if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']: - 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] - X,Y = zip(*XY) - Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y] - if self.sessions[s]['d18O_standardization_method'] == '1pt': - offset = np.mean(Y) - np.mean(X) - for r in self.sessions[s]['data']: - r['d18O_VSMOW'] += offset - elif self.sessions[s]['d18O_standardization_method'] == '2pt': - a,b = np.polyfit(X,Y,1) - for r in self.sessions[s]['data']: - r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b - - - def compute_bulk_and_clumping_deltas(self, r): - ''' - Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`. - ''' - - # Compute working gas R13, R18, and isobar ratios - R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000) - R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000) - R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg) - - # Compute analyte isobar ratios - R45 = (1 + r['d45'] / 1000) * R45_wg - R46 = (1 + r['d46'] / 1000) * R46_wg - R47 = (1 + r['d47'] / 1000) * R47_wg - R48 = (1 + r['d48'] / 1000) * R48_wg - R49 = (1 + r['d49'] / 1000) * R49_wg - - r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O']) - R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB - R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW - - # Compute stochastic isobar ratios of the analyte - R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios( - R13, R18, D17O = r['D17O'] - ) - - # Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1, - # and raise a warning if the corresponding anomalies exceed 0.02 ppm. - if (R45 / R45stoch - 1) > 5e-8: - self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm') - if (R46 / R46stoch - 1) > 5e-8: - self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm') - - # Compute raw clumped isotope anomalies - r['D47raw'] = 1000 * (R47 / R47stoch - 1) - r['D48raw'] = 1000 * (R48 / R48stoch - 1) - r['D49raw'] = 1000 * (R49 / R49stoch - 1) - - - def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0): - ''' - 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. - ''' - - # Compute R17 - R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17 - - # Compute isotope concentrations - C12 = (1 + R13) ** -1 - C13 = C12 * R13 - C16 = (1 + R17 + R18) ** -1 - C17 = C16 * R17 - C18 = C16 * R18 - - # Compute stochastic isotopologue concentrations - C626 = C16 * C12 * C16 - C627 = C16 * C12 * C17 * 2 - C628 = C16 * C12 * C18 * 2 - C636 = C16 * C13 * C16 - C637 = C16 * C13 * C17 * 2 - C638 = C16 * C13 * C18 * 2 - C727 = C17 * C12 * C17 - C728 = C17 * C12 * C18 * 2 - C737 = C17 * C13 * C17 - C738 = C17 * C13 * C18 * 2 - C828 = C18 * C12 * C18 - C838 = C18 * C13 * C18 - - # Compute stochastic isobar ratios - R45 = (C636 + C627) / C626 - R46 = (C628 + C637 + C727) / C626 - R47 = (C638 + C728 + C737) / C626 - R48 = (C738 + C828) / C626 - R49 = C838 / C626 - - # Account for stochastic anomalies - R47 *= 1 + D47 / 1000 - R48 *= 1 + D48 / 1000 - R49 *= 1 + D49 / 1000 - - # Return isobar ratios - return R45, R46, R47, R48, R49 - - - def split_samples(self, samples_to_split = 'all', grouping = 'by_session'): - ''' - 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` - ''' - if samples_to_split == 'all': - samples_to_split = [s for s in self.unknowns] - gkeys = {'by_uid':'UID', 'by_session':'Session'} - self.grouping = grouping.lower() - if self.grouping in gkeys: - gkey = gkeys[self.grouping] - for r in self: - if r['Sample'] in samples_to_split: - r['Sample_original'] = r['Sample'] - r['Sample'] = f"{r['Sample']}__{r[gkey]}" - elif r['Sample'] in self.unknowns: - r['Sample_original'] = r['Sample'] - self.refresh_samples() - - - def unsplit_samples(self, tables = False): - ''' - 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). - ''' - unknowns_old = sorted({s for s in self.unknowns}) - CM_old = self.standardization.covar[:,:] - VD_old = self.standardization.params.valuesdict().copy() - vars_old = self.standardization.var_names - - unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r}) - - Ns = len(vars_old) - len(unknowns_old) - vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new] - VD_new = {k: VD_old[k] for k in vars_old[:Ns]} - - W = np.zeros((len(vars_new), len(vars_old))) - W[:Ns,:Ns] = np.eye(Ns) - for u in unknowns_new: - splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u}) - if self.grouping == 'by_session': - weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits] - elif self.grouping == 'by_uid': - weights = [1 for s in splits] - sw = sum(weights) - weights = [w/sw for w in weights] - W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:] - - CM_new = W @ CM_old @ W.T - V = W @ np.array([[VD_old[k]] for k in vars_old]) - VD_new = {k:v[0] for k,v in zip(vars_new, V)} - - self.standardization.covar = CM_new - self.standardization.params.valuesdict = lambda : VD_new - self.standardization.var_names = vars_new - - for r in self: - if r['Sample'] in self.unknowns: - r['Sample_split'] = r['Sample'] - r['Sample'] = r['Sample_original'] - - self.refresh_samples() - self.consolidate_samples() - self.repeatabilities() - - if tables: - self.table_of_analyses() - self.table_of_samples() - - def assign_timestamps(self): - ''' - 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. - ''' - for session in self.sessions: - sdata = self.sessions[session]['data'] - try: - t0 = np.mean([r['TimeTag'] for r in sdata]) - for r in sdata: - r['t'] = r['TimeTag'] - t0 - except KeyError: - t0 = (len(sdata)-1)/2 - for t,r in enumerate(sdata): - r['t'] = t - t0 - - - def report(self): - ''' - Prints a report on the standardization fit. - Only applicable after `D4xdata.standardize(method='pooled')`. - ''' - report_fit(self.standardization) - - - def combine_samples(self, sample_groups): - ''' - 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: - ```py - {'group1': ['sample_1', 'sample_2'], - 'group2': ['sample_3', 'sample_4', 'sample_5']} - ``` - ''' - - samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])] - groups = sorted(sample_groups.keys()) - group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups} - D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples]) - CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples]) - W = np.array([ - [self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples] - for j in groups]) - D4x_new = W @ D4x_old - CM_new = W @ CM_old @ W.T - - return groups, D4x_new[:,0], CM_new - - - @make_verbal - def standardize(self, - method = 'pooled', - weighted_sessions = [], - consolidate = True, - consolidate_tables = False, - consolidate_plots = False, - constraints = {}, - ): - ''' - 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](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to - `'indep_sessions'`, the standardization processes each session independently, based only - on anchors analyses. - ''' - - self.standardization_method = method - self.assign_timestamps() - - if method == 'pooled': - if weighted_sessions: - for session_group in weighted_sessions: - X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x) - X.Nominal_D4x = self.Nominal_D4x.copy() - X.refresh() - result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False) - w = np.sqrt(result.redchi) - self.msg(f'Session group {session_group} MRSWD = {w:.4f}') - for r in X: - r[f'wD{self._4x}raw'] *= w - else: - self.msg(f'All D{self._4x}raw weights set to 1 ‰') - for r in self: - r[f'wD{self._4x}raw'] = 1. - - params = Parameters() - for k,session in enumerate(self.sessions): - self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.") - self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.") - self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.") - s = pf(session) - params.add(f'a_{s}', value = 0.9) - params.add(f'b_{s}', value = 0.) - params.add(f'c_{s}', value = -0.9) - params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift']) - params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift']) - params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift']) - for sample in self.unknowns: - params.add(f'D{self._4x}_{pf(sample)}', value = 0.5) - - for k in constraints: - params[k].expr = constraints[k] - - def residuals(p): - R = [] - for r in self: - session = pf(r['Session']) - sample = pf(r['Sample']) - if r['Sample'] in self.Nominal_D4x: - R += [ ( - r[f'D{self._4x}raw'] - ( - p[f'a_{session}'] * self.Nominal_D4x[r['Sample']] - + p[f'b_{session}'] * r[f'd{self._4x}'] - + p[f'c_{session}'] - + r['t'] * ( - p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']] - + p[f'b2_{session}'] * r[f'd{self._4x}'] - + p[f'c2_{session}'] - ) - ) - ) / r[f'wD{self._4x}raw'] ] - else: - R += [ ( - r[f'D{self._4x}raw'] - ( - p[f'a_{session}'] * p[f'D{self._4x}_{sample}'] - + p[f'b_{session}'] * r[f'd{self._4x}'] - + p[f'c_{session}'] - + r['t'] * ( - p[f'a2_{session}'] * p[f'D{self._4x}_{sample}'] - + p[f'b2_{session}'] * r[f'd{self._4x}'] - + p[f'c2_{session}'] - ) - ) - ) / r[f'wD{self._4x}raw'] ] - return R - - M = Minimizer(residuals, params) - result = M.least_squares() - self.Nf = result.nfree - self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf) -# if self.verbose: -# report_fit(result) - - for r in self: - s = pf(r["Session"]) - a = result.params.valuesdict()[f'a_{s}'] - b = result.params.valuesdict()[f'b_{s}'] - c = result.params.valuesdict()[f'c_{s}'] - a2 = result.params.valuesdict()[f'a2_{s}'] - b2 = result.params.valuesdict()[f'b2_{s}'] - c2 = result.params.valuesdict()[f'c2_{s}'] - 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']) - - self.standardization = result - - for session in self.sessions: - self.sessions[session]['Np'] = 3 - for k in ['scrambling', 'slope', 'wg']: - if self.sessions[session][f'{k}_drift']: - self.sessions[session]['Np'] += 1 - - if consolidate: - self.consolidate(tables = consolidate_tables, plots = consolidate_plots) - return result - - - elif method == 'indep_sessions': - - if weighted_sessions: - for session_group in weighted_sessions: - X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x) - X.Nominal_D4x = self.Nominal_D4x.copy() - X.refresh() - # This is only done to assign r['wD47raw'] for r in X: - X.standardize(method = method, weighted_sessions = [], consolidate = False) - 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}') - else: - self.msg('All weights set to 1 ‰') - for r in self: - r[f'wD{self._4x}raw'] = 1 - - for session in self.sessions: - s = self.sessions[session] - p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2'] - p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']] - s['Np'] = sum(p_active) - sdata = s['data'] - - A = np.array([ - [ - self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'], - r[f'd{self._4x}'] / r[f'wD{self._4x}raw'], - 1 / r[f'wD{self._4x}raw'], - self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'], - r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'], - r['t'] / r[f'wD{self._4x}raw'] - ] - for r in sdata if r['Sample'] in self.anchors - ])[:,p_active] # only keep columns for the active parameters - 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]) - s['Na'] = Y.size - CM = linalg.inv(A.T @ A) - bf = (CM @ A.T @ Y).T[0,:] - k = 0 - for n,a in zip(p_names, p_active): - if a: - s[n] = bf[k] -# self.msg(f'{n} = {bf[k]}') - k += 1 - else: - s[n] = 0. -# self.msg(f'{n} = 0.0') - - for r in sdata : - a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2'] - 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']) - r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t']) - - s['CM'] = np.zeros((6,6)) - i = 0 - k_active = [j for j,a in enumerate(p_active) if a] - for j,a in enumerate(p_active): - if a: - s['CM'][j,k_active] = CM[i,:] - i += 1 - - if not weighted_sessions: - w = self.rmswd()['rmswd'] - for r in self: - r[f'wD{self._4x}'] *= w - r[f'wD{self._4x}raw'] *= w - for session in self.sessions: - self.sessions[session]['CM'] *= w**2 - - for session in self.sessions: - s = self.sessions[session] - s['SE_a'] = s['CM'][0,0]**.5 - s['SE_b'] = s['CM'][1,1]**.5 - s['SE_c'] = s['CM'][2,2]**.5 - s['SE_a2'] = s['CM'][3,3]**.5 - s['SE_b2'] = s['CM'][4,4]**.5 - s['SE_c2'] = s['CM'][5,5]**.5 - - if not weighted_sessions: - self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions]) - else: - self.Nf = 0 - for sg in weighted_sessions: - self.Nf += self.rmswd(sessions = sg)['Nf'] - - self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf) - - avgD4x = { - sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample]) - for sample in self.samples - } - chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self]) - rD4x = (chi2/self.Nf)**.5 - self.repeatability[f'sigma_{self._4x}'] = rD4x - - if consolidate: - self.consolidate(tables = consolidate_tables, plots = consolidate_plots) - - - def standardization_error(self, session, d4x, D4x, t = 0): - ''' - Compute standardization error for a given session and - (δ47, Δ47) composition. - ''' - a = self.sessions[session]['a'] - b = self.sessions[session]['b'] - c = self.sessions[session]['c'] - a2 = self.sessions[session]['a2'] - b2 = self.sessions[session]['b2'] - c2 = self.sessions[session]['c2'] - CM = self.sessions[session]['CM'] - - x, y = D4x, d4x - z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t -# x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t) - dxdy = -(b+b2*t) / (a+a2*t) - dxdz = 1. / (a+a2*t) - dxda = -x / (a+a2*t) - dxdb = -y / (a+a2*t) - dxdc = -1. / (a+a2*t) - dxda2 = -x * a2 / (a+a2*t) - dxdb2 = -y * t / (a+a2*t) - dxdc2 = -t / (a+a2*t) - V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2]) - sx = (V @ CM @ V.T) ** .5 - return sx - - - @make_verbal - def summary(self, - dir = 'output', - filename = None, - save_to_file = True, - print_out = True, - ): - ''' - 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 - ''' - - out = [] - out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]] - 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])})"]] - out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]] - out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]] - out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]] - out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]] - out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]] - out += [['Model degrees of freedom', f"{self.Nf}"]] - out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]] - out += [['Standardization method', self.standardization_method]] - - if save_to_file: - if not os.path.exists(dir): - os.makedirs(dir) - if filename is None: - filename = f'D{self._4x}_summary.csv' - with open(f'{dir}/{filename}', 'w') as fid: - fid.write(make_csv(out)) - if print_out: - self.msg('\n' + pretty_table(out, header = 0)) - - - @make_verbal - def table_of_sessions(self, - dir = 'output', - filename = None, - save_to_file = True, - print_out = True, - output = None, - ): - ''' - 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']]`) - ''' - include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions]) - include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions]) - include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions]) - - 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']] - if include_a2: - out[-1] += ['a2 ± SE'] - if include_b2: - out[-1] += ['b2 ± SE'] - if include_c2: - out[-1] += ['c2 ± SE'] - for session in self.sessions: - out += [[ - session, - f"{self.sessions[session]['Na']}", - f"{self.sessions[session]['Nu']}", - f"{self.sessions[session]['d13Cwg_VPDB']:.3f}", - f"{self.sessions[session]['d18Owg_VSMOW']:.3f}", - f"{self.sessions[session]['r_d13C_VPDB']:.4f}", - f"{self.sessions[session]['r_d18O_VSMOW']:.4f}", - f"{self.sessions[session][f'r_D{self._4x}']:.4f}", - f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}", - f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}", - f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}", - ]] - if include_a2: - if self.sessions[session]['scrambling_drift']: - out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"] - else: - out[-1] += [''] - if include_b2: - if self.sessions[session]['slope_drift']: - out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"] - else: - out[-1] += [''] - if include_c2: - if self.sessions[session]['wg_drift']: - out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"] - else: - out[-1] += [''] - - if save_to_file: - if not os.path.exists(dir): - os.makedirs(dir) - if filename is None: - filename = f'D{self._4x}_sessions.csv' - with open(f'{dir}/{filename}', 'w') as fid: - fid.write(make_csv(out)) - if print_out: - self.msg('\n' + pretty_table(out)) - if output == 'raw': - return out - elif output == 'pretty': - return pretty_table(out) - - - @make_verbal - def table_of_analyses( - self, - dir = 'output', - filename = None, - save_to_file = True, - print_out = True, - output = None, - ): - ''' - 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']]`) - ''' - - out = [['UID','Session','Sample']] - 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}] - for f in extra_fields: - out[-1] += [f[0]] - out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}'] - for r in self: - out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]] - for f in extra_fields: - out[-1] += [f"{r[f[0]]:{f[1]}}"] - out[-1] += [ - f"{r['d13Cwg_VPDB']:.3f}", - f"{r['d18Owg_VSMOW']:.3f}", - f"{r['d45']:.6f}", - f"{r['d46']:.6f}", - f"{r['d47']:.6f}", - f"{r['d48']:.6f}", - f"{r['d49']:.6f}", - f"{r['d13C_VPDB']:.6f}", - f"{r['d18O_VSMOW']:.6f}", - f"{r['D47raw']:.6f}", - f"{r['D48raw']:.6f}", - f"{r['D49raw']:.6f}", - f"{r[f'D{self._4x}']:.6f}" - ] - if save_to_file: - if not os.path.exists(dir): - os.makedirs(dir) - if filename is None: - filename = f'D{self._4x}_analyses.csv' - with open(f'{dir}/{filename}', 'w') as fid: - fid.write(make_csv(out)) - if print_out: - self.msg('\n' + pretty_table(out)) - return out - - @make_verbal - def covar_table( - self, - correl = False, - dir = 'output', - filename = None, - save_to_file = True, - print_out = True, - output = None, - ): - ''' - 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']]`) - ''' - samples = sorted([u for u in self.unknowns]) - out = [[''] + samples] - for s1 in samples: - out.append([s1]) - for s2 in samples: - if correl: - out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}') - else: - out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}') - - if save_to_file: - if not os.path.exists(dir): - os.makedirs(dir) - if filename is None: - if correl: - filename = f'D{self._4x}_correl.csv' - else: - filename = f'D{self._4x}_covar.csv' - with open(f'{dir}/{filename}', 'w') as fid: - fid.write(make_csv(out)) - if print_out: - self.msg('\n'+pretty_table(out)) - if output == 'raw': - return out - elif output == 'pretty': - return pretty_table(out) - - @make_verbal - def table_of_samples( - self, - dir = 'output', - filename = None, - save_to_file = True, - print_out = True, - output = None, - ): - ''' - 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']]`) - ''' - - out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']] - for sample in self.anchors: - out += [[ - f"{sample}", - f"{self.samples[sample]['N']}", - f"{self.samples[sample]['d13C_VPDB']:.2f}", - f"{self.samples[sample]['d18O_VSMOW']:.2f}", - f"{self.samples[sample][f'D{self._4x}']:.4f}",'','', - f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', '' - ]] - for sample in self.unknowns: - out += [[ - f"{sample}", - f"{self.samples[sample]['N']}", - f"{self.samples[sample]['d13C_VPDB']:.2f}", - f"{self.samples[sample]['d18O_VSMOW']:.2f}", - f"{self.samples[sample][f'D{self._4x}']:.4f}", - f"{self.samples[sample][f'SE_D{self._4x}']:.4f}", - f"± {self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}", - f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', - f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else '' - ]] - if save_to_file: - if not os.path.exists(dir): - os.makedirs(dir) - if filename is None: - filename = f'D{self._4x}_samples.csv' - with open(f'{dir}/{filename}', 'w') as fid: - fid.write(make_csv(out)) - if print_out: - self.msg('\n'+pretty_table(out)) - if output == 'raw': - return out - elif output == 'pretty': - return pretty_table(out) - - - def plot_sessions(self, dir = 'output', figsize = (8,8)): - ''' - 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 - ''' - if not os.path.exists(dir): - os.makedirs(dir) - - for session in self.sessions: - sp = self.plot_single_session(session, xylimits = 'constant') - ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf') - ppl.close(sp.fig) - - - @make_verbal - def consolidate_samples(self): - ''' - 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 δ13C_VPDB value for this sample - + `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2) - + `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_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`. - ''' - D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']] - for sample in self.samples: - self.samples[sample]['N'] = len(self.samples[sample]['data']) - if self.samples[sample]['N'] > 1: - self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']]) - - self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']]) - self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']]) - - D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']] - if len(D4x_pop) > 2: - self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1] - - if self.standardization_method == 'pooled': - for sample in self.anchors: - self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample] - self.samples[sample][f'SE_D{self._4x}'] = 0. - for sample in self.unknowns: - self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}'] - try: - self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5 - except ValueError: - # when `sample` is constrained by self.standardize(constraints = {...}), - # it is no longer listed in self.standardization.var_names. - # Temporary fix: define SE as zero for now - self.samples[sample][f'SE_D4{self._4x}'] = 0. - - elif self.standardization_method == 'indep_sessions': - for sample in self.anchors: - self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample] - self.samples[sample][f'SE_D{self._4x}'] = 0. - for sample in self.unknowns: - self.msg(f'Consolidating sample {sample}') - self.unknowns[sample][f'session_D{self._4x}'] = {} - session_avg = [] - for session in self.sessions: - sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample] - if sdata: - self.msg(f'{sample} found in session {session}') - avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata]) - avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata]) - # !! TODO: sigma_s below does not account for temporal changes in standardization error - sigma_s = self.standardization_error(session, avg_d4x, avg_D4x) - sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5 - session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5]) - self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1] - self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg)) - weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']} - wsum = sum([weights[s] for s in weights]) - for s in weights: - self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum] - - - def consolidate_sessions(self): - ''' - 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`: δ13C_VPDB repeatability of analyses within the session - + `r_d18O_VSMOW`: δ18O_VSMOW 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`: δ13C_VPDB of WG - + `d18Owg_VSMOW`: δ18O_VSMOW of WG - ''' - for session in self.sessions: - if 'd13Cwg_VPDB' not in self.sessions[session]: - self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB'] - if 'd18Owg_VSMOW' not in self.sessions[session]: - self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW'] - self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors]) - self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns]) - - self.msg(f'Computing repeatabilities for session {session}') - self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session]) - self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session]) - self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session]) - - if self.standardization_method == 'pooled': - for session in self.sessions: - - self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}'] - i = self.standardization.var_names.index(f'a_{pf(session)}') - self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5 - - self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}'] - i = self.standardization.var_names.index(f'b_{pf(session)}') - self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5 - - self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}'] - i = self.standardization.var_names.index(f'c_{pf(session)}') - self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5 - - self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}'] - if self.sessions[session]['scrambling_drift']: - i = self.standardization.var_names.index(f'a2_{pf(session)}') - self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5 - else: - self.sessions[session]['SE_a2'] = 0. - - self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}'] - if self.sessions[session]['slope_drift']: - i = self.standardization.var_names.index(f'b2_{pf(session)}') - self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5 - else: - self.sessions[session]['SE_b2'] = 0. - - self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}'] - if self.sessions[session]['wg_drift']: - i = self.standardization.var_names.index(f'c2_{pf(session)}') - self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5 - else: - self.sessions[session]['SE_c2'] = 0. - - i = self.standardization.var_names.index(f'a_{pf(session)}') - j = self.standardization.var_names.index(f'b_{pf(session)}') - k = self.standardization.var_names.index(f'c_{pf(session)}') - CM = np.zeros((6,6)) - CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]] - try: - i2 = self.standardization.var_names.index(f'a2_{pf(session)}') - CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]] - CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2] - try: - j2 = self.standardization.var_names.index(f'b2_{pf(session)}') - CM[3,4] = self.standardization.covar[i2,j2] - CM[4,3] = self.standardization.covar[j2,i2] - except ValueError: - pass - try: - k2 = self.standardization.var_names.index(f'c2_{pf(session)}') - CM[3,5] = self.standardization.covar[i2,k2] - CM[5,3] = self.standardization.covar[k2,i2] - except ValueError: - pass - except ValueError: - pass - try: - j2 = self.standardization.var_names.index(f'b2_{pf(session)}') - CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]] - CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2] - try: - k2 = self.standardization.var_names.index(f'c2_{pf(session)}') - CM[4,5] = self.standardization.covar[j2,k2] - CM[5,4] = self.standardization.covar[k2,j2] - except ValueError: - pass - except ValueError: - pass - try: - k2 = self.standardization.var_names.index(f'c2_{pf(session)}') - CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]] - CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2] - except ValueError: - pass - - self.sessions[session]['CM'] = CM - - elif self.standardization_method == 'indep_sessions': - pass # Not implemented yet - - - @make_verbal - def repeatabilities(self): - ''' - Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x - (for all samples, for anchors, and for unknowns). - ''' - self.msg('Computing reproducibilities for all sessions') - - self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors') - self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors') - self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors') - self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns') - self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples') - - - @make_verbal - def consolidate(self, tables = True, plots = True): - ''' - Collect information about samples, sessions and repeatabilities. - ''' - self.consolidate_samples() - self.consolidate_sessions() - self.repeatabilities() - - if tables: - self.summary() - self.table_of_sessions() - self.table_of_analyses() - self.table_of_samples() - - if plots: - self.plot_sessions() - - - @make_verbal - def rmswd(self, - samples = 'all samples', - sessions = 'all sessions', - ): - ''' - 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'`. - ''' - if samples == 'all samples': - mysamples = [k for k in self.samples] - elif samples == 'anchors': - mysamples = [k for k in self.anchors] - elif samples == 'unknowns': - mysamples = [k for k in self.unknowns] - else: - mysamples = samples - - if sessions == 'all sessions': - sessions = [k for k in self.sessions] - - chisq, Nf = 0, 0 - for sample in mysamples : - G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ] - if len(G) > 1 : - X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G]) - Nf += (len(G) - 1) - chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G]) - r = (chisq / Nf)**.5 if Nf > 0 else 0 - self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.') - return {'rmswd': r, 'chisq': chisq, 'Nf': Nf} - - - @make_verbal - def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'): - ''' - Compute the repeatability of `[r[key] for r in self]` - ''' - # NB: it's debatable whether rD47 should be computed - # with Nf = len(self)-len(self.samples) instead of - # Nf = len(self) - len(self.unknwons) - 3*len(self.sessions) - - if samples == 'all samples': - mysamples = [k for k in self.samples] - elif samples == 'anchors': - mysamples = [k for k in self.anchors] - elif samples == 'unknowns': - mysamples = [k for k in self.unknowns] - else: - mysamples = samples - - if sessions == 'all sessions': - sessions = [k for k in self.sessions] - - if key in ['D47', 'D48']: - chisq, Nf = 0, 0 - for sample in mysamples : - X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ] - if len(X) > 1 : - chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ]) - if sample in self.unknowns: - Nf += len(X) - 1 - else: - Nf += len(X) - if samples in ['anchors', 'all samples']: - Nf -= sum([self.sessions[s]['Np'] for s in sessions]) - r = (chisq / Nf)**.5 if Nf > 0 else 0 - - else: # if key not in ['D47', 'D48'] - chisq, Nf = 0, 0 - for sample in mysamples : - X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ] - if len(X) > 1 : - Nf += len(X) - 1 - chisq += np.sum([ (x-np.mean(X))**2 for x in X ]) - r = (chisq / Nf)**.5 if Nf > 0 else 0 - - self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.') - return r - - def sample_average(self, samples, weights = 'equal', normalize = True): - ''' - 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** - - ```python - 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. - - ```python - self.sample_average(['X','Y'], [1, -1], normalize = False) - ``` - - returns the value and SE of the difference Δ4x(X) - Δ4x(Y). - ''' - if weights == 'equal': - weights = [1/len(samples)] * len(samples) - - if normalize: - s = sum(weights) - if s: - weights = [w/s for w in weights] - - try: -# indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples] -# C = self.standardization.covar[indices,:][:,indices] - C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples]) - X = [self.samples[sample][f'D{self._4x}'] for sample in samples] - return correlated_sum(X, C, weights) - except ValueError: - return (0., 0.) - - - def sample_D4x_covar(self, sample1, sample2 = None): - ''' - 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. - ''' - if sample2 is None: - sample2 = sample1 - if self.standardization_method == 'pooled': - i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}') - j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}') - return self.standardization.covar[i, j] - elif self.standardization_method == 'indep_sessions': - if sample1 == sample2: - return self.samples[sample1][f'SE_D{self._4x}']**2 - else: - c = 0 - for session in self.sessions: - sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1] - sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2] - if sdata1 and sdata2: - a = self.sessions[session]['a'] - # !! TODO: CM below does not account for temporal changes in standardization parameters - CM = self.sessions[session]['CM'][:3,:3] - avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1]) - avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1]) - avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2]) - avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2]) - c += ( - self.unknowns[sample1][f'session_D{self._4x}'][session][2] - * self.unknowns[sample2][f'session_D{self._4x}'][session][2] - * np.array([[avg_D4x_1, avg_d4x_1, 1]]) - @ CM - @ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T - ) / a**2 - return float(c) - - def sample_D4x_correl(self, sample1, sample2 = None): - ''' - Correlation between Δ4x errors of samples - - Returns the error correlation between the average Δ4x values of two samples. - ''' - if sample2 is None or sample2 == sample1: - return 1. - return ( - self.sample_D4x_covar(sample1, sample2) - / self.unknowns[sample1][f'SE_D{self._4x}'] - / self.unknowns[sample2][f'SE_D{self._4x}'] - ) - - def plot_single_session(self, - session, - kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4), - kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4), - kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75), - kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75), - kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75), - xylimits = 'free', # | 'constant' - x_label = None, - y_label = None, - error_contour_interval = 'auto', - fig = 'new', - ): - ''' - Generate plot for a single session - ''' - if x_label is None: - x_label = f'δ$_{{{self._4x}}}$ (‰)' - if y_label is None: - y_label = f'Δ$_{{{self._4x}}}$ (‰)' - - out = _SessionPlot() - anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]] - unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]] - - if fig == 'new': - out.fig = ppl.figure(figsize = (6,6)) - ppl.subplots_adjust(.1,.1,.9,.9) - - out.anchor_analyses, = ppl.plot( - [r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors], - [r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors], - **kw_plot_anchors) - out.unknown_analyses, = ppl.plot( - [r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns], - [r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns], - **kw_plot_unknowns) - out.anchor_avg = ppl.plot( - np.array([ np.array([ - np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1, - np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1 - ]) for sample in anchors]).T, - np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T, - **kw_plot_anchor_avg) - out.unknown_avg = ppl.plot( - np.array([ np.array([ - np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1, - np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1 - ]) for sample in unknowns]).T, - np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T, - **kw_plot_unknown_avg) - if xylimits == 'constant': - x = [r[f'd{self._4x}'] for r in self] - y = [r[f'D{self._4x}'] for r in self] - x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y) - w, h = x2-x1, y2-y1 - x1 -= w/20 - x2 += w/20 - y1 -= h/20 - y2 += h/20 - ppl.axis([x1, x2, y1, y2]) - elif xylimits == 'free': - x1, x2, y1, y2 = ppl.axis() - else: - x1, x2, y1, y2 = ppl.axis(xylimits) - - if error_contour_interval != 'none': - xi, yi = np.linspace(x1, x2), np.linspace(y1, y2) - XI,YI = np.meshgrid(xi, yi) - SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi]) - if error_contour_interval == 'auto': - rng = np.max(SI) - np.min(SI) - if rng <= 0.01: - cinterval = 0.001 - elif rng <= 0.03: - cinterval = 0.004 - elif rng <= 0.1: - cinterval = 0.01 - elif rng <= 0.3: - cinterval = 0.03 - elif rng <= 1.: - cinterval = 0.1 - else: - cinterval = 0.5 - else: - cinterval = error_contour_interval - - cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval) - out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error) - out.clabel = ppl.clabel(out.contour) - - ppl.xlabel(x_label) - ppl.ylabel(y_label) - ppl.title(session, weight = 'bold') - ppl.grid(alpha = .2) - out.ax = ppl.gca() - - return out - - def plot_residuals( - self, - hist = False, - binwidth = 2/3, - dir = 'output', - filename = None, - highlight = [], - colors = None, - figsize = None, - ): - ''' - Plot residuals of each analysis as a function of time (actually, as a function of - the order of analyses in the `D4xdata` object) - - + `hist`: whether to add a histogram of residuals - + `histbins`: specify bin edges for the histogram - + `dir`: the directory in which to save the plot - + `highlight`: a list of samples to highlight - + `colors`: a dict of `{<sample>: <color>}` for all samples - + `figsize`: (width, height) of figure - ''' - # Layout - fig = ppl.figure(figsize = (8,4) if figsize is None else figsize) - if hist: - ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72) - ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15) - else: - ppl.subplots_adjust(.08,.05,.78,.8) - ax1 = ppl.subplot(111) - - # Colors - N = len(self.anchors) - if colors is None: - if len(highlight) > 0: - Nh = len(highlight) - if Nh == 1: - colors = {highlight[0]: (0,0,0)} - elif Nh == 3: - colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])} - elif Nh == 4: - colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])} - else: - colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)} - else: - if N == 3: - colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])} - elif N == 4: - colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])} - else: - colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)} - - ppl.sca(ax1) - - ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75) - - session = self[0]['Session'] - x1 = 0 -# ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self]) - x_sessions = {} - one_or_more_singlets = False - one_or_more_multiplets = False - for k,r in enumerate(self): - if r['Session'] != session: - x2 = k-1 - x_sessions[session] = (x1+x2)/2 - ppl.axvline(k - 0.5, color = 'k', lw = .5) - session = r['Session'] - x1 = k - singlet = len(self.samples[r['Sample']]['data']) == 1 - if r['Sample'] in self.unknowns: - if singlet: - one_or_more_singlets = True - else: - one_or_more_multiplets = True - kw = dict( - marker = 'x' if singlet else '+', - ms = 4 if singlet else 5, - ls = 'None', - mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0), - mew = 1, - alpha = 0.2 if singlet else 1, - ) - if highlight and r['Sample'] not in highlight: - kw['alpha'] = 0.2 - ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw) - x2 = k - x_sessions[session] = (x1+x2)/2 - - ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1) - ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1) - if not hist: - ppl.text(len(self), self.repeatability['r_D47']*1000, f" SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center') - ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f" 95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center') - - xmin, xmax, ymin, ymax = ppl.axis() - for s in x_sessions: - ppl.text( - x_sessions[s], - ymax +1, - s, - va = 'bottom', - **( - dict(ha = 'center') - if len(self.sessions[s]['data']) > (0.15 * len(self)) - else dict(ha = 'left', rotation = 45) - ) - ) - - if hist: - ppl.sca(ax2) - - for s in colors: - kw['marker'] = '+' - kw['ms'] = 5 - kw['mec'] = colors[s] - kw['label'] = s - kw['alpha'] = 1 - ppl.plot([], [], **kw) - - kw['mec'] = (0,0,0) - - if one_or_more_singlets: - kw['marker'] = 'x' - kw['ms'] = 4 - kw['alpha'] = .2 - kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other' - ppl.plot([], [], **kw) - - if one_or_more_multiplets: - kw['marker'] = '+' - kw['ms'] = 4 - kw['alpha'] = 1 - kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other' - ppl.plot([], [], **kw) - - if hist: - leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9) - else: - leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5) - leg.set_zorder(-1000) - - ppl.sca(ax1) - - ppl.ylabel('Δ$_{47}$ residuals (ppm)') - ppl.xticks([]) - ppl.axis([-1, len(self), None, None]) - - if hist: - ppl.sca(ax2) - X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self] - ppl.hist( - X, - orientation = 'horizontal', - histtype = 'stepfilled', - ec = [.4]*3, - fc = [.25]*3, - alpha = .25, - bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)), - ) - ppl.axis([None, None, ymin, ymax]) - ppl.text(0, 0, - f" SD = {self.repeatability['r_D47']*1000:.1f} ppm\n 95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", - size = 8, - alpha = 1, - va = 'center', - ha = 'left', - ) - - ppl.xticks([]) - ppl.yticks([]) -# ax2.spines['left'].set_visible(False) - ax2.spines['right'].set_visible(False) - ax2.spines['top'].set_visible(False) - ax2.spines['bottom'].set_visible(False) - - - if not os.path.exists(dir): - os.makedirs(dir) - if filename is None: - return fig - elif filename == '': - filename = f'D{self._4x}_residuals.pdf' - ppl.savefig(f'{dir}/{filename}') - ppl.close(fig) - - - def simulate(self, *args, **kwargs): - ''' - Legacy function with warning message pointing to `virtual_data()` - ''' - raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()') - - def plot_distribution_of_analyses(self, dir = 'output', filename = None, vs_time = False, output = None): - ''' - Plot temporal distribution of all analyses in the data set. - - **Parameters** - - + `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially. - ''' - - asamples = [s for s in self.anchors] - usamples = [s for s in self.unknowns] - if output is None or output == 'fig': - fig = ppl.figure(figsize = (6,4)) - ppl.subplots_adjust(0.02, 0.03, 0.9, 0.8) - Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)]) - for k, s in enumerate(asamples + usamples): - if vs_time: - X = [r['TimeTag'] for r in self if r['Sample'] == s] - else: - X = [x for x,r in enumerate(self) if r['Sample'] == s] - Y = [k for x in X] - ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .5) - ppl.axhline(k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25) - ppl.text(Xmax, k, f' {s}', va = 'center', ha = 'left', size = 7) - if vs_time: - t = [r['TimeTag'] for r in self] - t1, t2 = min(t), max(t) - tspan = t2 - t1 - t1 -= tspan / len(self) - t2 += tspan / len(self) - ppl.axis([t1, t2, -1, k+1]) - else: - ppl.axis([-1, len(self), -1, k+1]) - - - x2 = 0 - for session in self.sessions: - x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session]) - if vs_time: - ppl.axvline(x1, color = 'k', lw = .75) - if k: - if vs_time: - ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .2) - else: - ppl.axvline((x1+x2)/2, color = 'k', lw = .75) - x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session]) -# from xlrd import xldate_as_datetime -# print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0)) - if vs_time: - ppl.axvline(x2, color = 'k', lw = .75) - ppl.text((2*x1+x2)/3, k+1, session, ha = 'left', va = 'bottom', rotation = 45, size = 8) - - ppl.xticks([]) - ppl.yticks([]) - - if output is None: - if not os.path.exists(dir): - os.makedirs(dir) - if filename == None: - filename = f'D{self._4x}_distribution_of_analyses.pdf' - ppl.savefig(f'{dir}/{filename}') - ppl.close(fig) - elif output == 'ax': - return ppl.gca() - elif output == 'fig': - return fig - - -class D47data(D4xdata): - ''' - Store and process data for a large set of Δ47 analyses, - usually comprising more than one analytical session. - ''' - - 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, - } # I-CDES (Bernasconi et al., 2021) - ''' - 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)](https://doi.org/10.1029/2020GC009588)): - ```py - { - '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, - } - ``` - ''' - - - @property - def Nominal_D47(self): - return self.Nominal_D4x - - - @Nominal_D47.setter - def Nominal_D47(self, new): - self.Nominal_D4x = dict(**new) - self.refresh() - - - def __init__(self, l = [], **kwargs): - ''' - **Parameters:** same as `D4xdata.__init__()` - ''' - D4xdata.__init__(self, l = l, mass = '47', **kwargs) - - - def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'): - ''' - 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)](https://doi.org/10.1029/2018GC008127); - `wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)). - + `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. - ''' - f = { - 'petersen': fCO2eqD47_Petersen, - 'wang': fCO2eqD47_Wang, - }[fCo2eqD47] - foo = {} - for r in self: - if 'Teq' in r: - if r['Sample'] in foo: - assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.' - else: - foo[r['Sample']] = f(r['Teq']) - else: - assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.' - - if priority == 'replace': - self.Nominal_D47 = {} - for s in foo: - if priority != 'old' or s not in self.Nominal_D47: - self.Nominal_D47[s] = foo[s] - - - - -class D48data(D4xdata): - ''' - Store and process data for a large set of Δ48 analyses, - usually comprising more than one analytical session. - ''' - - Nominal_D4x = { - 'ETH-1': 0.138, - 'ETH-2': 0.138, - 'ETH-3': 0.270, - 'ETH-4': 0.223, - 'GU-1': -0.419, - } # (Fiebig et al., 2019, 2021) - ''' - 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)](https://doi.org/10.1016/j.chemgeo.2019.05.019), - Fiebig et al. (in press)): - - ```py - { - 'ETH-1' : 0.138, - 'ETH-2' : 0.138, - 'ETH-3' : 0.270, - 'ETH-4' : 0.223, - 'GU-1' : -0.419, - } - ``` - ''' - - - @property - def Nominal_D48(self): - return self.Nominal_D4x - - - @Nominal_D48.setter - def Nominal_D48(self, new): - self.Nominal_D4x = dict(**new) - self.refresh() - - - def __init__(self, l = [], **kwargs): - ''' - **Parameters:** same as `D4xdata.__init__()` - ''' - D4xdata.__init__(self, l = l, mass = '48', **kwargs) - - -class _SessionPlot(): - ''' - Simple placeholder class - ''' - def __init__(self): - pass -
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 + 16__docformat__ = "restructuredtext" + 17__author__ = 'Mathieu Daëron' + 18__contact__ = 'daeron@lsce.ipsl.fr' + 19__copyright__ = 'Copyright (c) 2023 Mathieu Daëron' + 20__license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause' + 21__date__ = '2023-05-11' + 22__version__ = '2.0.4' + 23 + 24import os + 25import numpy as np + 26from statistics import stdev + 27from scipy.stats import t as tstudent + 28from scipy.stats import levene + 29from scipy.interpolate import interp1d + 30from numpy import linalg + 31from lmfit import Minimizer, Parameters, report_fit + 32from matplotlib import pyplot as ppl + 33from datetime import datetime as dt + 34from functools import wraps + 35from colorsys import hls_to_rgb + 36from matplotlib import rcParams + 37 + 38rcParams['font.family'] = 'sans-serif' + 39rcParams['font.sans-serif'] = 'Helvetica' + 40rcParams['font.size'] = 10 + 41rcParams['mathtext.fontset'] = 'custom' + 42rcParams['mathtext.rm'] = 'sans' + 43rcParams['mathtext.bf'] = 'sans:bold' + 44rcParams['mathtext.it'] = 'sans:italic' + 45rcParams['mathtext.cal'] = 'sans:italic' + 46rcParams['mathtext.default'] = 'rm' + 47rcParams['xtick.major.size'] = 4 + 48rcParams['xtick.major.width'] = 1 + 49rcParams['ytick.major.size'] = 4 + 50rcParams['ytick.major.width'] = 1 + 51rcParams['axes.grid'] = False + 52rcParams['axes.linewidth'] = 1 + 53rcParams['grid.linewidth'] = .75 + 54rcParams['grid.linestyle'] = '-' + 55rcParams['grid.alpha'] = .15 + 56rcParams['savefig.dpi'] = 150 + 57 + 58Petersen_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]]) + 59_fCO2eqD47_Petersen = interp1d(Petersen_etal_CO2eqD47[:,0], Petersen_etal_CO2eqD47[:,1]) + 60def fCO2eqD47_Petersen(T): + 61 ''' + 62 CO2 equilibrium Δ47 value as a function of T (in degrees C) + 63 according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127). + 64 + 65 ''' + 66 return float(_fCO2eqD47_Petersen(T)) + 67 + 68 + 69Wang_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]]) + 70_fCO2eqD47_Wang = interp1d(Wang_etal_CO2eqD47[:,0] - 0.15, Wang_etal_CO2eqD47[:,1]) + 71def fCO2eqD47_Wang(T): + 72 ''' + 73 CO2 equilibrium Δ47 value as a function of `T` (in degrees C) + 74 according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039) + 75 (supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)). + 76 ''' + 77 return float(_fCO2eqD47_Wang(T)) + 78 + 79 + 80def correlated_sum(X, C, w = None): + 81 ''' + 82 Compute covariance-aware linear combinations + 83 + 84 **Parameters** + 85 + 86 + `X`: list or 1-D array of values to sum + 87 + `C`: covariance matrix for the elements of `X` + 88 + `w`: list or 1-D array of weights to apply to the elements of `X` + 89 (all equal to 1 by default) + 90 + 91 Return the sum (and its SE) of the elements of `X`, with optional weights equal + 92 to the elements of `w`, accounting for covariances between the elements of `X`. + 93 ''' + 94 if w is None: + 95 w = [1 for x in X] + 96 return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5 + 97 + 98 + 99def make_csv(x, hsep = ',', vsep = '\n'): + 100 ''' + 101 Formats a list of lists of strings as a CSV + 102 + 103 **Parameters** + 104 + 105 + `x`: the list of lists of strings to format + 106 + `hsep`: the field separator (`,` by default) + 107 + `vsep`: the line-ending convention to use (`\\n` by default) + 108 + 109 **Example** + 110 + 111 ```py + 112 print(make_csv([['a', 'b', 'c'], ['d', 'e', 'f']])) + 113 ``` + 114 + 115 outputs: + 116 + 117 ```py + 118 a,b,c + 119 d,e,f + 120 ``` + 121 ''' + 122 return vsep.join([hsep.join(l) for l in x]) + 123 + 124 + 125def pf(txt): + 126 ''' + 127 Modify string `txt` to follow `lmfit.Parameter()` naming rules. + 128 ''' + 129 return txt.replace('-','_').replace('.','_').replace(' ','_') + 130 + 131 + 132def smart_type(x): + 133 ''' + 134 Tries to convert string `x` to a float if it includes a decimal point, or + 135 to an integer if it does not. If both attempts fail, return the original + 136 string unchanged. + 137 ''' + 138 try: + 139 y = float(x) + 140 except ValueError: + 141 return x + 142 if '.' not in x: + 143 return int(y) + 144 return y + 145 + 146 + 147def pretty_table(x, header = 1, hsep = ' ', vsep = '–', align = '<'): + 148 ''' + 149 Reads a list of lists of strings and outputs an ascii table + 150 + 151 **Parameters** + 152 + 153 + `x`: a list of lists of strings + 154 + `header`: the number of lines to treat as header lines + 155 + `hsep`: the horizontal separator between columns + 156 + `vsep`: the character to use as vertical separator + 157 + `align`: string of left (`<`) or right (`>`) alignment characters. + 158 + 159 **Example** + 160 + 161 ```py + 162 x = [['A', 'B', 'C'], ['1', '1.9999', 'foo'], ['10', 'x', 'bar']] + 163 print(pretty_table(x)) + 164 ``` + 165 yields: + 166 ``` + 167 -- ------ --- + 168 A B C + 169 -- ------ --- + 170 1 1.9999 foo + 171 10 x bar + 172 -- ------ --- + 173 ``` + 174 + 175 ''' + 176 txt = [] + 177 widths = [np.max([len(e) for e in c]) for c in zip(*x)] + 178 + 179 if len(widths) > len(align): + 180 align += '>' * (len(widths)-len(align)) + 181 sepline = hsep.join([vsep*w for w in widths]) + 182 txt += [sepline] + 183 for k,l in enumerate(x): + 184 if k and k == header: + 185 txt += [sepline] + 186 txt += [hsep.join([f'{e:{a}{w}}' for e, w, a in zip(l, widths, align)])] + 187 txt += [sepline] + 188 txt += [''] + 189 return '\n'.join(txt) + 190 + 191 + 192def transpose_table(x): + 193 ''' + 194 Transpose a list if lists + 195 + 196 **Parameters** + 197 + 198 + `x`: a list of lists + 199 + 200 **Example** + 201 + 202 ```py + 203 x = [[1, 2], [3, 4]] + 204 print(transpose_table(x)) # yields: [[1, 3], [2, 4]] + 205 ``` + 206 ''' + 207 return [[e for e in c] for c in zip(*x)] + 208 + 209 + 210def w_avg(X, sX) : + 211 ''' + 212 Compute variance-weighted average + 213 + 214 Returns the value and SE of the weighted average of the elements of `X`, + 215 with relative weights equal to their inverse variances (`1/sX**2`). + 216 + 217 **Parameters** + 218 + 219 + `X`: array-like of elements to average + 220 + `sX`: array-like of the corresponding SE values + 221 + 222 **Tip** + 223 + 224 If `X` and `sX` are initially arranged as a list of `(x, sx)` doublets, + 225 they may be rearranged using `zip()`: + 226 + 227 ```python + 228 foo = [(0, 1), (1, 0.5), (2, 0.5)] + 229 print(w_avg(*zip(*foo))) # yields: (1.3333333333333333, 0.3333333333333333) + 230 ``` + 231 ''' + 232 X = [ x for x in X ] + 233 sX = [ sx for sx in sX ] + 234 W = [ sx**-2 for sx in sX ] + 235 W = [ w/sum(W) for w in W ] + 236 Xavg = sum([ w*x for w,x in zip(W,X) ]) + 237 sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5 + 238 return Xavg, sXavg + 239 + 240 + 241def read_csv(filename, sep = ''): + 242 ''' + 243 Read contents of `filename` in csv format and return a list of dictionaries. + 244 + 245 In the csv string, spaces before and after field separators (`','` by default) + 246 are optional. + 247 + 248 **Parameters** + 249 + 250 + `filename`: the csv file to read + 251 + `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`, + 252 whichever appers most often in the contents of `filename`. + 253 ''' + 254 with open(filename) as fid: + 255 txt = fid.read() + 256 + 257 if sep == '': + 258 sep = sorted(',;\t', key = lambda x: - txt.count(x))[0] + 259 txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()] + 260 return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]] + 261 + 262 + 263def simulate_single_analysis( + 264 sample = 'MYSAMPLE', + 265 d13Cwg_VPDB = -4., d18Owg_VSMOW = 26., + 266 d13C_VPDB = None, d18O_VPDB = None, + 267 D47 = None, D48 = None, D49 = 0., D17O = 0., + 268 a47 = 1., b47 = 0., c47 = -0.9, + 269 a48 = 1., b48 = 0., c48 = -0.45, + 270 Nominal_D47 = None, + 271 Nominal_D48 = None, + 272 Nominal_d13C_VPDB = None, + 273 Nominal_d18O_VPDB = None, + 274 ALPHA_18O_ACID_REACTION = None, + 275 R13_VPDB = None, + 276 R17_VSMOW = None, + 277 R18_VSMOW = None, + 278 LAMBDA_17 = None, + 279 R18_VPDB = None, + 280 ): + 281 ''' + 282 Compute working-gas delta values for a single analysis, assuming a stochastic working + 283 gas and a “perfect” measurement (i.e. raw Δ values are identical to absolute values). + 284 + 285 **Parameters** + 286 + 287 + `sample`: sample name + 288 + `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas + 289 (respectively –4 and +26 ‰ by default) + 290 + `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample + 291 + `D47`, `D48`, `D49`, `D17O`: clumped-isotope and oxygen-17 anomalies + 292 of the carbonate sample + 293 + `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and + 294 Δ48 values if `D47` or `D48` are not specified + 295 + `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and + 296 δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified + 297 + `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor + 298 + `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17 + 299 correction parameters (by default equal to the `D4xdata` default values) + 300 + 301 Returns a dictionary with fields + 302 `['Sample', 'D17O', 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'd45', 'd46', 'd47', 'd48', 'd49']`. + 303 ''' + 304 + 305 if Nominal_d13C_VPDB is None: + 306 Nominal_d13C_VPDB = D4xdata().Nominal_d13C_VPDB + 307 + 308 if Nominal_d18O_VPDB is None: + 309 Nominal_d18O_VPDB = D4xdata().Nominal_d18O_VPDB + 310 + 311 if ALPHA_18O_ACID_REACTION is None: + 312 ALPHA_18O_ACID_REACTION = D4xdata().ALPHA_18O_ACID_REACTION + 313 + 314 if R13_VPDB is None: + 315 R13_VPDB = D4xdata().R13_VPDB + 316 + 317 if R17_VSMOW is None: + 318 R17_VSMOW = D4xdata().R17_VSMOW + 319 + 320 if R18_VSMOW is None: + 321 R18_VSMOW = D4xdata().R18_VSMOW + 322 + 323 if LAMBDA_17 is None: + 324 LAMBDA_17 = D4xdata().LAMBDA_17 + 325 + 326 if R18_VPDB is None: + 327 R18_VPDB = D4xdata().R18_VPDB + 328 + 329 R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW) ** LAMBDA_17 + 330 + 331 if Nominal_D47 is None: + 332 Nominal_D47 = D47data().Nominal_D47 + 333 + 334 if Nominal_D48 is None: + 335 Nominal_D48 = D48data().Nominal_D48 + 336 + 337 if d13C_VPDB is None: + 338 if sample in Nominal_d13C_VPDB: + 339 d13C_VPDB = Nominal_d13C_VPDB[sample] + 340 else: + 341 raise KeyError(f"Sample {sample} is missing d13C_VDP value, and it is not defined in Nominal_d13C_VDP.") + 342 + 343 if d18O_VPDB is None: + 344 if sample in Nominal_d18O_VPDB: + 345 d18O_VPDB = Nominal_d18O_VPDB[sample] + 346 else: + 347 raise KeyError(f"Sample {sample} is missing d18O_VPDB value, and it is not defined in Nominal_d18O_VPDB.") + 348 + 349 if D47 is None: + 350 if sample in Nominal_D47: + 351 D47 = Nominal_D47[sample] + 352 else: + 353 raise KeyError(f"Sample {sample} is missing D47 value, and it is not defined in Nominal_D47.") + 354 + 355 if D48 is None: + 356 if sample in Nominal_D48: + 357 D48 = Nominal_D48[sample] + 358 else: + 359 raise KeyError(f"Sample {sample} is missing D48 value, and it is not defined in Nominal_D48.") + 360 + 361 X = D4xdata() + 362 X.R13_VPDB = R13_VPDB + 363 X.R17_VSMOW = R17_VSMOW + 364 X.R18_VSMOW = R18_VSMOW + 365 X.LAMBDA_17 = LAMBDA_17 + 366 X.R18_VPDB = R18_VPDB + 367 X.R17_VPDB = R17_VSMOW * (R18_VPDB / R18_VSMOW)**LAMBDA_17 + 368 + 369 R45wg, R46wg, R47wg, R48wg, R49wg = X.compute_isobar_ratios( + 370 R13 = R13_VPDB * (1 + d13Cwg_VPDB/1000), + 371 R18 = R18_VSMOW * (1 + d18Owg_VSMOW/1000), + 372 ) + 373 R45, R46, R47, R48, R49 = X.compute_isobar_ratios( + 374 R13 = R13_VPDB * (1 + d13C_VPDB/1000), + 375 R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION, + 376 D17O=D17O, D47=D47, D48=D48, D49=D49, + 377 ) + 378 R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = X.compute_isobar_ratios( + 379 R13 = R13_VPDB * (1 + d13C_VPDB/1000), + 380 R18 = R18_VPDB * (1 + d18O_VPDB/1000) * ALPHA_18O_ACID_REACTION, + 381 D17O=D17O, + 382 ) + 383 + 384 d45 = 1000 * (R45/R45wg - 1) + 385 d46 = 1000 * (R46/R46wg - 1) + 386 d47 = 1000 * (R47/R47wg - 1) + 387 d48 = 1000 * (R48/R48wg - 1) + 388 d49 = 1000 * (R49/R49wg - 1) + 389 + 390 for k in range(3): # dumb iteration to adjust for small changes in d47 + 391 R47raw = (1 + (a47 * D47 + b47 * d47 + c47)/1000) * R47stoch + 392 R48raw = (1 + (a48 * D48 + b48 * d48 + c48)/1000) * R48stoch + 393 d47 = 1000 * (R47raw/R47wg - 1) + 394 d48 = 1000 * (R48raw/R48wg - 1) + 395 + 396 return dict( + 397 Sample = sample, + 398 D17O = D17O, + 399 d13Cwg_VPDB = d13Cwg_VPDB, + 400 d18Owg_VSMOW = d18Owg_VSMOW, + 401 d45 = d45, + 402 d46 = d46, + 403 d47 = d47, + 404 d48 = d48, + 405 d49 = d49, + 406 ) + 407 + 408 + 409def virtual_data( + 410 samples = [], + 411 a47 = 1., b47 = 0., c47 = -0.9, + 412 a48 = 1., b48 = 0., c48 = -0.45, + 413 rD47 = 0.015, rD48 = 0.045, + 414 d13Cwg_VPDB = None, d18Owg_VSMOW = None, + 415 session = None, + 416 Nominal_D47 = None, Nominal_D48 = None, + 417 Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None, + 418 ALPHA_18O_ACID_REACTION = None, + 419 R13_VPDB = None, + 420 R17_VSMOW = None, + 421 R18_VSMOW = None, + 422 LAMBDA_17 = None, + 423 R18_VPDB = None, + 424 seed = 0, + 425 ): + 426 ''' + 427 Return list with simulated analyses from a single session. + 428 + 429 **Parameters** + 430 + 431 + `samples`: a list of entries; each entry is a dictionary with the following fields: + 432 * `Sample`: the name of the sample + 433 * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample + 434 * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample + 435 * `N`: how many analyses to generate for this sample + 436 + `a47`: scrambling factor for Δ47 + 437 + `b47`: compositional nonlinearity for Δ47 + 438 + `c47`: working gas offset for Δ47 + 439 + `a48`: scrambling factor for Δ48 + 440 + `b48`: compositional nonlinearity for Δ48 + 441 + `c48`: working gas offset for Δ48 + 442 + `rD47`: analytical repeatability of Δ47 + 443 + `rD48`: analytical repeatability of Δ48 + 444 + `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas + 445 (by default equal to the `simulate_single_analysis` default values) + 446 + `session`: name of the session (no name by default) + 447 + `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values + 448 if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults) + 449 + `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and + 450 δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified + 451 (by default equal to the `simulate_single_analysis` defaults) + 452 + `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor + 453 (by default equal to the `simulate_single_analysis` defaults) + 454 + `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17 + 455 correction parameters (by default equal to the `simulate_single_analysis` default) + 456 + `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations + 457 + 458 + 459 Here is an example of using this method to generate an arbitrary combination of + 460 anchors and unknowns for a bunch of sessions: + 461 + 462 ```py + 463 args = dict( + 464 samples = [ + 465 dict(Sample = 'ETH-1', N = 4), + 466 dict(Sample = 'ETH-2', N = 5), + 467 dict(Sample = 'ETH-3', N = 6), + 468 dict(Sample = 'FOO', N = 2, + 469 d13C_VPDB = -5., d18O_VPDB = -10., + 470 D47 = 0.3, D48 = 0.15), + 471 ], rD47 = 0.010, rD48 = 0.030) + 472 + 473 session1 = virtual_data(session = 'Session_01', **args, seed = 123) + 474 session2 = virtual_data(session = 'Session_02', **args, seed = 1234) + 475 session3 = virtual_data(session = 'Session_03', **args, seed = 12345) + 476 session4 = virtual_data(session = 'Session_04', **args, seed = 123456) + 477 + 478 D = D47data(session1 + session2 + session3 + session4) + 479 + 480 D.crunch() + 481 D.standardize() + 482 + 483 D.table_of_sessions(verbose = True, save_to_file = False) + 484 D.table_of_samples(verbose = True, save_to_file = False) + 485 D.table_of_analyses(verbose = True, save_to_file = False) + 486 ``` + 487 + 488 This should output something like: + 489 + 490 ``` + 491 [table_of_sessions] + 492 –––––––––– –– –– ––––––––––– –––––––––––– –––––– –––––– –––––– ––––––––––––– –––––––––––––– –––––––––––––– + 493 Session Na Nu d13Cwg_VPDB d18Owg_VSMOW r_d13C r_d18O r_D47 a ± SE 1e3 x b ± SE c ± SE + 494 –––––––––– –– –– ––––––––––– –––––––––––– –––––– –––––– –––––– ––––––––––––– –––––––––––––– –––––––––––––– + 495 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 + 496 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 + 497 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 + 498 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 + 499 –––––––––– –– –– ––––––––––– –––––––––––– –––––– –––––– –––––– ––––––––––––– –––––––––––––– –––––––––––––– + 500 + 501 [table_of_samples] + 502 –––––– –– ––––––––– –––––––––– –––––– –––––– –––––––– –––––– –––––––– + 503 Sample N d13C_VPDB d18O_VSMOW D47 SE 95% CL SD p_Levene + 504 –––––– –– ––––––––– –––––––––– –––––– –––––– –––––––– –––––– –––––––– + 505 ETH-1 16 2.02 37.02 0.2052 0.0079 + 506 ETH-2 20 -10.17 19.88 0.2085 0.0100 + 507 ETH-3 24 1.71 37.45 0.6132 0.0105 + 508 FOO 8 -5.00 28.91 0.2989 0.0040 ± 0.0080 0.0101 0.638 + 509 –––––– –– ––––––––– –––––––––– –––––– –––––– –––––––– –––––– –––––––– + 510 + 511 [table_of_analyses] + 512 ––– –––––––––– –––––– ––––––––––– –––––––––––– ––––––––– ––––––––– –––––––––– –––––––––– –––––––––– –––––––––– –––––––––– ––––––––– ––––––––– ––––––––– –––––––– + 513 UID Session Sample d13Cwg_VPDB d18Owg_VSMOW d45 d46 d47 d48 d49 d13C_VPDB d18O_VSMOW D47raw D48raw D49raw D47 + 514 ––– –––––––––– –––––– ––––––––––– –––––––––––– ––––––––– ––––––––– –––––––––– –––––––––– –––––––––– –––––––––– –––––––––– ––––––––– ––––––––– ––––––––– –––––––– + 515 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 + 516 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 + 517 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 + 518 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 + 519 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 + 520 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 + 521 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 + 522 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 + 523 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 + 524 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 + 525 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 + 526 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 + 527 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 + 528 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 + 529 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 + 530 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 + 531 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 + 532 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 + 533 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 + 534 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 + 535 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 + 536 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 + 537 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 + 538 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 + 539 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 + 540 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 + 541 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 + 542 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 + 543 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 + 544 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 + 545 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 + 546 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 + 547 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 + 548 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 + 549 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 + 550 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 + 551 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 + 552 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 + 553 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 + 554 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 + 555 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 + 556 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 + 557 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 + 558 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 + 559 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 + 560 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 + 561 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 + 562 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 + 563 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 + 564 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 + 565 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 + 566 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 + 567 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 + 568 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 + 569 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 + 570 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 + 571 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 + 572 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 + 573 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 + 574 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 + 575 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 + 576 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 + 577 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 + 578 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 + 579 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 + 580 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 + 581 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 + 582 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 + 583 ––– –––––––––– –––––– ––––––––––– –––––––––––– ––––––––– ––––––––– –––––––––– –––––––––– –––––––––– –––––––––– –––––––––– ––––––––– ––––––––– ––––––––– –––––––– + 584 ``` + 585 ''' + 586 + 587 kwargs = locals().copy() + 588 + 589 from numpy import random as nprandom + 590 if seed: + 591 rng = nprandom.default_rng(seed) + 592 else: + 593 rng = nprandom.default_rng() + 594 + 595 N = sum([s['N'] for s in samples]) + 596 errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors + 597 errors47 *= rD47 / stdev(errors47) # scale errors to rD47 + 598 errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors + 599 errors48 *= rD48 / stdev(errors48) # scale errors to rD48 + 600 + 601 k = 0 + 602 out = [] + 603 for s in samples: + 604 kw = {} + 605 kw['sample'] = s['Sample'] + 606 kw = { + 607 **kw, + 608 **{var: kwargs[var] + 609 for var in [ + 610 'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION', + 611 'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB', + 612 'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB', + 613 'a47', 'b47', 'c47', 'a48', 'b48', 'c48', + 614 ] + 615 if kwargs[var] is not None}, + 616 **{var: s[var] + 617 for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O'] + 618 if var in s}, + 619 } + 620 + 621 sN = s['N'] + 622 while sN: + 623 out.append(simulate_single_analysis(**kw)) + 624 out[-1]['d47'] += errors47[k] * a47 + 625 out[-1]['d48'] += errors48[k] * a48 + 626 sN -= 1 + 627 k += 1 + 628 + 629 if session is not None: + 630 for r in out: + 631 r['Session'] = session + 632 return out + 633 + 634def table_of_samples( + 635 data47 = None, + 636 data48 = None, + 637 dir = 'output', + 638 filename = None, + 639 save_to_file = True, + 640 print_out = True, + 641 output = None, + 642 ): + 643 ''' + 644 Print out, save to disk and/or return a combined table of samples + 645 for a pair of `D47data` and `D48data` objects. + 646 + 647 **Parameters** + 648 + 649 + `data47`: `D47data` instance + 650 + `data48`: `D48data` instance + 651 + `dir`: the directory in which to save the table + 652 + `filename`: the name to the csv file to write to + 653 + `save_to_file`: whether to save the table to disk + 654 + `print_out`: whether to print out the table + 655 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); + 656 if set to `'raw'`: return a list of list of strings + 657 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`) + 658 ''' + 659 if data47 is None: + 660 if data48 is None: + 661 raise TypeError("Arguments must include at least one D47data() or D48data() instance.") + 662 else: + 663 return data48.table_of_samples( + 664 dir = dir, + 665 filename = filename, + 666 save_to_file = save_to_file, + 667 print_out = print_out, + 668 output = output + 669 ) + 670 else: + 671 if data48 is None: + 672 return data47.table_of_samples( + 673 dir = dir, + 674 filename = filename, + 675 save_to_file = save_to_file, + 676 print_out = print_out, + 677 output = output + 678 ) + 679 else: + 680 out47 = data47.table_of_samples(save_to_file = False, print_out = False, output = 'raw') + 681 out48 = data48.table_of_samples(save_to_file = False, print_out = False, output = 'raw') + 682 out = transpose_table(transpose_table(out47) + transpose_table(out48)[4:]) + 683 + 684 if save_to_file: + 685 if not os.path.exists(dir): + 686 os.makedirs(dir) + 687 if filename is None: + 688 filename = f'D47D48_samples.csv' + 689 with open(f'{dir}/{filename}', 'w') as fid: + 690 fid.write(make_csv(out)) + 691 if print_out: + 692 print('\n'+pretty_table(out)) + 693 if output == 'raw': + 694 return out + 695 elif output == 'pretty': + 696 return pretty_table(out) + 697 + 698 + 699def table_of_sessions( + 700 data47 = None, + 701 data48 = None, + 702 dir = 'output', + 703 filename = None, + 704 save_to_file = True, + 705 print_out = True, + 706 output = None, + 707 ): + 708 ''' + 709 Print out, save to disk and/or return a combined table of sessions + 710 for a pair of `D47data` and `D48data` objects. + 711 ***Only applicable if the sessions in `data47` and those in `data48` + 712 consist of the exact same sets of analyses.*** + 713 + 714 **Parameters** + 715 + 716 + `data47`: `D47data` instance + 717 + `data48`: `D48data` instance + 718 + `dir`: the directory in which to save the table + 719 + `filename`: the name to the csv file to write to + 720 + `save_to_file`: whether to save the table to disk + 721 + `print_out`: whether to print out the table + 722 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); + 723 if set to `'raw'`: return a list of list of strings + 724 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`) + 725 ''' + 726 if data47 is None: + 727 if data48 is None: + 728 raise TypeError("Arguments must include at least one D47data() or D48data() instance.") + 729 else: + 730 return data48.table_of_sessions( + 731 dir = dir, + 732 filename = filename, + 733 save_to_file = save_to_file, + 734 print_out = print_out, + 735 output = output + 736 ) + 737 else: + 738 if data48 is None: + 739 return data47.table_of_sessions( + 740 dir = dir, + 741 filename = filename, + 742 save_to_file = save_to_file, + 743 print_out = print_out, + 744 output = output + 745 ) + 746 else: + 747 out47 = data47.table_of_sessions(save_to_file = False, print_out = False, output = 'raw') + 748 out48 = data48.table_of_sessions(save_to_file = False, print_out = False, output = 'raw') + 749 for k,x in enumerate(out47[0]): + 750 if k>7: + 751 out47[0][k] = out47[0][k].replace('a', 'a_47').replace('b', 'b_47').replace('c', 'c_47') + 752 out48[0][k] = out48[0][k].replace('a', 'a_48').replace('b', 'b_48').replace('c', 'c_48') + 753 out = transpose_table(transpose_table(out47) + transpose_table(out48)[7:]) + 754 + 755 if save_to_file: + 756 if not os.path.exists(dir): + 757 os.makedirs(dir) + 758 if filename is None: + 759 filename = f'D47D48_sessions.csv' + 760 with open(f'{dir}/{filename}', 'w') as fid: + 761 fid.write(make_csv(out)) + 762 if print_out: + 763 print('\n'+pretty_table(out)) + 764 if output == 'raw': + 765 return out + 766 elif output == 'pretty': + 767 return pretty_table(out) + 768 + 769 + 770def table_of_analyses( + 771 data47 = None, + 772 data48 = None, + 773 dir = 'output', + 774 filename = None, + 775 save_to_file = True, + 776 print_out = True, + 777 output = None, + 778 ): + 779 ''' + 780 Print out, save to disk and/or return a combined table of analyses + 781 for a pair of `D47data` and `D48data` objects. + 782 + 783 If the sessions in `data47` and those in `data48` do not consist of + 784 the exact same sets of analyses, the table will have two columns + 785 `Session_47` and `Session_48` instead of a single `Session` column. + 786 + 787 **Parameters** + 788 + 789 + `data47`: `D47data` instance + 790 + `data48`: `D48data` instance + 791 + `dir`: the directory in which to save the table + 792 + `filename`: the name to the csv file to write to + 793 + `save_to_file`: whether to save the table to disk + 794 + `print_out`: whether to print out the table + 795 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); + 796 if set to `'raw'`: return a list of list of strings + 797 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`) + 798 ''' + 799 if data47 is None: + 800 if data48 is None: + 801 raise TypeError("Arguments must include at least one D47data() or D48data() instance.") + 802 else: + 803 return data48.table_of_analyses( + 804 dir = dir, + 805 filename = filename, + 806 save_to_file = save_to_file, + 807 print_out = print_out, + 808 output = output + 809 ) + 810 else: + 811 if data48 is None: + 812 return data47.table_of_analyses( + 813 dir = dir, + 814 filename = filename, + 815 save_to_file = save_to_file, + 816 print_out = print_out, + 817 output = output + 818 ) + 819 else: + 820 out47 = data47.table_of_analyses(save_to_file = False, print_out = False, output = 'raw') + 821 out48 = data48.table_of_analyses(save_to_file = False, print_out = False, output = 'raw') + 822 + 823 if [l[1] for l in out47[1:]] == [l[1] for l in out48[1:]]: # if sessions are identical + 824 out = transpose_table(transpose_table(out47) + transpose_table(out48)[-1:]) + 825 else: + 826 out47[0][1] = 'Session_47' + 827 out48[0][1] = 'Session_48' + 828 out47 = transpose_table(out47) + 829 out48 = transpose_table(out48) + 830 out = transpose_table(out47[:2] + out48[1:2] + out47[2:] + out48[-1:]) + 831 + 832 if save_to_file: + 833 if not os.path.exists(dir): + 834 os.makedirs(dir) + 835 if filename is None: + 836 filename = f'D47D48_sessions.csv' + 837 with open(f'{dir}/{filename}', 'w') as fid: + 838 fid.write(make_csv(out)) + 839 if print_out: + 840 print('\n'+pretty_table(out)) + 841 if output == 'raw': + 842 return out + 843 elif output == 'pretty': + 844 return pretty_table(out) + 845 + 846 + 847class D4xdata(list): + 848 ''' + 849 Store and process data for a large set of Δ47 and/or Δ48 + 850 analyses, usually comprising more than one analytical session. + 851 ''' + 852 + 853 ### 17O CORRECTION PARAMETERS + 854 R13_VPDB = 0.01118 # (Chang & Li, 1990) + 855 ''' + 856 Absolute (13C/12C) ratio of VPDB. + 857 By default equal to 0.01118 ([Chang & Li, 1990](http://www.cnki.com.cn/Article/CJFDTotal-JXTW199004006.htm)) + 858 ''' + 859 + 860 R18_VSMOW = 0.0020052 # (Baertschi, 1976) + 861 ''' + 862 Absolute (18O/16C) ratio of VSMOW. + 863 By default equal to 0.0020052 ([Baertschi, 1976](https://doi.org/10.1016/0012-821X(76)90115-1)) + 864 ''' + 865 + 866 LAMBDA_17 = 0.528 # (Barkan & Luz, 2005) + 867 ''' + 868 Mass-dependent exponent for triple oxygen isotopes. + 869 By default equal to 0.528 ([Barkan & Luz, 2005](https://doi.org/10.1002/rcm.2250)) + 870 ''' + 871 + 872 R17_VSMOW = 0.00038475 # (Assonov & Brenninkmeijer, 2003, rescaled to R13_VPDB) + 873 ''' + 874 Absolute (17O/16C) ratio of VSMOW. + 875 By default equal to 0.00038475 + 876 ([Assonov & Brenninkmeijer, 2003](https://dx.doi.org/10.1002/rcm.1011), + 877 rescaled to `R13_VPDB`) + 878 ''' + 879 + 880 R18_VPDB = R18_VSMOW * 1.03092 + 881 ''' + 882 Absolute (18O/16C) ratio of VPDB. + 883 By definition equal to `R18_VSMOW * 1.03092`. + 884 ''' + 885 + 886 R17_VPDB = R17_VSMOW * 1.03092 ** LAMBDA_17 + 887 ''' + 888 Absolute (17O/16C) ratio of VPDB. + 889 By definition equal to `R17_VSMOW * 1.03092 ** LAMBDA_17`. + 890 ''' + 891 + 892 LEVENE_REF_SAMPLE = 'ETH-3' + 893 ''' + 894 After the Δ4x standardization step, each sample is tested to + 895 assess whether the Δ4x variance within all analyses for that + 896 sample differs significantly from that observed for a given reference + 897 sample (using [Levene's test](https://en.wikipedia.org/wiki/Levene%27s_test), + 898 which yields a p-value corresponding to the null hypothesis that the + 899 underlying variances are equal). + 900 + 901 `LEVENE_REF_SAMPLE` (by default equal to `'ETH-3'`) specifies which + 902 sample should be used as a reference for this test. + 903 ''' + 904 + 905 ALPHA_18O_ACID_REACTION = round(np.exp(3.59 / (90 + 273.15) - 1.79e-3), 6) # (Kim et al., 2007, calcite) + 906 ''' + 907 Specifies the 18O/16O fractionation factor generally applicable + 908 to acid reactions in the dataset. Currently used by `D4xdata.wg()`, + 909 `D4xdata.standardize_d13C`, and `D4xdata.standardize_d18O`. + 910 + 911 By default equal to 1.008129 (calcite reacted at 90 °C, + 912 [Kim et al., 2007](https://dx.doi.org/10.1016/j.chemgeo.2007.08.005)). + 913 ''' + 914 + 915 Nominal_d13C_VPDB = { + 916 'ETH-1': 2.02, + 917 'ETH-2': -10.17, + 918 'ETH-3': 1.71, + 919 } # (Bernasconi et al., 2018) + 920 ''' + 921 Nominal δ13C_VPDB values assigned to carbonate standards, used by + 922 `D4xdata.standardize_d13C()`. + 923 + 924 By default equal to `{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71}` after + 925 [Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385). + 926 ''' + 927 + 928 Nominal_d18O_VPDB = { + 929 'ETH-1': -2.19, + 930 'ETH-2': -18.69, + 931 'ETH-3': -1.78, + 932 } # (Bernasconi et al., 2018) + 933 ''' + 934 Nominal δ18O_VPDB values assigned to carbonate standards, used by + 935 `D4xdata.standardize_d18O()`. + 936 + 937 By default equal to `{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78}` after + 938 [Bernasconi et al. (2018)](https://doi.org/10.1029/2017GC007385). + 939 ''' + 940 + 941 d13C_STANDARDIZATION_METHOD = '2pt' + 942 ''' + 943 Method by which to standardize δ13C values: + 944 + 945 + `none`: do not apply any δ13C standardization. + 946 + `'1pt'`: within each session, offset all initial δ13C values so as to + 947 minimize the difference between final δ13C_VPDB values and + 948 `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` is defined). + 949 + `'2pt'`: within each session, apply a affine trasformation to all δ13C + 950 values so as to minimize the difference between final δ13C_VPDB + 951 values and `Nominal_d13C_VPDB` (averaged over all analyses for which `Nominal_d13C_VPDB` + 952 is defined). + 953 ''' + 954 + 955 d18O_STANDARDIZATION_METHOD = '2pt' + 956 ''' + 957 Method by which to standardize δ18O values: + 958 + 959 + `none`: do not apply any δ18O standardization. + 960 + `'1pt'`: within each session, offset all initial δ18O values so as to + 961 minimize the difference between final δ18O_VPDB values and + 962 `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` is defined). + 963 + `'2pt'`: within each session, apply a affine trasformation to all δ18O + 964 values so as to minimize the difference between final δ18O_VPDB + 965 values and `Nominal_d18O_VPDB` (averaged over all analyses for which `Nominal_d18O_VPDB` + 966 is defined). + 967 ''' + 968 + 969 def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False): + 970 ''' + 971 **Parameters** + 972 + 973 + `l`: a list of dictionaries, with each dictionary including at least the keys + 974 `Sample`, `d45`, `d46`, and `d47` or `d48`. + 975 + `mass`: `'47'` or `'48'` + 976 + `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods. + 977 + `session`: define session name for analyses without a `Session` key + 978 + `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods. + 979 + 980 Returns a `D4xdata` object derived from `list`. + 981 ''' + 982 self._4x = mass + 983 self.verbose = verbose + 984 self.prefix = 'D4xdata' + 985 self.logfile = logfile + 986 list.__init__(self, l) + 987 self.Nf = None + 988 self.repeatability = {} + 989 self.refresh(session = session) + 990 + 991 + 992 def make_verbal(oldfun): + 993 ''' + 994 Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`. + 995 ''' + 996 @wraps(oldfun) + 997 def newfun(*args, verbose = '', **kwargs): + 998 myself = args[0] + 999 oldprefix = myself.prefix +1000 myself.prefix = oldfun.__name__ +1001 if verbose != '': +1002 oldverbose = myself.verbose +1003 myself.verbose = verbose +1004 out = oldfun(*args, **kwargs) +1005 myself.prefix = oldprefix +1006 if verbose != '': +1007 myself.verbose = oldverbose +1008 return out +1009 return newfun +1010 +1011 +1012 def msg(self, txt): +1013 ''' +1014 Log a message to `self.logfile`, and print it out if `verbose = True` +1015 ''' +1016 self.log(txt) +1017 if self.verbose: +1018 print(f'{f"[{self.prefix}]":<16} {txt}') +1019 +1020 +1021 def vmsg(self, txt): +1022 ''' +1023 Log a message to `self.logfile` and print it out +1024 ''' +1025 self.log(txt) +1026 print(txt) +1027 +1028 +1029 def log(self, *txts): +1030 ''' +1031 Log a message to `self.logfile` +1032 ''' +1033 if self.logfile: +1034 with open(self.logfile, 'a') as fid: +1035 for txt in txts: +1036 fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}') +1037 +1038 +1039 def refresh(self, session = 'mySession'): +1040 ''' +1041 Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`. +1042 ''' +1043 self.fill_in_missing_info(session = session) +1044 self.refresh_sessions() +1045 self.refresh_samples() +1046 +1047 +1048 def refresh_sessions(self): +1049 ''' +1050 Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift` +1051 to `False` for all sessions. +1052 ''' +1053 self.sessions = { +1054 s: {'data': [r for r in self if r['Session'] == s]} +1055 for s in sorted({r['Session'] for r in self}) +1056 } +1057 for s in self.sessions: +1058 self.sessions[s]['scrambling_drift'] = False +1059 self.sessions[s]['slope_drift'] = False +1060 self.sessions[s]['wg_drift'] = False +1061 self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD +1062 self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD +1063 +1064 +1065 def refresh_samples(self): +1066 ''' +1067 Define `self.samples`, `self.anchors`, and `self.unknowns`. +1068 ''' +1069 self.samples = { +1070 s: {'data': [r for r in self if r['Sample'] == s]} +1071 for s in sorted({r['Sample'] for r in self}) +1072 } +1073 self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x} +1074 self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x} +1075 +1076 +1077 def read(self, filename, sep = '', session = ''): +1078 ''' +1079 Read file in csv format to load data into a `D47data` object. +1080 +1081 In the csv file, spaces before and after field separators (`','` by default) +1082 are optional. Each line corresponds to a single analysis. +1083 +1084 The required fields are: +1085 +1086 + `UID`: a unique identifier +1087 + `Session`: an identifier for the analytical session +1088 + `Sample`: a sample identifier +1089 + `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values +1090 +1091 Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to +1092 VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48` +1093 and `d49` are optional, and set to NaN by default. +1094 +1095 **Parameters** +1096 +1097 + `fileneme`: the path of the file to read +1098 + `sep`: csv separator delimiting the fields +1099 + `session`: set `Session` field to this string for all analyses +1100 ''' +1101 with open(filename) as fid: +1102 self.input(fid.read(), sep = sep, session = session) +1103 +1104 +1105 def input(self, txt, sep = '', session = ''): +1106 ''' +1107 Read `txt` string in csv format to load analysis data into a `D47data` object. +1108 +1109 In the csv string, spaces before and after field separators (`','` by default) +1110 are optional. Each line corresponds to a single analysis. +1111 +1112 The required fields are: +1113 +1114 + `UID`: a unique identifier +1115 + `Session`: an identifier for the analytical session +1116 + `Sample`: a sample identifier +1117 + `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values +1118 +1119 Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to +1120 VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48` +1121 and `d49` are optional, and set to NaN by default. +1122 +1123 **Parameters** +1124 +1125 + `txt`: the csv string to read +1126 + `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`, +1127 whichever appers most often in `txt`. +1128 + `session`: set `Session` field to this string for all analyses +1129 ''' +1130 if sep == '': +1131 sep = sorted(',;\t', key = lambda x: - txt.count(x))[0] +1132 txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()] +1133 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:]] +1134 +1135 if session != '': +1136 for r in data: +1137 r['Session'] = session +1138 +1139 self += data +1140 self.refresh() +1141 +1142 +1143 @make_verbal +1144 def wg(self, samples = None, a18_acid = None): +1145 ''' +1146 Compute bulk composition of the working gas for each session based on +1147 the carbonate standards defined in both `self.Nominal_d13C_VPDB` and +1148 `self.Nominal_d18O_VPDB`. +1149 ''' +1150 +1151 self.msg('Computing WG composition:') +1152 +1153 if a18_acid is None: +1154 a18_acid = self.ALPHA_18O_ACID_REACTION +1155 if samples is None: +1156 samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB] +1157 +1158 assert a18_acid, f'Acid fractionation factor should not be zero.' +1159 +1160 samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB] +1161 R45R46_standards = {} +1162 for sample in samples: +1163 d13C_vpdb = self.Nominal_d13C_VPDB[sample] +1164 d18O_vpdb = self.Nominal_d18O_VPDB[sample] +1165 R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000) +1166 R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17 +1167 R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid +1168 +1169 C12_s = 1 / (1 + R13_s) +1170 C13_s = R13_s / (1 + R13_s) +1171 C16_s = 1 / (1 + R17_s + R18_s) +1172 C17_s = R17_s / (1 + R17_s + R18_s) +1173 C18_s = R18_s / (1 + R17_s + R18_s) +1174 +1175 C626_s = C12_s * C16_s ** 2 +1176 C627_s = 2 * C12_s * C16_s * C17_s +1177 C628_s = 2 * C12_s * C16_s * C18_s +1178 C636_s = C13_s * C16_s ** 2 +1179 C637_s = 2 * C13_s * C16_s * C17_s +1180 C727_s = C12_s * C17_s ** 2 +1181 +1182 R45_s = (C627_s + C636_s) / C626_s +1183 R46_s = (C628_s + C637_s + C727_s) / C626_s +1184 R45R46_standards[sample] = (R45_s, R46_s) +1185 +1186 for s in self.sessions: +1187 db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples] +1188 assert db, f'No sample from {samples} found in session "{s}".' +1189# dbsamples = sorted({r['Sample'] for r in db}) +1190 +1191 X = [r['d45'] for r in db] +1192 Y = [R45R46_standards[r['Sample']][0] for r in db] +1193 x1, x2 = np.min(X), np.max(X) +1194 +1195 if x1 < x2: +1196 wgcoord = x1/(x1-x2) +1197 else: +1198 wgcoord = 999 +1199 +1200 if wgcoord < -.5 or wgcoord > 1.5: +1201 # unreasonable to extrapolate to d45 = 0 +1202 R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)]) +1203 else : +1204 # d45 = 0 is reasonably well bracketed +1205 R45_wg = np.polyfit(X, Y, 1)[1] +1206 +1207 X = [r['d46'] for r in db] +1208 Y = [R45R46_standards[r['Sample']][1] for r in db] +1209 x1, x2 = np.min(X), np.max(X) +1210 +1211 if x1 < x2: +1212 wgcoord = x1/(x1-x2) +1213 else: +1214 wgcoord = 999 +1215 +1216 if wgcoord < -.5 or wgcoord > 1.5: +1217 # unreasonable to extrapolate to d46 = 0 +1218 R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)]) +1219 else : +1220 # d46 = 0 is reasonably well bracketed +1221 R46_wg = np.polyfit(X, Y, 1)[1] +1222 +1223 d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg) +1224 +1225 self.msg(f'Session {s} WG: δ13C_VPDB = {d13Cwg_VPDB:.3f} δ18O_VSMOW = {d18Owg_VSMOW:.3f}') +1226 +1227 self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB +1228 self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW +1229 for r in self.sessions[s]['data']: +1230 r['d13Cwg_VPDB'] = d13Cwg_VPDB +1231 r['d18Owg_VSMOW'] = d18Owg_VSMOW +1232 +1233 +1234 def compute_bulk_delta(self, R45, R46, D17O = 0): +1235 ''' +1236 Compute δ13C_VPDB and δ18O_VSMOW, +1237 by solving the generalized form of equation (17) from +1238 [Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05), +1239 assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and +1240 solving the corresponding second-order Taylor polynomial. +1241 (Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014)) +1242 ''' +1243 +1244 K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17 +1245 +1246 A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17) +1247 B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17 +1248 C = 2 * self.R18_VSMOW +1249 D = -R46 +1250 +1251 aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2 +1252 bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C +1253 cc = A + B + C + D +1254 +1255 d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa) +1256 +1257 R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW +1258 R17 = K * R18 ** self.LAMBDA_17 +1259 R13 = R45 - 2 * R17 +1260 +1261 d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1) +1262 +1263 return d13C_VPDB, d18O_VSMOW +1264 +1265 +1266 @make_verbal +1267 def crunch(self, verbose = ''): +1268 ''' +1269 Compute bulk composition and raw clumped isotope anomalies for all analyses. +1270 ''' +1271 for r in self: +1272 self.compute_bulk_and_clumping_deltas(r) +1273 self.standardize_d13C() +1274 self.standardize_d18O() +1275 self.msg(f"Crunched {len(self)} analyses.") +1276 +1277 +1278 def fill_in_missing_info(self, session = 'mySession'): +1279 ''' +1280 Fill in optional fields with default values +1281 ''' +1282 for i,r in enumerate(self): +1283 if 'D17O' not in r: +1284 r['D17O'] = 0. +1285 if 'UID' not in r: +1286 r['UID'] = f'{i+1}' +1287 if 'Session' not in r: +1288 r['Session'] = session +1289 for k in ['d47', 'd48', 'd49']: +1290 if k not in r: +1291 r[k] = np.nan +1292 +1293 +1294 def standardize_d13C(self): +1295 ''' +1296 Perform δ13C standadization within each session `s` according to +1297 `self.sessions[s]['d13C_standardization_method']`, which is defined by default +1298 by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but +1299 may be redefined abitrarily at a later stage. +1300 ''' +1301 for s in self.sessions: +1302 if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']: +1303 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] +1304 X,Y = zip(*XY) +1305 if self.sessions[s]['d13C_standardization_method'] == '1pt': +1306 offset = np.mean(Y) - np.mean(X) +1307 for r in self.sessions[s]['data']: +1308 r['d13C_VPDB'] += offset +1309 elif self.sessions[s]['d13C_standardization_method'] == '2pt': +1310 a,b = np.polyfit(X,Y,1) +1311 for r in self.sessions[s]['data']: +1312 r['d13C_VPDB'] = a * r['d13C_VPDB'] + b +1313 +1314 def standardize_d18O(self): +1315 ''' +1316 Perform δ18O standadization within each session `s` according to +1317 `self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`, +1318 which is defined by default by `D47data.refresh_sessions()`as equal to +1319 `self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage. +1320 ''' +1321 for s in self.sessions: +1322 if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']: +1323 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] +1324 X,Y = zip(*XY) +1325 Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y] +1326 if self.sessions[s]['d18O_standardization_method'] == '1pt': +1327 offset = np.mean(Y) - np.mean(X) +1328 for r in self.sessions[s]['data']: +1329 r['d18O_VSMOW'] += offset +1330 elif self.sessions[s]['d18O_standardization_method'] == '2pt': +1331 a,b = np.polyfit(X,Y,1) +1332 for r in self.sessions[s]['data']: +1333 r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b +1334 +1335 +1336 def compute_bulk_and_clumping_deltas(self, r): +1337 ''' +1338 Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`. +1339 ''' +1340 +1341 # Compute working gas R13, R18, and isobar ratios +1342 R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000) +1343 R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000) +1344 R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg) +1345 +1346 # Compute analyte isobar ratios +1347 R45 = (1 + r['d45'] / 1000) * R45_wg +1348 R46 = (1 + r['d46'] / 1000) * R46_wg +1349 R47 = (1 + r['d47'] / 1000) * R47_wg +1350 R48 = (1 + r['d48'] / 1000) * R48_wg +1351 R49 = (1 + r['d49'] / 1000) * R49_wg +1352 +1353 r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O']) +1354 R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB +1355 R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW +1356 +1357 # Compute stochastic isobar ratios of the analyte +1358 R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios( +1359 R13, R18, D17O = r['D17O'] +1360 ) +1361 +1362 # Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1, +1363 # and raise a warning if the corresponding anomalies exceed 0.02 ppm. +1364 if (R45 / R45stoch - 1) > 5e-8: +1365 self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm') +1366 if (R46 / R46stoch - 1) > 5e-8: +1367 self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm') +1368 +1369 # Compute raw clumped isotope anomalies +1370 r['D47raw'] = 1000 * (R47 / R47stoch - 1) +1371 r['D48raw'] = 1000 * (R48 / R48stoch - 1) +1372 r['D49raw'] = 1000 * (R49 / R49stoch - 1) +1373 +1374 +1375 def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0): +1376 ''' +1377 Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`, +1378 optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope +1379 anomalies (`D47`, `D48`, `D49`), all expressed in permil. +1380 ''' +1381 +1382 # Compute R17 +1383 R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17 +1384 +1385 # Compute isotope concentrations +1386 C12 = (1 + R13) ** -1 +1387 C13 = C12 * R13 +1388 C16 = (1 + R17 + R18) ** -1 +1389 C17 = C16 * R17 +1390 C18 = C16 * R18 +1391 +1392 # Compute stochastic isotopologue concentrations +1393 C626 = C16 * C12 * C16 +1394 C627 = C16 * C12 * C17 * 2 +1395 C628 = C16 * C12 * C18 * 2 +1396 C636 = C16 * C13 * C16 +1397 C637 = C16 * C13 * C17 * 2 +1398 C638 = C16 * C13 * C18 * 2 +1399 C727 = C17 * C12 * C17 +1400 C728 = C17 * C12 * C18 * 2 +1401 C737 = C17 * C13 * C17 +1402 C738 = C17 * C13 * C18 * 2 +1403 C828 = C18 * C12 * C18 +1404 C838 = C18 * C13 * C18 +1405 +1406 # Compute stochastic isobar ratios +1407 R45 = (C636 + C627) / C626 +1408 R46 = (C628 + C637 + C727) / C626 +1409 R47 = (C638 + C728 + C737) / C626 +1410 R48 = (C738 + C828) / C626 +1411 R49 = C838 / C626 +1412 +1413 # Account for stochastic anomalies +1414 R47 *= 1 + D47 / 1000 +1415 R48 *= 1 + D48 / 1000 +1416 R49 *= 1 + D49 / 1000 +1417 +1418 # Return isobar ratios +1419 return R45, R46, R47, R48, R49 +1420 +1421 +1422 def split_samples(self, samples_to_split = 'all', grouping = 'by_session'): +1423 ''' +1424 Split unknown samples by UID (treat all analyses as different samples) +1425 or by session (treat analyses of a given sample in different sessions as +1426 different samples). +1427 +1428 **Parameters** +1429 +1430 + `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']` +1431 + `grouping`: `by_uid` | `by_session` +1432 ''' +1433 if samples_to_split == 'all': +1434 samples_to_split = [s for s in self.unknowns] +1435 gkeys = {'by_uid':'UID', 'by_session':'Session'} +1436 self.grouping = grouping.lower() +1437 if self.grouping in gkeys: +1438 gkey = gkeys[self.grouping] +1439 for r in self: +1440 if r['Sample'] in samples_to_split: +1441 r['Sample_original'] = r['Sample'] +1442 r['Sample'] = f"{r['Sample']}__{r[gkey]}" +1443 elif r['Sample'] in self.unknowns: +1444 r['Sample_original'] = r['Sample'] +1445 self.refresh_samples() +1446 +1447 +1448 def unsplit_samples(self, tables = False): +1449 ''' +1450 Reverse the effects of `D47data.split_samples()`. +1451 +1452 This should only be used after `D4xdata.standardize()` with `method='pooled'`. +1453 +1454 After `D4xdata.standardize()` with `method='indep_sessions'`, one should +1455 probably use `D4xdata.combine_samples()` instead to reverse the effects of +1456 `D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the +1457 effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in +1458 that case session-averaged Δ4x values are statistically independent). +1459 ''' +1460 unknowns_old = sorted({s for s in self.unknowns}) +1461 CM_old = self.standardization.covar[:,:] +1462 VD_old = self.standardization.params.valuesdict().copy() +1463 vars_old = self.standardization.var_names +1464 +1465 unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r}) +1466 +1467 Ns = len(vars_old) - len(unknowns_old) +1468 vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new] +1469 VD_new = {k: VD_old[k] for k in vars_old[:Ns]} +1470 +1471 W = np.zeros((len(vars_new), len(vars_old))) +1472 W[:Ns,:Ns] = np.eye(Ns) +1473 for u in unknowns_new: +1474 splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u}) +1475 if self.grouping == 'by_session': +1476 weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits] +1477 elif self.grouping == 'by_uid': +1478 weights = [1 for s in splits] +1479 sw = sum(weights) +1480 weights = [w/sw for w in weights] +1481 W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:] +1482 +1483 CM_new = W @ CM_old @ W.T +1484 V = W @ np.array([[VD_old[k]] for k in vars_old]) +1485 VD_new = {k:v[0] for k,v in zip(vars_new, V)} +1486 +1487 self.standardization.covar = CM_new +1488 self.standardization.params.valuesdict = lambda : VD_new +1489 self.standardization.var_names = vars_new +1490 +1491 for r in self: +1492 if r['Sample'] in self.unknowns: +1493 r['Sample_split'] = r['Sample'] +1494 r['Sample'] = r['Sample_original'] +1495 +1496 self.refresh_samples() +1497 self.consolidate_samples() +1498 self.repeatabilities() +1499 +1500 if tables: +1501 self.table_of_analyses() +1502 self.table_of_samples() +1503 +1504 def assign_timestamps(self): +1505 ''' +1506 Assign a time field `t` of type `float` to each analysis. +1507 +1508 If `TimeTag` is one of the data fields, `t` is equal within a given session +1509 to `TimeTag` minus the mean value of `TimeTag` for that session. +1510 Otherwise, `TimeTag` is by default equal to the index of each analysis +1511 in the dataset and `t` is defined as above. +1512 ''' +1513 for session in self.sessions: +1514 sdata = self.sessions[session]['data'] +1515 try: +1516 t0 = np.mean([r['TimeTag'] for r in sdata]) +1517 for r in sdata: +1518 r['t'] = r['TimeTag'] - t0 +1519 except KeyError: +1520 t0 = (len(sdata)-1)/2 +1521 for t,r in enumerate(sdata): +1522 r['t'] = t - t0 +1523 +1524 +1525 def report(self): +1526 ''' +1527 Prints a report on the standardization fit. +1528 Only applicable after `D4xdata.standardize(method='pooled')`. +1529 ''' +1530 report_fit(self.standardization) +1531 +1532 +1533 def combine_samples(self, sample_groups): +1534 ''' +1535 Combine analyses of different samples to compute weighted average Δ4x +1536 and new error (co)variances corresponding to the groups defined by the `sample_groups` +1537 dictionary. +1538 +1539 Caution: samples are weighted by number of replicate analyses, which is a +1540 reasonable default behavior but is not always optimal (e.g., in the case of strongly +1541 correlated analytical errors for one or more samples). +1542 +1543 Returns a tuplet of: +1544 +1545 + the list of group names +1546 + an array of the corresponding Δ4x values +1547 + the corresponding (co)variance matrix +1548 +1549 **Parameters** +1550 +1551 + `sample_groups`: a dictionary of the form: +1552 ```py +1553 {'group1': ['sample_1', 'sample_2'], +1554 'group2': ['sample_3', 'sample_4', 'sample_5']} +1555 ``` +1556 ''' +1557 +1558 samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])] +1559 groups = sorted(sample_groups.keys()) +1560 group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups} +1561 D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples]) +1562 CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples]) +1563 W = np.array([ +1564 [self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples] +1565 for j in groups]) +1566 D4x_new = W @ D4x_old +1567 CM_new = W @ CM_old @ W.T +1568 +1569 return groups, D4x_new[:,0], CM_new +1570 +1571 +1572 @make_verbal +1573 def standardize(self, +1574 method = 'pooled', +1575 weighted_sessions = [], +1576 consolidate = True, +1577 consolidate_tables = False, +1578 consolidate_plots = False, +1579 constraints = {}, +1580 ): +1581 ''' +1582 Compute absolute Δ4x values for all replicate analyses and for sample averages. +1583 If `method` argument is set to `'pooled'`, the standardization processes all sessions +1584 in a single step, assuming that all samples (anchors and unknowns alike) are homogeneous, +1585 i.e. that their true Δ4x value does not change between sessions, +1586 ([Daëron, 2021](https://doi.org/10.1029/2020GC009592)). If `method` argument is set to +1587 `'indep_sessions'`, the standardization processes each session independently, based only +1588 on anchors analyses. +1589 ''' +1590 +1591 self.standardization_method = method +1592 self.assign_timestamps() +1593 +1594 if method == 'pooled': +1595 if weighted_sessions: +1596 for session_group in weighted_sessions: +1597 if self._4x == '47': +1598 X = D47data([r for r in self if r['Session'] in session_group]) +1599 elif self._4x == '48': +1600 X = D48data([r for r in self if r['Session'] in session_group]) +1601 X.Nominal_D4x = self.Nominal_D4x.copy() +1602 X.refresh() +1603 result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False) +1604 w = np.sqrt(result.redchi) +1605 self.msg(f'Session group {session_group} MRSWD = {w:.4f}') +1606 for r in X: +1607 r[f'wD{self._4x}raw'] *= w +1608 else: +1609 self.msg(f'All D{self._4x}raw weights set to 1 ‰') +1610 for r in self: +1611 r[f'wD{self._4x}raw'] = 1. +1612 +1613 params = Parameters() +1614 for k,session in enumerate(self.sessions): +1615 self.msg(f"Session {session}: scrambling_drift is {self.sessions[session]['scrambling_drift']}.") +1616 self.msg(f"Session {session}: slope_drift is {self.sessions[session]['slope_drift']}.") +1617 self.msg(f"Session {session}: wg_drift is {self.sessions[session]['wg_drift']}.") +1618 s = pf(session) +1619 params.add(f'a_{s}', value = 0.9) +1620 params.add(f'b_{s}', value = 0.) +1621 params.add(f'c_{s}', value = -0.9) +1622 params.add(f'a2_{s}', value = 0., vary = self.sessions[session]['scrambling_drift']) +1623 params.add(f'b2_{s}', value = 0., vary = self.sessions[session]['slope_drift']) +1624 params.add(f'c2_{s}', value = 0., vary = self.sessions[session]['wg_drift']) +1625 for sample in self.unknowns: +1626 params.add(f'D{self._4x}_{pf(sample)}', value = 0.5) +1627 +1628 for k in constraints: +1629 params[k].expr = constraints[k] +1630 +1631 def residuals(p): +1632 R = [] +1633 for r in self: +1634 session = pf(r['Session']) +1635 sample = pf(r['Sample']) +1636 if r['Sample'] in self.Nominal_D4x: +1637 R += [ ( +1638 r[f'D{self._4x}raw'] - ( +1639 p[f'a_{session}'] * self.Nominal_D4x[r['Sample']] +1640 + p[f'b_{session}'] * r[f'd{self._4x}'] +1641 + p[f'c_{session}'] +1642 + r['t'] * ( +1643 p[f'a2_{session}'] * self.Nominal_D4x[r['Sample']] +1644 + p[f'b2_{session}'] * r[f'd{self._4x}'] +1645 + p[f'c2_{session}'] +1646 ) +1647 ) +1648 ) / r[f'wD{self._4x}raw'] ] +1649 else: +1650 R += [ ( +1651 r[f'D{self._4x}raw'] - ( +1652 p[f'a_{session}'] * p[f'D{self._4x}_{sample}'] +1653 + p[f'b_{session}'] * r[f'd{self._4x}'] +1654 + p[f'c_{session}'] +1655 + r['t'] * ( +1656 p[f'a2_{session}'] * p[f'D{self._4x}_{sample}'] +1657 + p[f'b2_{session}'] * r[f'd{self._4x}'] +1658 + p[f'c2_{session}'] +1659 ) +1660 ) +1661 ) / r[f'wD{self._4x}raw'] ] +1662 return R +1663 +1664 M = Minimizer(residuals, params) +1665 result = M.least_squares() +1666 self.Nf = result.nfree +1667 self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf) +1668# if self.verbose: +1669# report_fit(result) +1670 +1671 for r in self: +1672 s = pf(r["Session"]) +1673 a = result.params.valuesdict()[f'a_{s}'] +1674 b = result.params.valuesdict()[f'b_{s}'] +1675 c = result.params.valuesdict()[f'c_{s}'] +1676 a2 = result.params.valuesdict()[f'a2_{s}'] +1677 b2 = result.params.valuesdict()[f'b2_{s}'] +1678 c2 = result.params.valuesdict()[f'c2_{s}'] +1679 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']) +1680 +1681 self.standardization = result +1682 +1683 for session in self.sessions: +1684 self.sessions[session]['Np'] = 3 +1685 for k in ['scrambling', 'slope', 'wg']: +1686 if self.sessions[session][f'{k}_drift']: +1687 self.sessions[session]['Np'] += 1 +1688 +1689 if consolidate: +1690 self.consolidate(tables = consolidate_tables, plots = consolidate_plots) +1691 return result +1692 +1693 +1694 elif method == 'indep_sessions': +1695 +1696 if weighted_sessions: +1697 for session_group in weighted_sessions: +1698 X = D4xdata([r for r in self if r['Session'] in session_group], mass = self._4x) +1699 X.Nominal_D4x = self.Nominal_D4x.copy() +1700 X.refresh() +1701 # This is only done to assign r['wD47raw'] for r in X: +1702 X.standardize(method = method, weighted_sessions = [], consolidate = False) +1703 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}') +1704 else: +1705 self.msg('All weights set to 1 ‰') +1706 for r in self: +1707 r[f'wD{self._4x}raw'] = 1 +1708 +1709 for session in self.sessions: +1710 s = self.sessions[session] +1711 p_names = ['a', 'b', 'c', 'a2', 'b2', 'c2'] +1712 p_active = [True, True, True, s['scrambling_drift'], s['slope_drift'], s['wg_drift']] +1713 s['Np'] = sum(p_active) +1714 sdata = s['data'] +1715 +1716 A = np.array([ +1717 [ +1718 self.Nominal_D4x[r['Sample']] / r[f'wD{self._4x}raw'], +1719 r[f'd{self._4x}'] / r[f'wD{self._4x}raw'], +1720 1 / r[f'wD{self._4x}raw'], +1721 self.Nominal_D4x[r['Sample']] * r['t'] / r[f'wD{self._4x}raw'], +1722 r[f'd{self._4x}'] * r['t'] / r[f'wD{self._4x}raw'], +1723 r['t'] / r[f'wD{self._4x}raw'] +1724 ] +1725 for r in sdata if r['Sample'] in self.anchors +1726 ])[:,p_active] # only keep columns for the active parameters +1727 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]) +1728 s['Na'] = Y.size +1729 CM = linalg.inv(A.T @ A) +1730 bf = (CM @ A.T @ Y).T[0,:] +1731 k = 0 +1732 for n,a in zip(p_names, p_active): +1733 if a: +1734 s[n] = bf[k] +1735# self.msg(f'{n} = {bf[k]}') +1736 k += 1 +1737 else: +1738 s[n] = 0. +1739# self.msg(f'{n} = 0.0') +1740 +1741 for r in sdata : +1742 a, b, c, a2, b2, c2 = s['a'], s['b'], s['c'], s['a2'], s['b2'], s['c2'] +1743 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']) +1744 r[f'wD{self._4x}'] = r[f'wD{self._4x}raw'] / (a + a2 * r['t']) +1745 +1746 s['CM'] = np.zeros((6,6)) +1747 i = 0 +1748 k_active = [j for j,a in enumerate(p_active) if a] +1749 for j,a in enumerate(p_active): +1750 if a: +1751 s['CM'][j,k_active] = CM[i,:] +1752 i += 1 +1753 +1754 if not weighted_sessions: +1755 w = self.rmswd()['rmswd'] +1756 for r in self: +1757 r[f'wD{self._4x}'] *= w +1758 r[f'wD{self._4x}raw'] *= w +1759 for session in self.sessions: +1760 self.sessions[session]['CM'] *= w**2 +1761 +1762 for session in self.sessions: +1763 s = self.sessions[session] +1764 s['SE_a'] = s['CM'][0,0]**.5 +1765 s['SE_b'] = s['CM'][1,1]**.5 +1766 s['SE_c'] = s['CM'][2,2]**.5 +1767 s['SE_a2'] = s['CM'][3,3]**.5 +1768 s['SE_b2'] = s['CM'][4,4]**.5 +1769 s['SE_c2'] = s['CM'][5,5]**.5 +1770 +1771 if not weighted_sessions: +1772 self.Nf = len(self) - len(self.unknowns) - np.sum([self.sessions[s]['Np'] for s in self.sessions]) +1773 else: +1774 self.Nf = 0 +1775 for sg in weighted_sessions: +1776 self.Nf += self.rmswd(sessions = sg)['Nf'] +1777 +1778 self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf) +1779 +1780 avgD4x = { +1781 sample: np.mean([r[f'D{self._4x}'] for r in self if r['Sample'] == sample]) +1782 for sample in self.samples +1783 } +1784 chi2 = np.sum([(r[f'D{self._4x}'] - avgD4x[r['Sample']])**2 for r in self]) +1785 rD4x = (chi2/self.Nf)**.5 +1786 self.repeatability[f'sigma_{self._4x}'] = rD4x +1787 +1788 if consolidate: +1789 self.consolidate(tables = consolidate_tables, plots = consolidate_plots) +1790 +1791 +1792 def standardization_error(self, session, d4x, D4x, t = 0): +1793 ''' +1794 Compute standardization error for a given session and +1795 (δ47, Δ47) composition. +1796 ''' +1797 a = self.sessions[session]['a'] +1798 b = self.sessions[session]['b'] +1799 c = self.sessions[session]['c'] +1800 a2 = self.sessions[session]['a2'] +1801 b2 = self.sessions[session]['b2'] +1802 c2 = self.sessions[session]['c2'] +1803 CM = self.sessions[session]['CM'] +1804 +1805 x, y = D4x, d4x +1806 z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t +1807# x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t) +1808 dxdy = -(b+b2*t) / (a+a2*t) +1809 dxdz = 1. / (a+a2*t) +1810 dxda = -x / (a+a2*t) +1811 dxdb = -y / (a+a2*t) +1812 dxdc = -1. / (a+a2*t) +1813 dxda2 = -x * a2 / (a+a2*t) +1814 dxdb2 = -y * t / (a+a2*t) +1815 dxdc2 = -t / (a+a2*t) +1816 V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2]) +1817 sx = (V @ CM @ V.T) ** .5 +1818 return sx +1819 +1820 +1821 @make_verbal +1822 def summary(self, +1823 dir = 'output', +1824 filename = None, +1825 save_to_file = True, +1826 print_out = True, +1827 ): +1828 ''' +1829 Print out an/or save to disk a summary of the standardization results. +1830 +1831 **Parameters** +1832 +1833 + `dir`: the directory in which to save the table +1834 + `filename`: the name to the csv file to write to +1835 + `save_to_file`: whether to save the table to disk +1836 + `print_out`: whether to print out the table +1837 ''' +1838 +1839 out = [] +1840 out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]] +1841 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])})"]] +1842 out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]] +1843 out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]] +1844 out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]] +1845 out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]] +1846 out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]] +1847 out += [['Model degrees of freedom', f"{self.Nf}"]] +1848 out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]] +1849 out += [['Standardization method', self.standardization_method]] +1850 +1851 if save_to_file: +1852 if not os.path.exists(dir): +1853 os.makedirs(dir) +1854 if filename is None: +1855 filename = f'D{self._4x}_summary.csv' +1856 with open(f'{dir}/{filename}', 'w') as fid: +1857 fid.write(make_csv(out)) +1858 if print_out: +1859 self.msg('\n' + pretty_table(out, header = 0)) +1860 +1861 +1862 @make_verbal +1863 def table_of_sessions(self, +1864 dir = 'output', +1865 filename = None, +1866 save_to_file = True, +1867 print_out = True, +1868 output = None, +1869 ): +1870 ''' +1871 Print out an/or save to disk a table of sessions. +1872 +1873 **Parameters** +1874 +1875 + `dir`: the directory in which to save the table +1876 + `filename`: the name to the csv file to write to +1877 + `save_to_file`: whether to save the table to disk +1878 + `print_out`: whether to print out the table +1879 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); +1880 if set to `'raw'`: return a list of list of strings +1881 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`) +1882 ''' +1883 include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions]) +1884 include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions]) +1885 include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions]) +1886 +1887 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']] +1888 if include_a2: +1889 out[-1] += ['a2 ± SE'] +1890 if include_b2: +1891 out[-1] += ['b2 ± SE'] +1892 if include_c2: +1893 out[-1] += ['c2 ± SE'] +1894 for session in self.sessions: +1895 out += [[ +1896 session, +1897 f"{self.sessions[session]['Na']}", +1898 f"{self.sessions[session]['Nu']}", +1899 f"{self.sessions[session]['d13Cwg_VPDB']:.3f}", +1900 f"{self.sessions[session]['d18Owg_VSMOW']:.3f}", +1901 f"{self.sessions[session]['r_d13C_VPDB']:.4f}", +1902 f"{self.sessions[session]['r_d18O_VSMOW']:.4f}", +1903 f"{self.sessions[session][f'r_D{self._4x}']:.4f}", +1904 f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}", +1905 f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}", +1906 f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}", +1907 ]] +1908 if include_a2: +1909 if self.sessions[session]['scrambling_drift']: +1910 out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"] +1911 else: +1912 out[-1] += [''] +1913 if include_b2: +1914 if self.sessions[session]['slope_drift']: +1915 out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"] +1916 else: +1917 out[-1] += [''] +1918 if include_c2: +1919 if self.sessions[session]['wg_drift']: +1920 out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"] +1921 else: +1922 out[-1] += [''] +1923 +1924 if save_to_file: +1925 if not os.path.exists(dir): +1926 os.makedirs(dir) +1927 if filename is None: +1928 filename = f'D{self._4x}_sessions.csv' +1929 with open(f'{dir}/{filename}', 'w') as fid: +1930 fid.write(make_csv(out)) +1931 if print_out: +1932 self.msg('\n' + pretty_table(out)) +1933 if output == 'raw': +1934 return out +1935 elif output == 'pretty': +1936 return pretty_table(out) +1937 +1938 +1939 @make_verbal +1940 def table_of_analyses( +1941 self, +1942 dir = 'output', +1943 filename = None, +1944 save_to_file = True, +1945 print_out = True, +1946 output = None, +1947 ): +1948 ''' +1949 Print out an/or save to disk a table of analyses. +1950 +1951 **Parameters** +1952 +1953 + `dir`: the directory in which to save the table +1954 + `filename`: the name to the csv file to write to +1955 + `save_to_file`: whether to save the table to disk +1956 + `print_out`: whether to print out the table +1957 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); +1958 if set to `'raw'`: return a list of list of strings +1959 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`) +1960 ''' +1961 +1962 out = [['UID','Session','Sample']] +1963 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}] +1964 for f in extra_fields: +1965 out[-1] += [f[0]] +1966 out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}'] +1967 for r in self: +1968 out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]] +1969 for f in extra_fields: +1970 out[-1] += [f"{r[f[0]]:{f[1]}}"] +1971 out[-1] += [ +1972 f"{r['d13Cwg_VPDB']:.3f}", +1973 f"{r['d18Owg_VSMOW']:.3f}", +1974 f"{r['d45']:.6f}", +1975 f"{r['d46']:.6f}", +1976 f"{r['d47']:.6f}", +1977 f"{r['d48']:.6f}", +1978 f"{r['d49']:.6f}", +1979 f"{r['d13C_VPDB']:.6f}", +1980 f"{r['d18O_VSMOW']:.6f}", +1981 f"{r['D47raw']:.6f}", +1982 f"{r['D48raw']:.6f}", +1983 f"{r['D49raw']:.6f}", +1984 f"{r[f'D{self._4x}']:.6f}" +1985 ] +1986 if save_to_file: +1987 if not os.path.exists(dir): +1988 os.makedirs(dir) +1989 if filename is None: +1990 filename = f'D{self._4x}_analyses.csv' +1991 with open(f'{dir}/{filename}', 'w') as fid: +1992 fid.write(make_csv(out)) +1993 if print_out: +1994 self.msg('\n' + pretty_table(out)) +1995 return out +1996 +1997 @make_verbal +1998 def covar_table( +1999 self, +2000 correl = False, +2001 dir = 'output', +2002 filename = None, +2003 save_to_file = True, +2004 print_out = True, +2005 output = None, +2006 ): +2007 ''' +2008 Print out, save to disk and/or return the variance-covariance matrix of D4x +2009 for all unknown samples. +2010 +2011 **Parameters** +2012 +2013 + `dir`: the directory in which to save the csv +2014 + `filename`: the name of the csv file to write to +2015 + `save_to_file`: whether to save the csv +2016 + `print_out`: whether to print out the matrix +2017 + `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`); +2018 if set to `'raw'`: return a list of list of strings +2019 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`) +2020 ''' +2021 samples = sorted([u for u in self.unknowns]) +2022 out = [[''] + samples] +2023 for s1 in samples: +2024 out.append([s1]) +2025 for s2 in samples: +2026 if correl: +2027 out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}') +2028 else: +2029 out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}') +2030 +2031 if save_to_file: +2032 if not os.path.exists(dir): +2033 os.makedirs(dir) +2034 if filename is None: +2035 if correl: +2036 filename = f'D{self._4x}_correl.csv' +2037 else: +2038 filename = f'D{self._4x}_covar.csv' +2039 with open(f'{dir}/{filename}', 'w') as fid: +2040 fid.write(make_csv(out)) +2041 if print_out: +2042 self.msg('\n'+pretty_table(out)) +2043 if output == 'raw': +2044 return out +2045 elif output == 'pretty': +2046 return pretty_table(out) +2047 +2048 @make_verbal +2049 def table_of_samples( +2050 self, +2051 dir = 'output', +2052 filename = None, +2053 save_to_file = True, +2054 print_out = True, +2055 output = None, +2056 ): +2057 ''' +2058 Print out, save to disk and/or return a table of samples. +2059 +2060 **Parameters** +2061 +2062 + `dir`: the directory in which to save the csv +2063 + `filename`: the name of the csv file to write to +2064 + `save_to_file`: whether to save the csv +2065 + `print_out`: whether to print out the table +2066 + `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`); +2067 if set to `'raw'`: return a list of list of strings +2068 (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`) +2069 ''' +2070 +2071 out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']] +2072 for sample in self.anchors: +2073 out += [[ +2074 f"{sample}", +2075 f"{self.samples[sample]['N']}", +2076 f"{self.samples[sample]['d13C_VPDB']:.2f}", +2077 f"{self.samples[sample]['d18O_VSMOW']:.2f}", +2078 f"{self.samples[sample][f'D{self._4x}']:.4f}",'','', +2079 f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', '' +2080 ]] +2081 for sample in self.unknowns: +2082 out += [[ +2083 f"{sample}", +2084 f"{self.samples[sample]['N']}", +2085 f"{self.samples[sample]['d13C_VPDB']:.2f}", +2086 f"{self.samples[sample]['d18O_VSMOW']:.2f}", +2087 f"{self.samples[sample][f'D{self._4x}']:.4f}", +2088 f"{self.samples[sample][f'SE_D{self._4x}']:.4f}", +2089 f"± {self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}", +2090 f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', +2091 f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else '' +2092 ]] +2093 if save_to_file: +2094 if not os.path.exists(dir): +2095 os.makedirs(dir) +2096 if filename is None: +2097 filename = f'D{self._4x}_samples.csv' +2098 with open(f'{dir}/{filename}', 'w') as fid: +2099 fid.write(make_csv(out)) +2100 if print_out: +2101 self.msg('\n'+pretty_table(out)) +2102 if output == 'raw': +2103 return out +2104 elif output == 'pretty': +2105 return pretty_table(out) +2106 +2107 +2108 def plot_sessions(self, dir = 'output', figsize = (8,8)): +2109 ''' +2110 Generate session plots and save them to disk. +2111 +2112 **Parameters** +2113 +2114 + `dir`: the directory in which to save the plots +2115 + `figsize`: the width and height (in inches) of each plot +2116 ''' +2117 if not os.path.exists(dir): +2118 os.makedirs(dir) +2119 +2120 for session in self.sessions: +2121 sp = self.plot_single_session(session, xylimits = 'constant') +2122 ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf') +2123 ppl.close(sp.fig) +2124 +2125 +2126 @make_verbal +2127 def consolidate_samples(self): +2128 ''' +2129 Compile various statistics for each sample. +2130 +2131 For each anchor sample: +2132 +2133 + `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x` +2134 + `SE_D47` or `SE_D48`: set to zero by definition +2135 +2136 For each unknown sample: +2137 +2138 + `D47` or `D48`: the standardized Δ4x value for this unknown +2139 + `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown +2140 +2141 For each anchor and unknown: +2142 +2143 + `N`: the total number of analyses of this sample +2144 + `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample +2145 + `d13C_VPDB`: the average δ13C_VPDB value for this sample +2146 + `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2) +2147 + `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal +2148 variance, indicating whether the Δ4x repeatability this sample differs significantly from +2149 that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`. +2150 ''' +2151 D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']] +2152 for sample in self.samples: +2153 self.samples[sample]['N'] = len(self.samples[sample]['data']) +2154 if self.samples[sample]['N'] > 1: +2155 self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']]) +2156 +2157 self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']]) +2158 self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']]) +2159 +2160 D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']] +2161 if len(D4x_pop) > 2: +2162 self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1] +2163 +2164 if self.standardization_method == 'pooled': +2165 for sample in self.anchors: +2166 self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample] +2167 self.samples[sample][f'SE_D{self._4x}'] = 0. +2168 for sample in self.unknowns: +2169 self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}'] +2170 try: +2171 self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5 +2172 except ValueError: +2173 # when `sample` is constrained by self.standardize(constraints = {...}), +2174 # it is no longer listed in self.standardization.var_names. +2175 # Temporary fix: define SE as zero for now +2176 self.samples[sample][f'SE_D4{self._4x}'] = 0. +2177 +2178 elif self.standardization_method == 'indep_sessions': +2179 for sample in self.anchors: +2180 self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample] +2181 self.samples[sample][f'SE_D{self._4x}'] = 0. +2182 for sample in self.unknowns: +2183 self.msg(f'Consolidating sample {sample}') +2184 self.unknowns[sample][f'session_D{self._4x}'] = {} +2185 session_avg = [] +2186 for session in self.sessions: +2187 sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample] +2188 if sdata: +2189 self.msg(f'{sample} found in session {session}') +2190 avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata]) +2191 avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata]) +2192 # !! TODO: sigma_s below does not account for temporal changes in standardization error +2193 sigma_s = self.standardization_error(session, avg_d4x, avg_D4x) +2194 sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5 +2195 session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5]) +2196 self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1] +2197 self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg)) +2198 weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']} +2199 wsum = sum([weights[s] for s in weights]) +2200 for s in weights: +2201 self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum] +2202 +2203 +2204 def consolidate_sessions(self): +2205 ''' +2206 Compute various statistics for each session. +2207 +2208 + `Na`: Number of anchor analyses in the session +2209 + `Nu`: Number of unknown analyses in the session +2210 + `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session +2211 + `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session +2212 + `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session +2213 + `a`: scrambling factor +2214 + `b`: compositional slope +2215 + `c`: WG offset +2216 + `SE_a`: Model stadard erorr of `a` +2217 + `SE_b`: Model stadard erorr of `b` +2218 + `SE_c`: Model stadard erorr of `c` +2219 + `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`) +2220 + `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`) +2221 + `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`) +2222 + `a2`: scrambling factor drift +2223 + `b2`: compositional slope drift +2224 + `c2`: WG offset drift +2225 + `Np`: Number of standardization parameters to fit +2226 + `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`) +2227 + `d13Cwg_VPDB`: δ13C_VPDB of WG +2228 + `d18Owg_VSMOW`: δ18O_VSMOW of WG +2229 ''' +2230 for session in self.sessions: +2231 if 'd13Cwg_VPDB' not in self.sessions[session]: +2232 self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB'] +2233 if 'd18Owg_VSMOW' not in self.sessions[session]: +2234 self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW'] +2235 self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors]) +2236 self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns]) +2237 +2238 self.msg(f'Computing repeatabilities for session {session}') +2239 self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session]) +2240 self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session]) +2241 self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session]) +2242 +2243 if self.standardization_method == 'pooled': +2244 for session in self.sessions: +2245 +2246 self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}'] +2247 i = self.standardization.var_names.index(f'a_{pf(session)}') +2248 self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5 +2249 +2250 self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}'] +2251 i = self.standardization.var_names.index(f'b_{pf(session)}') +2252 self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5 +2253 +2254 self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}'] +2255 i = self.standardization.var_names.index(f'c_{pf(session)}') +2256 self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5 +2257 +2258 self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}'] +2259 if self.sessions[session]['scrambling_drift']: +2260 i = self.standardization.var_names.index(f'a2_{pf(session)}') +2261 self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5 +2262 else: +2263 self.sessions[session]['SE_a2'] = 0. +2264 +2265 self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}'] +2266 if self.sessions[session]['slope_drift']: +2267 i = self.standardization.var_names.index(f'b2_{pf(session)}') +2268 self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5 +2269 else: +2270 self.sessions[session]['SE_b2'] = 0. +2271 +2272 self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}'] +2273 if self.sessions[session]['wg_drift']: +2274 i = self.standardization.var_names.index(f'c2_{pf(session)}') +2275 self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5 +2276 else: +2277 self.sessions[session]['SE_c2'] = 0. +2278 +2279 i = self.standardization.var_names.index(f'a_{pf(session)}') +2280 j = self.standardization.var_names.index(f'b_{pf(session)}') +2281 k = self.standardization.var_names.index(f'c_{pf(session)}') +2282 CM = np.zeros((6,6)) +2283 CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]] +2284 try: +2285 i2 = self.standardization.var_names.index(f'a2_{pf(session)}') +2286 CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]] +2287 CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2] +2288 try: +2289 j2 = self.standardization.var_names.index(f'b2_{pf(session)}') +2290 CM[3,4] = self.standardization.covar[i2,j2] +2291 CM[4,3] = self.standardization.covar[j2,i2] +2292 except ValueError: +2293 pass +2294 try: +2295 k2 = self.standardization.var_names.index(f'c2_{pf(session)}') +2296 CM[3,5] = self.standardization.covar[i2,k2] +2297 CM[5,3] = self.standardization.covar[k2,i2] +2298 except ValueError: +2299 pass +2300 except ValueError: +2301 pass +2302 try: +2303 j2 = self.standardization.var_names.index(f'b2_{pf(session)}') +2304 CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]] +2305 CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2] +2306 try: +2307 k2 = self.standardization.var_names.index(f'c2_{pf(session)}') +2308 CM[4,5] = self.standardization.covar[j2,k2] +2309 CM[5,4] = self.standardization.covar[k2,j2] +2310 except ValueError: +2311 pass +2312 except ValueError: +2313 pass +2314 try: +2315 k2 = self.standardization.var_names.index(f'c2_{pf(session)}') +2316 CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]] +2317 CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2] +2318 except ValueError: +2319 pass +2320 +2321 self.sessions[session]['CM'] = CM +2322 +2323 elif self.standardization_method == 'indep_sessions': +2324 pass # Not implemented yet +2325 +2326 +2327 @make_verbal +2328 def repeatabilities(self): +2329 ''' +2330 Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x +2331 (for all samples, for anchors, and for unknowns). +2332 ''' +2333 self.msg('Computing reproducibilities for all sessions') +2334 +2335 self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors') +2336 self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors') +2337 self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors') +2338 self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns') +2339 self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples') +2340 +2341 +2342 @make_verbal +2343 def consolidate(self, tables = True, plots = True): +2344 ''' +2345 Collect information about samples, sessions and repeatabilities. +2346 ''' +2347 self.consolidate_samples() +2348 self.consolidate_sessions() +2349 self.repeatabilities() +2350 +2351 if tables: +2352 self.summary() +2353 self.table_of_sessions() +2354 self.table_of_analyses() +2355 self.table_of_samples() +2356 +2357 if plots: +2358 self.plot_sessions() +2359 +2360 +2361 @make_verbal +2362 def rmswd(self, +2363 samples = 'all samples', +2364 sessions = 'all sessions', +2365 ): +2366 ''' +2367 Compute the χ2, root mean squared weighted deviation +2368 (i.e. reduced χ2), and corresponding degrees of freedom of the +2369 Δ4x values for samples in `samples` and sessions in `sessions`. +2370 +2371 Only used in `D4xdata.standardize()` with `method='indep_sessions'`. +2372 ''' +2373 if samples == 'all samples': +2374 mysamples = [k for k in self.samples] +2375 elif samples == 'anchors': +2376 mysamples = [k for k in self.anchors] +2377 elif samples == 'unknowns': +2378 mysamples = [k for k in self.unknowns] +2379 else: +2380 mysamples = samples +2381 +2382 if sessions == 'all sessions': +2383 sessions = [k for k in self.sessions] +2384 +2385 chisq, Nf = 0, 0 +2386 for sample in mysamples : +2387 G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ] +2388 if len(G) > 1 : +2389 X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G]) +2390 Nf += (len(G) - 1) +2391 chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G]) +2392 r = (chisq / Nf)**.5 if Nf > 0 else 0 +2393 self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.') +2394 return {'rmswd': r, 'chisq': chisq, 'Nf': Nf} +2395 +2396 +2397 @make_verbal +2398 def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'): +2399 ''' +2400 Compute the repeatability of `[r[key] for r in self]` +2401 ''' +2402 # NB: it's debatable whether rD47 should be computed +2403 # with Nf = len(self)-len(self.samples) instead of +2404 # Nf = len(self) - len(self.unknwons) - 3*len(self.sessions) +2405 +2406 if samples == 'all samples': +2407 mysamples = [k for k in self.samples] +2408 elif samples == 'anchors': +2409 mysamples = [k for k in self.anchors] +2410 elif samples == 'unknowns': +2411 mysamples = [k for k in self.unknowns] +2412 else: +2413 mysamples = samples +2414 +2415 if sessions == 'all sessions': +2416 sessions = [k for k in self.sessions] +2417 +2418 if key in ['D47', 'D48']: +2419 chisq, Nf = 0, 0 +2420 for sample in mysamples : +2421 X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ] +2422 if len(X) > 1 : +2423 chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ]) +2424 if sample in self.unknowns: +2425 Nf += len(X) - 1 +2426 else: +2427 Nf += len(X) +2428 if samples in ['anchors', 'all samples']: +2429 Nf -= sum([self.sessions[s]['Np'] for s in sessions]) +2430 r = (chisq / Nf)**.5 if Nf > 0 else 0 +2431 +2432 else: # if key not in ['D47', 'D48'] +2433 chisq, Nf = 0, 0 +2434 for sample in mysamples : +2435 X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ] +2436 if len(X) > 1 : +2437 Nf += len(X) - 1 +2438 chisq += np.sum([ (x-np.mean(X))**2 for x in X ]) +2439 r = (chisq / Nf)**.5 if Nf > 0 else 0 +2440 +2441 self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.') +2442 return r +2443 +2444 def sample_average(self, samples, weights = 'equal', normalize = True): +2445 ''' +2446 Weighted average Δ4x value of a group of samples, accounting for covariance. +2447 +2448 Returns the weighed average Δ4x value and associated SE +2449 of a group of samples. Weights are equal by default. If `normalize` is +2450 true, `weights` will be rescaled so that their sum equals 1. +2451 +2452 **Examples** +2453 +2454 ```python +2455 self.sample_average(['X','Y'], [1, 2]) +2456 ``` +2457 +2458 returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3, +2459 where Δ4x(X) and Δ4x(Y) are the average Δ4x +2460 values of samples X and Y, respectively. +2461 +2462 ```python +2463 self.sample_average(['X','Y'], [1, -1], normalize = False) +2464 ``` +2465 +2466 returns the value and SE of the difference Δ4x(X) - Δ4x(Y). +2467 ''' +2468 if weights == 'equal': +2469 weights = [1/len(samples)] * len(samples) +2470 +2471 if normalize: +2472 s = sum(weights) +2473 if s: +2474 weights = [w/s for w in weights] +2475 +2476 try: +2477# indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples] +2478# C = self.standardization.covar[indices,:][:,indices] +2479 C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples]) +2480 X = [self.samples[sample][f'D{self._4x}'] for sample in samples] +2481 return correlated_sum(X, C, weights) +2482 except ValueError: +2483 return (0., 0.) +2484 +2485 +2486 def sample_D4x_covar(self, sample1, sample2 = None): +2487 ''' +2488 Covariance between Δ4x values of samples +2489 +2490 Returns the error covariance between the average Δ4x values of two +2491 samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`), +2492 returns the Δ4x variance for that sample. +2493 ''' +2494 if sample2 is None: +2495 sample2 = sample1 +2496 if self.standardization_method == 'pooled': +2497 i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}') +2498 j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}') +2499 return self.standardization.covar[i, j] +2500 elif self.standardization_method == 'indep_sessions': +2501 if sample1 == sample2: +2502 return self.samples[sample1][f'SE_D{self._4x}']**2 +2503 else: +2504 c = 0 +2505 for session in self.sessions: +2506 sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1] +2507 sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2] +2508 if sdata1 and sdata2: +2509 a = self.sessions[session]['a'] +2510 # !! TODO: CM below does not account for temporal changes in standardization parameters +2511 CM = self.sessions[session]['CM'][:3,:3] +2512 avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1]) +2513 avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1]) +2514 avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2]) +2515 avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2]) +2516 c += ( +2517 self.unknowns[sample1][f'session_D{self._4x}'][session][2] +2518 * self.unknowns[sample2][f'session_D{self._4x}'][session][2] +2519 * np.array([[avg_D4x_1, avg_d4x_1, 1]]) +2520 @ CM +2521 @ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T +2522 ) / a**2 +2523 return float(c) +2524 +2525 def sample_D4x_correl(self, sample1, sample2 = None): +2526 ''' +2527 Correlation between Δ4x errors of samples +2528 +2529 Returns the error correlation between the average Δ4x values of two samples. +2530 ''' +2531 if sample2 is None or sample2 == sample1: +2532 return 1. +2533 return ( +2534 self.sample_D4x_covar(sample1, sample2) +2535 / self.unknowns[sample1][f'SE_D{self._4x}'] +2536 / self.unknowns[sample2][f'SE_D{self._4x}'] +2537 ) +2538 +2539 def plot_single_session(self, +2540 session, +2541 kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4), +2542 kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4), +2543 kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75), +2544 kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75), +2545 kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75), +2546 xylimits = 'free', # | 'constant' +2547 x_label = None, +2548 y_label = None, +2549 error_contour_interval = 'auto', +2550 fig = 'new', +2551 ): +2552 ''' +2553 Generate plot for a single session +2554 ''' +2555 if x_label is None: +2556 x_label = f'δ$_{{{self._4x}}}$ (‰)' +2557 if y_label is None: +2558 y_label = f'Δ$_{{{self._4x}}}$ (‰)' +2559 +2560 out = _SessionPlot() +2561 anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]] +2562 unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]] +2563 +2564 if fig == 'new': +2565 out.fig = ppl.figure(figsize = (6,6)) +2566 ppl.subplots_adjust(.1,.1,.9,.9) +2567 +2568 out.anchor_analyses, = ppl.plot( +2569 [r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors], +2570 [r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors], +2571 **kw_plot_anchors) +2572 out.unknown_analyses, = ppl.plot( +2573 [r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns], +2574 [r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns], +2575 **kw_plot_unknowns) +2576 out.anchor_avg = ppl.plot( +2577 np.array([ np.array([ +2578 np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1, +2579 np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1 +2580 ]) for sample in anchors]).T, +2581 np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T, +2582 **kw_plot_anchor_avg) +2583 out.unknown_avg = ppl.plot( +2584 np.array([ np.array([ +2585 np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1, +2586 np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1 +2587 ]) for sample in unknowns]).T, +2588 np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T, +2589 **kw_plot_unknown_avg) +2590 if xylimits == 'constant': +2591 x = [r[f'd{self._4x}'] for r in self] +2592 y = [r[f'D{self._4x}'] for r in self] +2593 x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y) +2594 w, h = x2-x1, y2-y1 +2595 x1 -= w/20 +2596 x2 += w/20 +2597 y1 -= h/20 +2598 y2 += h/20 +2599 ppl.axis([x1, x2, y1, y2]) +2600 elif xylimits == 'free': +2601 x1, x2, y1, y2 = ppl.axis() +2602 else: +2603 x1, x2, y1, y2 = ppl.axis(xylimits) +2604 +2605 if error_contour_interval != 'none': +2606 xi, yi = np.linspace(x1, x2), np.linspace(y1, y2) +2607 XI,YI = np.meshgrid(xi, yi) +2608 SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi]) +2609 if error_contour_interval == 'auto': +2610 rng = np.max(SI) - np.min(SI) +2611 if rng <= 0.01: +2612 cinterval = 0.001 +2613 elif rng <= 0.03: +2614 cinterval = 0.004 +2615 elif rng <= 0.1: +2616 cinterval = 0.01 +2617 elif rng <= 0.3: +2618 cinterval = 0.03 +2619 elif rng <= 1.: +2620 cinterval = 0.1 +2621 else: +2622 cinterval = 0.5 +2623 else: +2624 cinterval = error_contour_interval +2625 +2626 cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval) +2627 out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error) +2628 out.clabel = ppl.clabel(out.contour) +2629 +2630 ppl.xlabel(x_label) +2631 ppl.ylabel(y_label) +2632 ppl.title(session, weight = 'bold') +2633 ppl.grid(alpha = .2) +2634 out.ax = ppl.gca() +2635 +2636 return out +2637 +2638 def plot_residuals( +2639 self, +2640 hist = False, +2641 binwidth = 2/3, +2642 dir = 'output', +2643 filename = None, +2644 highlight = [], +2645 colors = None, +2646 figsize = None, +2647 ): +2648 ''' +2649 Plot residuals of each analysis as a function of time (actually, as a function of +2650 the order of analyses in the `D4xdata` object) +2651 +2652 + `hist`: whether to add a histogram of residuals +2653 + `histbins`: specify bin edges for the histogram +2654 + `dir`: the directory in which to save the plot +2655 + `highlight`: a list of samples to highlight +2656 + `colors`: a dict of `{<sample>: <color>}` for all samples +2657 + `figsize`: (width, height) of figure +2658 ''' +2659 # Layout +2660 fig = ppl.figure(figsize = (8,4) if figsize is None else figsize) +2661 if hist: +2662 ppl.subplots_adjust(left = .08, bottom = .05, right = .98, top = .8, wspace = -0.72) +2663 ax1, ax2 = ppl.subplot(121), ppl.subplot(1,15,15) +2664 else: +2665 ppl.subplots_adjust(.08,.05,.78,.8) +2666 ax1 = ppl.subplot(111) +2667 +2668 # Colors +2669 N = len(self.anchors) +2670 if colors is None: +2671 if len(highlight) > 0: +2672 Nh = len(highlight) +2673 if Nh == 1: +2674 colors = {highlight[0]: (0,0,0)} +2675 elif Nh == 3: +2676 colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0)])} +2677 elif Nh == 4: +2678 colors = {a: c for a,c in zip(highlight, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])} +2679 else: +2680 colors = {a: hls_to_rgb(k/Nh, .4, 1) for k,a in enumerate(highlight)} +2681 else: +2682 if N == 3: +2683 colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0)])} +2684 elif N == 4: +2685 colors = {a: c for a,c in zip(self.anchors, [(0,0,1), (1,0,0), (0,2/3,0), (.75,0,.75)])} +2686 else: +2687 colors = {a: hls_to_rgb(k/N, .4, 1) for k,a in enumerate(self.anchors)} +2688 +2689 ppl.sca(ax1) +2690 +2691 ppl.axhline(0, color = 'k', alpha = .25, lw = 0.75) +2692 +2693 session = self[0]['Session'] +2694 x1 = 0 +2695# ymax = np.max([1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self]) +2696 x_sessions = {} +2697 one_or_more_singlets = False +2698 one_or_more_multiplets = False +2699 multiplets = set() +2700 for k,r in enumerate(self): +2701 if r['Session'] != session: +2702 x2 = k-1 +2703 x_sessions[session] = (x1+x2)/2 +2704 ppl.axvline(k - 0.5, color = 'k', lw = .5) +2705 session = r['Session'] +2706 x1 = k +2707 singlet = len(self.samples[r['Sample']]['data']) == 1 +2708 if not singlet: +2709 multiplets.add(r['Sample']) +2710 if r['Sample'] in self.unknowns: +2711 if singlet: +2712 one_or_more_singlets = True +2713 else: +2714 one_or_more_multiplets = True +2715 kw = dict( +2716 marker = 'x' if singlet else '+', +2717 ms = 4 if singlet else 5, +2718 ls = 'None', +2719 mec = colors[r['Sample']] if r['Sample'] in colors else (0,0,0), +2720 mew = 1, +2721 alpha = 0.2 if singlet else 1, +2722 ) +2723 if highlight and r['Sample'] not in highlight: +2724 kw['alpha'] = 0.2 +2725 ppl.plot(k, 1e3 * (r['D47'] - self.samples[r['Sample']]['D47']), **kw) +2726 x2 = k +2727 x_sessions[session] = (x1+x2)/2 +2728 +2729 ppl.axhspan(-self.repeatability['r_D47']*1000, self.repeatability['r_D47']*1000, color = 'k', alpha = .05, lw = 1) +2730 ppl.axhspan(-self.repeatability['r_D47']*1000*self.t95, self.repeatability['r_D47']*1000*self.t95, color = 'k', alpha = .05, lw = 1) +2731 if not hist: +2732 ppl.text(len(self), self.repeatability['r_D47']*1000, f" SD = {self.repeatability['r_D47']*1000:.1f} ppm", size = 9, alpha = 1, va = 'center') +2733 ppl.text(len(self), self.repeatability['r_D47']*1000*self.t95, f" 95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", size = 9, alpha = 1, va = 'center') +2734 +2735 xmin, xmax, ymin, ymax = ppl.axis() +2736 for s in x_sessions: +2737 ppl.text( +2738 x_sessions[s], +2739 ymax +1, +2740 s, +2741 va = 'bottom', +2742 **( +2743 dict(ha = 'center') +2744 if len(self.sessions[s]['data']) > (0.15 * len(self)) +2745 else dict(ha = 'left', rotation = 45) +2746 ) +2747 ) +2748 +2749 if hist: +2750 ppl.sca(ax2) +2751 +2752 for s in colors: +2753 kw['marker'] = '+' +2754 kw['ms'] = 5 +2755 kw['mec'] = colors[s] +2756 kw['label'] = s +2757 kw['alpha'] = 1 +2758 ppl.plot([], [], **kw) +2759 +2760 kw['mec'] = (0,0,0) +2761 +2762 if one_or_more_singlets: +2763 kw['marker'] = 'x' +2764 kw['ms'] = 4 +2765 kw['alpha'] = .2 +2766 kw['label'] = 'other (N$\\,$=$\\,$1)' if one_or_more_multiplets else 'other' +2767 ppl.plot([], [], **kw) +2768 +2769 if one_or_more_multiplets: +2770 kw['marker'] = '+' +2771 kw['ms'] = 4 +2772 kw['alpha'] = 1 +2773 kw['label'] = 'other (N$\\,$>$\\,$1)' if one_or_more_singlets else 'other' +2774 ppl.plot([], [], **kw) +2775 +2776 if hist: +2777 leg = ppl.legend(loc = 'upper right', bbox_to_anchor = (1, 1), bbox_transform=fig.transFigure, borderaxespad = 1.5, fontsize = 9) +2778 else: +2779 leg = ppl.legend(loc = 'lower right', bbox_to_anchor = (1, 0), bbox_transform=fig.transFigure, borderaxespad = 1.5) +2780 leg.set_zorder(-1000) +2781 +2782 ppl.sca(ax1) +2783 +2784 ppl.ylabel('Δ$_{47}$ residuals (ppm)') +2785 ppl.xticks([]) +2786 ppl.axis([-1, len(self), None, None]) +2787 +2788 if hist: +2789 ppl.sca(ax2) +2790 X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets] +2791 ppl.hist( +2792 X, +2793 orientation = 'horizontal', +2794 histtype = 'stepfilled', +2795 ec = [.4]*3, +2796 fc = [.25]*3, +2797 alpha = .25, +2798 bins = np.linspace(-9e3*self.repeatability['r_D47'], 9e3*self.repeatability['r_D47'], int(18/binwidth+1)), +2799 ) +2800 ppl.axis([None, None, ymin, ymax]) +2801 ppl.text(0, 0, +2802 f" SD = {self.repeatability['r_D47']*1000:.1f} ppm\n 95% CL = ± {self.repeatability['r_D47']*1000*self.t95:.1f} ppm", +2803 size = 8, +2804 alpha = 1, +2805 va = 'center', +2806 ha = 'left', +2807 ) +2808 +2809 ppl.xticks([]) +2810 ppl.yticks([]) +2811# ax2.spines['left'].set_visible(False) +2812 ax2.spines['right'].set_visible(False) +2813 ax2.spines['top'].set_visible(False) +2814 ax2.spines['bottom'].set_visible(False) +2815 +2816 +2817 if not os.path.exists(dir): +2818 os.makedirs(dir) +2819 if filename is None: +2820 return fig +2821 elif filename == '': +2822 filename = f'D{self._4x}_residuals.pdf' +2823 ppl.savefig(f'{dir}/{filename}') +2824 ppl.close(fig) +2825 +2826 +2827 def simulate(self, *args, **kwargs): +2828 ''' +2829 Legacy function with warning message pointing to `virtual_data()` +2830 ''' +2831 raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()') +2832 +2833 def plot_distribution_of_analyses( +2834 self, +2835 dir = 'output', +2836 filename = None, +2837 vs_time = False, +2838 figsize = (6,4), +2839 subplots_adjust = (0.02, 0.13, 0.85, 0.8), +2840 output = None, +2841 ): +2842 ''' +2843 Plot temporal distribution of all analyses in the data set. +2844 +2845 **Parameters** +2846 +2847 + `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially. +2848 ''' +2849 +2850 asamples = [s for s in self.anchors] +2851 usamples = [s for s in self.unknowns] +2852 if output is None or output == 'fig': +2853 fig = ppl.figure(figsize = figsize) +2854 ppl.subplots_adjust(*subplots_adjust) +2855 Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)]) +2856 Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)]) +2857 Xmax += (Xmax-Xmin)/40 +2858 Xmin -= (Xmax-Xmin)/41 +2859 for k, s in enumerate(asamples + usamples): +2860 if vs_time: +2861 X = [r['TimeTag'] for r in self if r['Sample'] == s] +2862 else: +2863 X = [x for x,r in enumerate(self) if r['Sample'] == s] +2864 Y = [-k for x in X] +2865 ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75) +2866 ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25) +2867 ppl.text(Xmax, -k, f' {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r') +2868 ppl.axis([Xmin, Xmax, -k-1, 1]) +2869 ppl.xlabel('\ntime') +2870 ppl.gca().annotate('', +2871 xy = (0.6, -0.02), +2872 xycoords = 'axes fraction', +2873 xytext = (.4, -0.02), +2874 arrowprops = dict(arrowstyle = "->", color = 'k'), +2875 ) +2876 +2877 +2878 x2 = -1 +2879 for session in self.sessions: +2880 x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session]) +2881 if vs_time: +2882 ppl.axvline(x1, color = 'k', lw = .75) +2883 if x2 > -1: +2884 if not vs_time: +2885 ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5) +2886 x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session]) +2887# from xlrd import xldate_as_datetime +2888# print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0)) +2889 if vs_time: +2890 ppl.axvline(x2, color = 'k', lw = .75) +2891 ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15) +2892 ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8) +2893 +2894 ppl.xticks([]) +2895 ppl.yticks([]) +2896 +2897 if output is None: +2898 if not os.path.exists(dir): +2899 os.makedirs(dir) +2900 if filename == None: +2901 filename = f'D{self._4x}_distribution_of_analyses.pdf' +2902 ppl.savefig(f'{dir}/{filename}') +2903 ppl.close(fig) +2904 elif output == 'ax': +2905 return ppl.gca() +2906 elif output == 'fig': +2907 return fig +2908 +2909 +2910class D47data(D4xdata): +2911 ''' +2912 Store and process data for a large set of Δ47 analyses, +2913 usually comprising more than one analytical session. +2914 ''' +2915 +2916 Nominal_D4x = { +2917 'ETH-1': 0.2052, +2918 'ETH-2': 0.2085, +2919 'ETH-3': 0.6132, +2920 'ETH-4': 0.4511, +2921 'IAEA-C1': 0.3018, +2922 'IAEA-C2': 0.6409, +2923 'MERCK': 0.5135, +2924 } # I-CDES (Bernasconi et al., 2021) +2925 ''' +2926 Nominal Δ47 values assigned to the Δ47 anchor samples, used by +2927 `D47data.standardize()` to normalize unknown samples to an absolute Δ47 +2928 reference frame. +2929 +2930 By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)): +2931 ```py +2932 { +2933 'ETH-1' : 0.2052, +2934 'ETH-2' : 0.2085, +2935 'ETH-3' : 0.6132, +2936 'ETH-4' : 0.4511, +2937 'IAEA-C1' : 0.3018, +2938 'IAEA-C2' : 0.6409, +2939 'MERCK' : 0.5135, +2940 } +2941 ``` +2942 ''' +2943 +2944 +2945 @property +2946 def Nominal_D47(self): +2947 return self.Nominal_D4x +2948 +2949 +2950 @Nominal_D47.setter +2951 def Nominal_D47(self, new): +2952 self.Nominal_D4x = dict(**new) +2953 self.refresh() +2954 +2955 +2956 def __init__(self, l = [], **kwargs): +2957 ''' +2958 **Parameters:** same as `D4xdata.__init__()` +2959 ''' +2960 D4xdata.__init__(self, l = l, mass = '47', **kwargs) +2961 +2962 +2963 def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'): +2964 ''' +2965 Find all samples for which `Teq` is specified, compute equilibrium Δ47 +2966 value for that temperature, and add treat these samples as additional anchors. +2967 +2968 **Parameters** +2969 +2970 + `fCo2eqD47`: Which CO2 equilibrium law to use +2971 (`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127); +2972 `wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)). +2973 + `priority`: if `replace`: forget old anchors and only use the new ones; +2974 if `new`: keep pre-existing anchors but update them in case of conflict +2975 between old and new Δ47 values; +2976 if `old`: keep pre-existing anchors but preserve their original Δ47 +2977 values in case of conflict. +2978 ''' +2979 f = { +2980 'petersen': fCO2eqD47_Petersen, +2981 'wang': fCO2eqD47_Wang, +2982 }[fCo2eqD47] +2983 foo = {} +2984 for r in self: +2985 if 'Teq' in r: +2986 if r['Sample'] in foo: +2987 assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.' +2988 else: +2989 foo[r['Sample']] = f(r['Teq']) +2990 else: +2991 assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.' +2992 +2993 if priority == 'replace': +2994 self.Nominal_D47 = {} +2995 for s in foo: +2996 if priority != 'old' or s not in self.Nominal_D47: +2997 self.Nominal_D47[s] = foo[s] +2998 +2999 +3000 +3001 +3002class D48data(D4xdata): +3003 ''' +3004 Store and process data for a large set of Δ48 analyses, +3005 usually comprising more than one analytical session. +3006 ''' +3007 +3008 Nominal_D4x = { +3009 'ETH-1': 0.138, +3010 'ETH-2': 0.138, +3011 'ETH-3': 0.270, +3012 'ETH-4': 0.223, +3013 'GU-1': -0.419, +3014 } # (Fiebig et al., 2019, 2021) +3015 ''' +3016 Nominal Δ48 values assigned to the Δ48 anchor samples, used by +3017 `D48data.standardize()` to normalize unknown samples to an absolute Δ48 +3018 reference frame. +3019 +3020 By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019), +3021 Fiebig et al. (in press)): +3022 +3023 ```py +3024 { +3025 'ETH-1' : 0.138, +3026 'ETH-2' : 0.138, +3027 'ETH-3' : 0.270, +3028 'ETH-4' : 0.223, +3029 'GU-1' : -0.419, +3030 } +3031 ``` +3032 ''' +3033 +3034 +3035 @property +3036 def Nominal_D48(self): +3037 return self.Nominal_D4x +3038 +3039 +3040 @Nominal_D48.setter +3041 def Nominal_D48(self, new): +3042 self.Nominal_D4x = dict(**new) +3043 self.refresh() +3044 +3045 +3046 def __init__(self, l = [], **kwargs): +3047 ''' +3048 **Parameters:** same as `D4xdata.__init__()` +3049 ''' +3050 D4xdata.__init__(self, l = l, mass = '48', **kwargs) +3051 +3052 +3053class _SessionPlot(): +3054 ''' +3055 Simple placeholder class +3056 ''' +3057 def __init__(self): +3058 pass +
View Source
-def fCO2eqD47_Petersen(T): - ''' - CO2 equilibrium Δ47 value as a function of T (in degrees C) - according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127). + - ''' - return float(_fCO2eqD47_Petersen(T)) -