From ff5573915676e66cfbe5f87f6c2a24aa4231f96c Mon Sep 17 00:00:00 2001 From: mdaeron Date: Sun, 27 Feb 2022 14:08:18 +0100 Subject: [PATCH 01/16] Bump version --- D47crunch/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 5245e4d..8f55067 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -21,7 +21,7 @@ __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' +__version__ = '2.0.4-dev0' import os import numpy as np From 8a99dba6ffe6a725d542a0e3d3047aa41f5afb7e Mon Sep 17 00:00:00 2001 From: mdaeron Date: Sun, 27 Feb 2022 14:27:30 +0100 Subject: [PATCH 02/16] Update doc --- D47crunch/__init__.py | 2 -- docs/howto.md | 16 ++++++++-------- docs/index.html | 41 +++++++++++++++++++++++------------------ docs/tutorial.md | 6 +++--- 4 files changed, 34 insertions(+), 31 deletions(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 5245e4d..cfb2e95 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -11,8 +11,6 @@ .. include:: ../docs/tutorial.md .. include:: ../docs/howto.md - -## D47crunch API ''' __docformat__ = "restructuredtext" diff --git a/docs/howto.md b/docs/howto.md index dd3dc6e..ee7bd27 100755 --- a/docs/howto.md +++ b/docs/howto.md @@ -1,6 +1,6 @@ -## 2. How-to +# 2. How-to -### 2.1 Simulate a virtual data set to play with +## 2.1 Simulate a virtual data set to play with It is sometimes convenient to quickly build a virtual data set of analyses, for instance to assess the final analytical precision achievable for a given combination of anchor and unknown analyses (see also Fig. 6 of [Daëron, 2021](https://doi.org/10.1029/2020GC009592)). @@ -42,7 +42,7 @@ 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 +## 2.2 Control data quality `D47crunch` offers several tools to visualize processed data. The examples below use the same virtual data set, generated with: @@ -81,7 +81,7 @@ data47.crunch() data47.standardize() ``` -#### 2.1.1 Plotting the distribution of analyses through time +### 2.2.1 Plotting the distribution of analyses through time ```py data47.plot_distribution_of_analyses(filename = 'time_distribution.pdf') @@ -91,7 +91,7 @@ data47.plot_distribution_of_analyses(filename = 'time_distribution.pdf') The plot above shows the succession of analyses as if they were all distributed at regular time intervals. See `D4xdata.plot_distribution_of_analyses()` for how to plot analyses as a function of “true” time (based on the `TimeTag` for each analysis). -#### 2.1.2 Generating session plots +### 2.2.2 Generating session plots ```py data47.plot_sessions() @@ -101,7 +101,7 @@ Below is one of the resulting sessions plots. Each cross marker is an analysis. ![D47_plot_Session_03.png](D47_plot_Session_03.png) -#### 2.1.3 Plotting Δ47 or Δ48 residuals +### 2.2.3 Plotting Δ47 or Δ48 residuals ```py data47.plot_residuals(filename = 'residuals.pdf') @@ -111,7 +111,7 @@ data47.plot_residuals(filename = 'residuals.pdf') Again, note that this plot only shows the succession of analyses as if they were all distributed at regular time intervals. -### 2.3 Use a different set of anchors, change anchor nominal values, and/or change 17O correction parameters +## 2.3 Use a different set of anchors, change anchor nominal values, and/or change oxygen-17 correction parameters Nominal values for various carbonate standards are defined in four places: @@ -230,7 +230,7 @@ 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 +## 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`. diff --git a/docs/index.html b/docs/index.html index 611b6d1..7809019 100644 --- a/docs/index.html +++ b/docs/index.html @@ -21,9 +21,18 @@

Contents

@@ -308,9 +317,9 @@

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

-

1. Tutorial

+

1. Tutorial

-

1.1 Installation

+

1.1 Installation

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

@@ -336,7 +345,7 @@

1.1 Installation

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

-

1.2 Usage

+

1.2 Usage

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

@@ -433,9 +442,9 @@

1.2 Usage

––– ––––––––– –––––––––– ––––––––––– –––––––––––– ––––––––– ––––––––– –––––––––– –––––––––– –––––––– –––––––––– –––––––––– ––––––––– ––––––––– –––––––––– –––––––– -

2. How-to

+

2. How-to

-

2.1 Simulate a virtual data set to play with

+

2.1 Simulate a virtual data set to play with

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

@@ -476,7 +485,7 @@

2.1 Simulate a virtual data D.table_of_analyses(verbose = True, save_to_file = False) -

2.2 Control data quality

+

2.2 Control data quality

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

@@ -514,7 +523,7 @@

2.2 Control data quality

data47.standardize() -

2.1.1 Plotting the distribution of analyses through time

+

2.2.1 Plotting the distribution of analyses through time

data47.plot_distribution_of_analyses(filename = 'time_distribution.pdf')
 
@@ -523,7 +532,7 @@

2.1.1 Plotting t

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

-

2.1.2 Generating session plots

+

2.2.2 Generating session plots

data47.plot_sessions()
 
@@ -532,7 +541,7 @@

2.1.2 Generating session plots

D47_plot_Session_03.png

-

2.1.3 Plotting Δ47 or Δ48 residuals

+

2.2.3 Plotting Δ47 or Δ48 residuals

data47.plot_residuals(filename = 'residuals.pdf')
 
@@ -541,7 +550,7 @@

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

-

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

+

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

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

@@ -661,7 +670,7 @@

bar.table_of_samples(verbose = True, save_to_file = False) -

2.4 Process paired Δ47 and Δ48 values

+

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.

@@ -750,8 +759,6 @@

2.4 Process paired Δ - -

D47crunch API

@@ -769,8 +776,6 @@

D47crunch API

.. include:: ../docs/tutorial.md .. include:: ../docs/howto.md - -## D47crunch API ''' __docformat__ = "restructuredtext" diff --git a/docs/tutorial.md b/docs/tutorial.md index 7bbe3fe..d3581dd 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -1,6 +1,6 @@ -## 1. Tutorial +# 1. Tutorial -### 1.1 Installation +## 1.1 Installation The easy option is to use `pip`; open a shell terminal and simply type: @@ -24,7 +24,7 @@ import D47crunch Documentation for the development version can be downloaded [here](https://github.com/mdaeron/D47crunch/raw/dev/docs/index.html) (save html file and open it locally). -### 1.2 Usage +## 1.2 Usage Start by creating a file named `rawdata.csv` with the following contents: From 89e2dd778771ab5758267829fddbbdf7c5a1b569 Mon Sep 17 00:00:00 2001 From: mdaeron Date: Sun, 6 Mar 2022 15:30:18 +0100 Subject: [PATCH 03/16] Fix bug in D4xdata.standardize() when using weighted sessions --- D47crunch/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 1d8da13..bbe65f0 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -972,6 +972,7 @@ def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', ver + `l`: a list of dictionaries, with each dictionary including at least the keys `Sample`, `d45`, `d46`, and `d47` or `d48`. + + `mass`: `'47'` or `'48'` + `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods. + `session`: define session name for analyses without a `Session` key + `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods. @@ -1593,7 +1594,10 @@ def standardize(self, 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) + if self._4x == '47': + X = D47data([r for r in self if r['Session'] in session_group]) + elif self._4x == '48': + X = D48data([r for r in self if r['Session'] in session_group]) X.Nominal_D4x = self.Nominal_D4x.copy() X.refresh() result = X.standardize(method = 'pooled', weighted_sessions = [], consolidate = False) From 8801b8b4fdd983255e7f028d50662e8bd85fc16b Mon Sep 17 00:00:00 2001 From: mdaeron Date: Thu, 10 Mar 2022 10:48:59 +0100 Subject: [PATCH 04/16] Improve D4xdata.plot_distribution_of_analyses() --- D47crunch/__init__.py | 44 ++++++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index bbe65f0..83dcea4 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -2827,7 +2827,15 @@ def simulate(self, *args, **kwargs): ''' 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): + def plot_distribution_of_analyses( + self, + dir = 'output', + filename = None, + vs_time = False, + figsize = (6,4), + subplots_adjust = (0.02, 0.03, 0.85, 0.8), + output = None, + ): ''' Plot temporal distribution of all analyses in the data set. @@ -2839,45 +2847,39 @@ def plot_distribution_of_analyses(self, dir = 'output', filename = None, vs_time 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) + fig = ppl.figure(figsize = figsize) + ppl.subplots_adjust(*subplots_adjust) + Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)]) Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)]) + Xmax += (Xmax-Xmin)/40 + Xmin -= (Xmax-Xmin)/41 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]) + 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 = .75) + 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, color = 'b' if s in usamples else 'r') + ppl.axis([Xmin, Xmax, -k-1, 1]) - x2 = 0 + x2 = -1 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: + if not vs_time: 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.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15) + ppl.text((x1+x2)/2, 1, f' {session}', ha = 'center', va = 'bottom', rotation = 45, size = 8) ppl.xticks([]) ppl.yticks([]) From b6f415b324c664392885df61fd9e2d0f87693443 Mon Sep 17 00:00:00 2001 From: mdaeron Date: Thu, 10 Mar 2022 21:27:34 +0100 Subject: [PATCH 05/16] Improve D4xdata.plot_distribution_of_analyses() --- D47crunch/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 83dcea4..35a6860 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -2833,7 +2833,7 @@ def plot_distribution_of_analyses( filename = None, vs_time = False, figsize = (6,4), - subplots_adjust = (0.02, 0.03, 0.85, 0.8), + subplots_adjust = (0.02, 0.13, 0.85, 0.8), output = None, ): ''' @@ -2863,6 +2863,13 @@ def plot_distribution_of_analyses( 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, color = 'b' if s in usamples else 'r') ppl.axis([Xmin, Xmax, -k-1, 1]) + ppl.xlabel('\ntime') + ppl.gca().annotate('', + xy = (0.6, -0.05), + xycoords = 'axes fraction', + xytext = (.4, -0.05), + arrowprops = dict(arrowstyle = "->", color = 'k'), + ) x2 = -1 From b9250b12fde1928581efe2739ad4754657aa8c4d Mon Sep 17 00:00:00 2001 From: mdaeron Date: Sun, 13 Mar 2022 15:03:36 +0100 Subject: [PATCH 06/16] Improve D4xdata.plot_distribution_of_analyses() --- D47crunch/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 35a6860..5f10952 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -2696,6 +2696,7 @@ def plot_residuals( x_sessions = {} one_or_more_singlets = False one_or_more_multiplets = False + multiplets = set() for k,r in enumerate(self): if r['Session'] != session: x2 = k-1 @@ -2704,6 +2705,8 @@ def plot_residuals( session = r['Session'] x1 = k singlet = len(self.samples[r['Sample']]['data']) == 1 + if not singlet: + multiplets.add(r['Sample']) if r['Sample'] in self.unknowns: if singlet: one_or_more_singlets = True @@ -2784,7 +2787,7 @@ def plot_residuals( if hist: ppl.sca(ax2) - X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self] + X = [1e3 * (r['D47'] - self.samples[r['Sample']]['D47']) for r in self if r['Sample'] in multiplets] ppl.hist( X, orientation = 'horizontal', From ccfacf3d61bd16ef3db53f78558d1c3141a3588e Mon Sep 17 00:00:00 2001 From: mdaeron Date: Tue, 29 Mar 2022 09:12:00 +0200 Subject: [PATCH 07/16] Improve D4xdata.plot_distribution_of_analyses() --- D47crunch/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 5f10952..61544ed 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -2868,9 +2868,9 @@ def plot_distribution_of_analyses( ppl.axis([Xmin, Xmax, -k-1, 1]) ppl.xlabel('\ntime') ppl.gca().annotate('', - xy = (0.6, -0.05), + xy = (0.6, -0.02), xycoords = 'axes fraction', - xytext = (.4, -0.05), + xytext = (.4, -0.02), arrowprops = dict(arrowstyle = "->", color = 'k'), ) @@ -2880,16 +2880,16 @@ def plot_distribution_of_analyses( 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 x2 > -1: if not vs_time: - ppl.axvline((x1+x2)/2, color = 'k', lw = .75) + ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5) 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.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15) - ppl.text((x1+x2)/2, 1, f' {session}', ha = 'center', va = 'bottom', rotation = 45, size = 8) + ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8) ppl.xticks([]) ppl.yticks([]) From d0d7c4f5d59f236bef4baf546a33dc550a468010 Mon Sep 17 00:00:00 2001 From: mdaeron Date: Thu, 11 May 2023 09:34:43 +0200 Subject: [PATCH 08/16] Bump version --- D47crunch/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 61544ed..5b06f2b 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -16,10 +16,10 @@ __docformat__ = "restructuredtext" __author__ = 'Mathieu Daëron' __contact__ = 'daeron@lsce.ipsl.fr' -__copyright__ = 'Copyright (c) 2022 Mathieu Daëron' +__copyright__ = 'Copyright (c) 2023 Mathieu Daëron' __license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause' -__date__ = '2022-02-27' -__version__ = '2.0.4-dev0' +__date__ = '2023-05-11' +__version__ = '2.0.4' import os import numpy as np From 2106bc48d6b8f7404e2b4204a62bac5ca8f5180b Mon Sep 17 00:00:00 2001 From: mdaeron Date: Thu, 11 May 2023 10:58:15 +0200 Subject: [PATCH 09/16] Refresh docs --- docs/index.html | 17509 +++++++++++++++++++++++----------------------- 1 file changed, 8741 insertions(+), 8768 deletions(-) diff --git a/docs/index.html b/docs/index.html index 7809019..c0174ee 100644 --- a/docs/index.html +++ b/docs/index.html @@ -3,14 +3,14 @@ - + D47crunch API documentation - - + +
-
+

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:

    -
  1. Download the dev branch source code here and rename it to D47crunch.py.
  2. +
  3. Download the dev branch source code here and rename it to D47crunch.py.
  4. Do any of the following:
      -
    • copy D47crunch.py to somewhere in your Python path
    • -
    • copy D47crunch.py to a working directory (import D47crunch will only work if called within that directory)
    • -
    • copy D47crunch.py to any other location (e.g., /foo/bar) and then use the following code snippet in your own code to import D47crunch:
    • +
    • copy D47crunch.py to somewhere in your Python path
    • +
    • copy D47crunch.py to a working directory (import D47crunch will only work if called within that directory)
    • +
    • copy D47crunch.py to any other location (e.g., /foo/bar) and then use the following code snippet in your own code to import D47crunch:
-
import sys
+
+
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')
+
+

time_distribution.png

@@ -534,8 +550,10 @@

2.2.1 Plotting t

2.2.2 Generating session plots

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

residuals.png

@@ -557,8 +577,8 @@

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
    +
    +

    -
    #   + +
    + + def + fCO2eqD47_Petersen(T): - - def - fCO2eqD47_Petersen(T): -
    - -
    - 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))
    -
    +
    + +
    61def fCO2eqD47_Petersen(T):
    +62	'''
    +63	CO2 equilibrium Δ47 value as a function of T (in degrees C)
    +64	according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).
    +65
    +66	'''
    +67	return float(_fCO2eqD47_Petersen(T))
    +
    -

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

    @@ -3838,25 +3883,25 @@

    2.4 Process paired Δ
    -
    #   + +
    + + def + fCO2eqD47_Wang(T): - - def - fCO2eqD47_Wang(T): -
    + -
    - View Source -
    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))
    -
    +
    + +
    72def fCO2eqD47_Wang(T):
    +73	'''
    +74	CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
    +75	according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
    +76	(supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
    +77	'''
    +78	return float(_fCO2eqD47_Wang(T))
    +
    -

    CO2 equilibrium Δ47 value as a function of T (in degrees C) according to Wang et al. (2004) @@ -3866,35 +3911,35 @@

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

    Compute covariance-aware linear combinations

    @@ -3904,7 +3949,7 @@

    2.4 Process paired Δ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)
  • +(all equal to 1 by default)

    Return the sum (and its SE) of the elements of X, with optional weights equal @@ -3914,42 +3959,42 @@

    2.4 Process paired Δ
    -
    #   - - - def - make_csv(x, hsep=',', vsep='\n'): -
    - -
    - View Source -
    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)
    +                            
    +
    + + def + make_csv(x, hsep=',', vsep='\n'): - **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]) -
    +

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

    Formats a list of lists of strings as a CSV

    @@ -3963,36 +4008,40 @@

    2.4 Process paired ΔExample

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

    outputs:

    -
    a,b,c
    +
    +
    a,b,c
     d,e,f
    -
    +
    +

    -
    #   + +
    + + def + pf(txt): - - def - pf(txt): -
    + -
    - View Source -
    def pf(txt):
    -	'''
    -	Modify string `txt` to follow `lmfit.Parameter()` naming rules.
    -	'''
    -	return txt.replace('-','_').replace('.','_').replace(' ','_')
    -
    +
    + +
    126def pf(txt):
    +127	'''
    +128	Modify string `txt` to follow `lmfit.Parameter()` naming rules.
    +129	'''
    +130	return txt.replace('-','_').replace('.','_').replace(' ','_')
    +
    -

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

    @@ -4000,31 +4049,31 @@

    2.4 Process paired Δ
    -
    #   + +
    + + def + smart_type(x): + + - - def - smart_type(x):
    + +
    133def smart_type(x):
    +134	'''
    +135	Tries to convert string `x` to a float if it includes a decimal point, or
    +136	to an integer if it does not. If both attempts fail, return the original
    +137	string unchanged.
    +138	'''
    +139	try:
    +140		y = float(x)
    +141	except ValueError:
    +142		return x
    +143	if '.' not in x:
    +144		return int(y)
    +145	return y
    +
    -
    - View Source -
    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
    -
    - -

    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 @@ -4034,61 +4083,61 @@

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

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

    @@ -4104,9 +4153,11 @@

    2.4 Process paired ΔExample

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

    yields:

    @@ -4122,34 +4173,34 @@

    2.4 Process paired Δ
    -
    #   - - - def - transpose_table(x): -
    - -
    - View Source -
    def transpose_table(x):
    -	'''
    -	Transpose a list if lists
    +                            
    +
    + + def + transpose_table(x): - **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)] -
    +

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

    Transpose a list if lists

    @@ -4161,55 +4212,57 @@

    2.4 Process paired ΔExample

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

    -
    #   - - - def - w_avg(X, sX): -
    - -
    - View Source -
    def w_avg(X, sX) :
    -	'''
    -	Compute variance-weighted average
    +                            
    +
    + + def + w_avg(X, sX): - 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 -
    +

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

    Compute variance-weighted average

    @@ -4228,46 +4281,48 @@

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

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

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

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

    @@ -4286,188 +4341,162 @@

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

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

    @@ -4477,17 +4506,17 @@

    2.4 Process paired Δ
  • sample: sample name
  • d13Cwg_VPDB, d18Owg_VSMOW: bulk composition of the working gas - (respectively –4 and +26 ‰ by default)
  • +(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
  • +of the carbonate sample
  • Nominal_D47, Nominal_D48: where to lookup Δ47 and - Δ48 values if D47 or D48 are not specified
  • 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
  • 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)
  • +correction parameters (by default equal to the D4xdata default values)

    Returns a dictionary with fields @@ -4497,266 +4526,242 @@

    2.4 Process paired Δ
    -
    #   + +
    + + def + virtual_data( samples=[], a47=1.0, b47=0.0, c47=-0.9, a48=1.0, b48=0.0, c48=-0.45, rD47=0.015, rD48=0.045, d13Cwg_VPDB=None, d18Owg_VSMOW=None, session=None, Nominal_D47=None, Nominal_D48=None, Nominal_d13C_VPDB=None, Nominal_d18O_VPDB=None, ALPHA_18O_ACID_REACTION=None, R13_VPDB=None, R17_VSMOW=None, R18_VSMOW=None, LAMBDA_17=None, R18_VPDB=None, seed=0): + + - - def - virtual_data( - samples=[], - a47=1.0, - b47=0.0, - c47=-0.9, - a48=1.0, - b48=0.0, - c48=-0.45, - rD47=0.015, - rD48=0.045, - d13Cwg_VPDB=None, - d18Owg_VSMOW=None, - session=None, - Nominal_D47=None, - Nominal_D48=None, - Nominal_d13C_VPDB=None, - Nominal_d18O_VPDB=None, - ALPHA_18O_ACID_REACTION=None, - R13_VPDB=None, - R17_VSMOW=None, - R18_VSMOW=None, - LAMBDA_17=None, - R18_VPDB=None, - seed=0 -):
    + +
    410def virtual_data(
    +411	samples = [],
    +412	a47 = 1., b47 = 0., c47 = -0.9,
    +413	a48 = 1., b48 = 0., c48 = -0.45,
    +414	rD47 = 0.015, rD48 = 0.045,
    +415	d13Cwg_VPDB = None, d18Owg_VSMOW = None,
    +416	session = None,
    +417	Nominal_D47 = None, Nominal_D48 = None,
    +418	Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
    +419	ALPHA_18O_ACID_REACTION = None,
    +420	R13_VPDB = None,
    +421	R17_VSMOW = None,
    +422	R18_VSMOW = None,
    +423	LAMBDA_17 = None,
    +424	R18_VPDB = None,
    +425	seed = 0,
    +426	):
    +427	'''
    +428	Return list with simulated analyses from a single session.
    +429	
    +430	**Parameters**
    +431	
    +432	+ `samples`: a list of entries; each entry is a dictionary with the following fields:
    +433	    * `Sample`: the name of the sample
    +434	    * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
    +435	    * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
    +436	    * `N`: how many analyses to generate for this sample
    +437	+ `a47`: scrambling factor for Δ47
    +438	+ `b47`: compositional nonlinearity for Δ47
    +439	+ `c47`: working gas offset for Δ47
    +440	+ `a48`: scrambling factor for Δ48
    +441	+ `b48`: compositional nonlinearity for Δ48
    +442	+ `c48`: working gas offset for Δ48
    +443	+ `rD47`: analytical repeatability of Δ47
    +444	+ `rD48`: analytical repeatability of Δ48
    +445	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
    +446		(by default equal to the `simulate_single_analysis` default values)
    +447	+ `session`: name of the session (no name by default)
    +448	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
    +449		if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
    +450	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
    +451		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified 
    +452		(by default equal to the `simulate_single_analysis` defaults)
    +453	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
    +454		(by default equal to the `simulate_single_analysis` defaults)
    +455	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
    +456		correction parameters (by default equal to the `simulate_single_analysis` default)
    +457	+ `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations
    +458	
    +459		
    +460	Here is an example of using this method to generate an arbitrary combination of
    +461	anchors and unknowns for a bunch of sessions:
    +462
    +463	```py
    +464	args = dict(
    +465		samples = [
    +466			dict(Sample = 'ETH-1', N = 4),
    +467			dict(Sample = 'ETH-2', N = 5),
    +468			dict(Sample = 'ETH-3', N = 6),
    +469			dict(Sample = 'FOO', N = 2,
    +470				d13C_VPDB = -5., d18O_VPDB = -10.,
    +471				D47 = 0.3, D48 = 0.15),
    +472			], rD47 = 0.010, rD48 = 0.030)
    +473
    +474	session1 = virtual_data(session = 'Session_01', **args, seed = 123)
    +475	session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
    +476	session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
    +477	session4 = virtual_data(session = 'Session_04', **args, seed = 123456)
    +478
    +479	D = D47data(session1 + session2 + session3 + session4)
    +480
    +481	D.crunch()
    +482	D.standardize()
    +483
    +484	D.table_of_sessions(verbose = True, save_to_file = False)
    +485	D.table_of_samples(verbose = True, save_to_file = False)
    +486	D.table_of_analyses(verbose = True, save_to_file = False)
    +487	```
    +488	
    +489	This should output something like:
    +490	
    +491	```
    +492	[table_of_sessions] 
    +493	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    +494	Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
    +495	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    +496	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
    +497	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
    +498	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
    +499	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
    +500	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    +501
    +502	[table_of_samples] 
    +503	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    +504	Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
    +505	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    +506	ETH-1   16       2.02       37.02  0.2052                    0.0079          
    +507	ETH-2   20     -10.17       19.88  0.2085                    0.0100          
    +508	ETH-3   24       1.71       37.45  0.6132                    0.0105          
    +509	FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
    +510	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    +511
    +512	[table_of_analyses] 
    +513	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    +514	UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
    +515	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    +516	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
    +517	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
    +518	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
    +519	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
    +520	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
    +521	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
    +522	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
    +523	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
    +524	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
    +525	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
    +526	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
    +527	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
    +528	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
    +529	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
    +530	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
    +531	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
    +532	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
    +533	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
    +534	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
    +535	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
    +536	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
    +537	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
    +538	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
    +539	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
    +540	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
    +541	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
    +542	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
    +543	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
    +544	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
    +545	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
    +546	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
    +547	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
    +548	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
    +549	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
    +550	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
    +551	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
    +552	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
    +553	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
    +554	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
    +555	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
    +556	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
    +557	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
    +558	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
    +559	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
    +560	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
    +561	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
    +562	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
    +563	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
    +564	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
    +565	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
    +566	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
    +567	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
    +568	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
    +569	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
    +570	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
    +571	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
    +572	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
    +573	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
    +574	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
    +575	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
    +576	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
    +577	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
    +578	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
    +579	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
    +580	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
    +581	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
    +582	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
    +583	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
    +584	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    +585	```
    +586	'''
    +587	
    +588	kwargs = locals().copy()
    +589
    +590	from numpy import random as nprandom
    +591	if seed:
    +592		rng = nprandom.default_rng(seed)
    +593	else:
    +594		rng = nprandom.default_rng()
    +595	
    +596	N = sum([s['N'] for s in samples])
    +597	errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    +598	errors47 *= rD47 / stdev(errors47) # scale errors to rD47
    +599	errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    +600	errors48 *= rD48 / stdev(errors48) # scale errors to rD48
    +601	
    +602	k = 0
    +603	out = []
    +604	for s in samples:
    +605		kw = {}
    +606		kw['sample'] = s['Sample']
    +607		kw = {
    +608			**kw,
    +609			**{var: kwargs[var]
    +610				for var in [
    +611					'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
    +612					'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
    +613					'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
    +614					'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
    +615					]
    +616				if kwargs[var] is not None},
    +617			**{var: s[var]
    +618				for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
    +619				if var in s},
    +620			}
    +621
    +622		sN = s['N']
    +623		while sN:
    +624			out.append(simulate_single_analysis(**kw))
    +625			out[-1]['d47'] += errors47[k] * a47
    +626			out[-1]['d48'] += errors48[k] * a48
    +627			sN -= 1
    +628			k += 1
    +629
    +630		if session is not None:
    +631			for r in out:
    +632				r['Session'] = session
    +633	return out
    +
    -
    - View Source -
    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
    -
    - -

    Return list with simulated analyses from a single session.

    @@ -4779,24 +4784,25 @@

    2.4 Process paired Δ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)
  • +(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)
  • +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)
  • 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)
  • +(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)
  • +correction parameters (by default equal to the simulate_single_analysis default)
  • seed: explicitly set to a non-zero value to achieve random but repeatable simulations
  • Here is an example of using this method to generate an arbitrary combination of anchors and unknowns for a bunch of sessions:

    -
    args = dict(
    +
    +
    args = dict(
             samples = [
                     dict(Sample = 'ETH-1', N = 4),
                     dict(Sample = 'ETH-2', N = 5),
    @@ -4819,7 +4825,8 @@ 

    2.4 Process paired Δ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:

    @@ -4922,89 +4929,81 @@

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

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

    @@ -5019,103 +5018,95 @@

    2.4 Process paired Δ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 set to 'raw': return a list of list of strings +(e.g., [['header1', 'header2'], ['0.1', '0.2']])

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

    Print out, save to disk and/or return a combined table of sessions for a pair of D47data and D48data objects. @@ -5132,109 +5123,101 @@

    2.4 Process paired Δ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 set to 'raw': return a list of list of strings +(e.g., [['header1', 'header2'], ['0.1', '0.2']])

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

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

    @@ -5253,2072 +5236,2087 @@

    2.4 Process paired Δ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 set to 'raw': return a list of list of strings +(e.g., [['header1', 'header2'], ['0.1', '0.2']])

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

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

    @@ -7326,43 +7324,45 @@

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

    Parameters

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

      2.4 Process paired Δ
      -
      #   +
      + R13_VPDB = +0.01118 - R13_VPDB = 0.01118 +
      - +

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

      @@ -7387,11 +7389,13 @@

      2.4 Process paired Δ
      -
      #   +
      + R18_VSMOW = +0.0020052 - R18_VSMOW = 0.0020052 +
      - +

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

      @@ -7400,11 +7404,13 @@

      2.4 Process paired Δ
      -
      #   +
      + LAMBDA_17 = +0.528 - LAMBDA_17 = 0.528 +
      - +

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

      @@ -7413,11 +7419,13 @@

      2.4 Process paired Δ
      -
      #   +
      + R17_VSMOW = +0.00038475 - R17_VSMOW = 0.00038475 +
      - +

      Absolute (17O/16C) ratio of VSMOW. By default equal to 0.00038475 @@ -7428,11 +7436,13 @@

      2.4 Process paired Δ
      -
      #   +
      + R18_VPDB = +0.0020672007840000003 - R18_VPDB = 0.0020672007840000003 +
      - +

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

      @@ -7441,11 +7451,13 @@

      2.4 Process paired Δ
      -
      #   +
      + R17_VPDB = +0.0003909861828790272 - R17_VPDB = 0.0003909861828790272 +
      - +

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

      @@ -7454,11 +7466,13 @@

      2.4 Process paired Δ
      -
      #   +
      + LEVENE_REF_SAMPLE = +'ETH-3' - 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 @@ -7474,11 +7488,13 @@

      2.4 Process paired Δ
      -
      #   +
      + ALPHA_18O_ACID_REACTION = +1.008129 - ALPHA_18O_ACID_REACTION = 1.008129 +
      - +

      Specifies the 18O/16O fractionation factor generally applicable to acid reactions in the dataset. Currently used by D4xdata.wg(), @@ -7491,11 +7507,13 @@

      2.4 Process paired Δ
      -
      #   +
      + Nominal_d13C_VPDB = +{'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71} - Nominal_d13C_VPDB = {'ETH-1': 2.02, 'ETH-2': -10.17, 'ETH-3': 1.71} +
      - +

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

      @@ -7507,11 +7525,13 @@

      2.4 Process paired Δ
      -
      #   +
      + Nominal_d18O_VPDB = +{'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78} - Nominal_d18O_VPDB = {'ETH-1': -2.19, 'ETH-2': -18.69, 'ETH-3': -1.78} +
      - +

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

      @@ -7523,11 +7543,13 @@

      2.4 Process paired Δ
      -
      #   +
      + d13C_STANDARDIZATION_METHOD = +'2pt' - d13C_STANDARDIZATION_METHOD = '2pt' +
      - +

      Method by which to standardize δ13C values:

      @@ -7546,11 +7568,13 @@

      2.4 Process paired Δ
      -
      #   +
      + d18O_STANDARDIZATION_METHOD = +'2pt' - d18O_STANDARDIZATION_METHOD = '2pt' +
      - +

      Method by which to standardize δ18O values:

      @@ -7569,36 +7593,36 @@

      2.4 Process paired Δ
      -
      #   + +
      + + def + make_verbal(oldfun): + + - - def - make_verbal(oldfun):
      + +
       993	def make_verbal(oldfun):
      + 994		'''
      + 995		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
      + 996		'''
      + 997		@wraps(oldfun)
      + 998		def newfun(*args, verbose = '', **kwargs):
      + 999			myself = args[0]
      +1000			oldprefix = myself.prefix
      +1001			myself.prefix = oldfun.__name__
      +1002			if verbose != '':
      +1003				oldverbose = myself.verbose
      +1004				myself.verbose = verbose
      +1005			out = oldfun(*args, **kwargs)
      +1006			myself.prefix = oldprefix
      +1007			if verbose != '':
      +1008				myself.verbose = oldverbose
      +1009			return out
      +1010		return newfun
      +
      -
      - View Source -
      	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
      -
      - -

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

      @@ -7606,25 +7630,25 @@

      2.4 Process paired Δ
      -
      #   + +
      + + def + msg(self, txt): - - def - msg(self, txt): -
      + -
      - View Source -
      	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}')
      -
      +
      + +
      1013	def msg(self, txt):
      +1014		'''
      +1015		Log a message to `self.logfile`, and print it out if `verbose = True`
      +1016		'''
      +1017		self.log(txt)
      +1018		if self.verbose:
      +1019			print(f'{f"[{self.prefix}]":<16} {txt}')
      +
      -

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

      @@ -7632,24 +7656,24 @@

      2.4 Process paired Δ
      -
      #   + +
      + + def + vmsg(self, txt): - - def - vmsg(self, txt): -
      + -
      - View Source -
      	def vmsg(self, txt):
      -		'''
      -		Log a message to `self.logfile` and print it out
      -		'''
      -		self.log(txt)
      -		print(txt)
      -
      +
      + +
      1022	def vmsg(self, txt):
      +1023		'''
      +1024		Log a message to `self.logfile` and print it out
      +1025		'''
      +1026		self.log(txt)
      +1027		print(txt)
      +
      -

      Log a message to self.logfile and print it out

      @@ -7657,26 +7681,26 @@

      2.4 Process paired Δ
      -
      #   + +
      + + def + log(self, *txts): - - def - log(self, *txts): -
      + -
      - View Source -
      	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}')
      -
      +
      + +
      1030	def log(self, *txts):
      +1031		'''
      +1032		Log a message to `self.logfile`
      +1033		'''
      +1034		if self.logfile:
      +1035			with open(self.logfile, 'a') as fid:
      +1036				for txt in txts:
      +1037					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
      +
      -

      Log a message to self.logfile

      @@ -7684,25 +7708,25 @@

      2.4 Process paired Δ
      -
      #   + +
      + + def + refresh(self, session='mySession'): - - def - refresh(self, session='mySession'): -
      + -
      - View Source -
      	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()
      -
      +
      + +
      1040	def refresh(self, session = 'mySession'):
      +1041		'''
      +1042		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
      +1043		'''
      +1044		self.fill_in_missing_info(session = session)
      +1045		self.refresh_sessions()
      +1046		self.refresh_samples()
      +
      -

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

      @@ -7710,33 +7734,33 @@

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

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

      @@ -7745,28 +7769,28 @@

      2.4 Process paired Δ
      -
      #   + +
      + + def + refresh_samples(self): + + - - def - refresh_samples(self):
      + +
      1066	def refresh_samples(self):
      +1067		'''
      +1068		Define `self.samples`, `self.anchors`, and `self.unknowns`.
      +1069		'''
      +1070		self.samples = {
      +1071			s: {'data': [r for r in self if r['Sample'] == s]}
      +1072			for s in sorted({r['Sample'] for r in self})
      +1073			}
      +1074		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
      +1075		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
      +
      -
      - View Source -
      	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}
      -
      - -

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

      @@ -7774,44 +7798,44 @@

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

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

      @@ -7843,54 +7867,54 @@

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

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

      @@ -7923,108 +7947,108 @@

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

      Compute bulk composition of the working gas for each session based on the carbonate standards defined in both self.Nominal_d13C_VPDB and @@ -8034,48 +8058,48 @@

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

      Compute δ13CVPDB and δ18OVSMOW, by solving the generalized form of equation (17) from @@ -8088,29 +8112,29 @@

      2.4 Process paired Δ
      -
      #   + +
      +
      @make_verbal
      -
      @make_verbal
      + def + crunch(self, verbose=''): + + - def - crunch(self, verbose=''):
      + +
      1267	@make_verbal
      +1268	def crunch(self, verbose = ''):
      +1269		'''
      +1270		Compute bulk composition and raw clumped isotope anomalies for all analyses.
      +1271		'''
      +1272		for r in self:
      +1273			self.compute_bulk_and_clumping_deltas(r)
      +1274		self.standardize_d13C()
      +1275		self.standardize_d18O()
      +1276		self.msg(f"Crunched {len(self)} analyses.")
      +
      -
      - View Source -
      	@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.")
      -
      - -

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

      @@ -8118,32 +8142,32 @@

      2.4 Process paired Δ
      -
      #   + +
      + + def + fill_in_missing_info(self, session='mySession'): + + - - def - fill_in_missing_info(self, session='mySession'):
      + +
      1279	def fill_in_missing_info(self, session = 'mySession'):
      +1280		'''
      +1281		Fill in optional fields with default values
      +1282		'''
      +1283		for i,r in enumerate(self):
      +1284			if 'D17O' not in r:
      +1285				r['D17O'] = 0.
      +1286			if 'UID' not in r:
      +1287				r['UID'] = f'{i+1}'
      +1288			if 'Session' not in r:
      +1289				r['Session'] = session
      +1290			for k in ['d47', 'd48', 'd49']:
      +1291				if k not in r:
      +1292					r[k] = np.nan
      +
      -
      - View Source -
      	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
      -
      - -

      Fill in optional fields with default values

      @@ -8151,37 +8175,37 @@

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

      Perform δ13C standadization within each session s according to self.sessions[s]['d13C_standardization_method'], which is defined by default @@ -8192,38 +8216,38 @@

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

      Perform δ18O standadization within each session s according to self.ALPHA_18O_ACID_REACTION and self.sessions[s]['d18O_standardization_method'], @@ -8234,55 +8258,55 @@

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

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

      @@ -8290,63 +8314,63 @@

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

      Compute isobar ratios for a sample with isotopic ratios R13 and R18, optionally accounting for non-zero values of Δ17O (D17O) and clumped isotope @@ -8356,42 +8380,42 @@

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

      Split unknown samples by UID (treat all analyses as different samples) or by session (treat analyses of a given sample in different sessions as @@ -8408,73 +8432,73 @@

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

      Reverse the effects of D47data.split_samples().

      @@ -8490,37 +8514,37 @@

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

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

      @@ -8533,24 +8557,24 @@

      2.4 Process paired Δ
      -
      #   + +
      + + def + report(self): - - def - report(self): -
      + -
      - View Source -
      	def report(self):
      -		'''
      -		Prints a report on the standardization fit.
      -		Only applicable after `D4xdata.standardize(method='pooled')`.
      -		'''
      -		report_fit(self.standardization)
      -
      +
      + +
      1526	def report(self):
      +1527		'''
      +1528		Prints a report on the standardization fit.
      +1529		Only applicable after `D4xdata.standardize(method='pooled')`.
      +1530		'''
      +1531		report_fit(self.standardization)
      +
      -

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

      @@ -8559,55 +8583,55 @@

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

      Combine analyses of different samples to compute weighted average Δ4x and new error (co)variances corresponding to the groups defined by the sample_groups @@ -8631,250 +8655,247 @@

      2.4 Process paired Δsample_groups: a dictionary of the form:

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

    Compute absolute Δ4x values for all replicate analyses and for sample averages. If method argument is set to 'pooled', the standardization processes all sessions @@ -8888,45 +8909,45 @@

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

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

    @@ -8935,58 +8956,58 @@

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

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

    @@ -9003,101 +9024,94 @@

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

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

    @@ -9117,83 +9131,76 @@

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

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

    @@ -9213,77 +9220,69 @@

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

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

    @@ -9304,84 +9303,77 @@

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

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

    @@ -9401,34 +9393,34 @@

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

    Generate session plots and save them to disk.

    @@ -9443,95 +9435,95 @@

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

    Compile various statistics for each sample.

    @@ -9565,139 +9557,139 @@

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

    Compute various statistics for each session.

    @@ -9729,32 +9721,32 @@

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

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

    @@ -9763,36 +9755,36 @@

    2.4 Process paired Δ
    -
    #   + +
    +
    @make_verbal
    + + def + consolidate(self, tables=True, plots=True): -
    @make_verbal
    + - def - consolidate(self, tables=True, plots=True):
    + +
    2343	@make_verbal
    +2344	def consolidate(self, tables = True, plots = True):
    +2345		'''
    +2346		Collect information about samples, sessions and repeatabilities.
    +2347		'''
    +2348		self.consolidate_samples()
    +2349		self.consolidate_sessions()
    +2350		self.repeatabilities()
    +2351
    +2352		if tables:
    +2353			self.summary()
    +2354			self.table_of_sessions()
    +2355			self.table_of_analyses()
    +2356			self.table_of_samples()
    +2357
    +2358		if plots:
    +2359			self.plot_sessions()
    +
    -
    - View Source -
    	@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()
    -
    - -

    Collect information about samples, sessions and repeatabilities.

    @@ -9800,53 +9792,53 @@

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

    Compute the χ2, root mean squared weighted deviation (i.e. reduced χ2), and corresponding degrees of freedom of the @@ -9858,65 +9850,65 @@

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

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

    @@ -9924,58 +9916,58 @@

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

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

    @@ -9985,15 +9977,19 @@

    2.4 Process paired ΔExamples

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

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

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

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

    @@ -10001,56 +9997,56 @@

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

    Covariance between Δ4x values of samples

    @@ -10062,31 +10058,31 @@

    2.4 Process paired Δ
    -
    #   + +
    + + def + sample_D4x_correl(self, sample1, sample2=None): + + - - def - sample_D4x_correl(self, sample1, sample2=None):
    + +
    2526	def sample_D4x_correl(self, sample1, sample2 = None):
    +2527		'''
    +2528		Correlation between Δ4x errors of samples
    +2529
    +2530		Returns the error correlation between the average Δ4x values of two samples.
    +2531		'''
    +2532		if sample2 is None or sample2 == sample1:
    +2533			return 1.
    +2534		return (
    +2535			self.sample_D4x_covar(sample1, sample2)
    +2536			/ self.unknowns[sample1][f'SE_D{self._4x}']
    +2537			/ self.unknowns[sample2][f'SE_D{self._4x}']
    +2538			)
    +
    -
    - View Source -
    	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}']
    -			)
    -
    - -

    Correlation between Δ4x errors of samples

    @@ -10096,129 +10092,116 @@

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

    Generate plot for a single session

    @@ -10226,211 +10209,205 @@

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

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

    @@ -10448,23 +10425,23 @@

    2.4 Process paired Δ
    -
    #   + +
    + + def + simulate(self, *args, **kwargs): - - def - simulate(self, *args, **kwargs): -
    + -
    - View Source -
    	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()')
    -
    +
    + +
    2828	def simulate(self, *args, **kwargs):
    +2829		'''
    +2830		Legacy function with warning message pointing to `virtual_data()`
    +2831		'''
    +2832		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
    +
    -

    Legacy function with warning message pointing to virtual_data()

    @@ -10472,84 +10449,93 @@

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

    Plot temporal distribution of all analyses in the data set.

    @@ -10583,107 +10569,106 @@
    Inherited Members

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

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

    @@ -10691,22 +10676,22 @@
    Inherited Members
    -
    #   + +
    + + D47data(l=[], **kwargs) - - D47data(l=[], **kwargs) -
    + -
    - View Source -
    	def __init__(self, l = [], **kwargs):
    -		'''
    -		**Parameters:** same as `D4xdata.__init__()`
    -		'''
    -		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    -
    +
    + +
    2957	def __init__(self, l = [], **kwargs):
    +2958		'''
    +2959		**Parameters:** same as `D4xdata.__init__()`
    +2960		'''
    +2961		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    +
    -

    Parameters: same as D4xdata.__init__()

    @@ -10714,11 +10699,14 @@
    Inherited Members
    -
    #   +
    + Nominal_D4x = + + {'ETH-1': 0.2052, 'ETH-2': 0.2085, 'ETH-3': 0.6132, 'ETH-4': 0.4511, 'IAEA-C1': 0.3018, 'IAEA-C2': 0.6409, 'MERCK': 0.5135} - Nominal_D4x = {'ETH-1': 0.2052, 'ETH-2': 0.2085, 'ETH-3': 0.6132, 'ETH-4': 0.4511, 'IAEA-C1': 0.3018, 'IAEA-C2': 0.6409, 'MERCK': 0.5135} +
    - +

    Nominal Δ47 values assigned to the Δ47 anchor samples, used by D47data.standardize() to normalize unknown samples to an absolute Δ47 @@ -10726,7 +10714,8 @@

    Inherited Members

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

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

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

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

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

    @@ -10966,22 +10945,22 @@
    Inherited Members
    -
    #   + +
    + + D48data(l=[], **kwargs) - - D48data(l=[], **kwargs) -
    + -
    - View Source -
    	def __init__(self, l = [], **kwargs):
    -		'''
    -		**Parameters:** same as `D4xdata.__init__()`
    -		'''
    -		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
    -
    +
    + +
    3047	def __init__(self, l = [], **kwargs):
    +3048		'''
    +3049		**Parameters:** same as `D4xdata.__init__()`
    +3050		'''
    +3051		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
    +
    -

    Parameters: same as D4xdata.__init__()

    @@ -10989,11 +10968,13 @@
    Inherited Members
    -
    #   +
    + Nominal_D4x = +{'ETH-1': 0.138, 'ETH-2': 0.138, 'ETH-3': 0.27, 'ETH-4': 0.223, 'GU-1': -0.419} - Nominal_D4x = {'ETH-1': 0.138, 'ETH-2': 0.138, 'ETH-3': 0.27, 'ETH-4': 0.223, 'GU-1': -0.419} +
    - +

    Nominal Δ48 values assigned to the Δ48 anchor samples, used by D48data.standardize() to normalize unknown samples to an absolute Δ48 @@ -11002,27 +10983,19 @@

    Inherited Members

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

    -
    {
    +
    +
    {
             'ETH-1' :  0.138,
             'ETH-2' :  0.138,
             'ETH-3' :  0.270,
             'ETH-4' :  0.223,
             'GU-1'  : -0.419,
     }
    -
    +
    +
    -
    -
    -
    #   - - Nominal_D48 -
    - - - -
    Inherited Members
    From 533c41537c6be487b4eedf0104bdc2e0a426717e Mon Sep 17 00:00:00 2001 From: mdaeron Date: Thu, 11 May 2023 16:24:11 +0200 Subject: [PATCH 10/16] Refresh docs --- docs/index.html | 8142 ----------------------------------------------- 1 file changed, 8142 deletions(-) diff --git a/docs/index.html b/docs/index.html index 14eb9c7..fd81ee0 100644 --- a/docs/index.html +++ b/docs/index.html @@ -32,10 +32,7 @@

    Contents

  • 2.2 Control data quality
  • 2.3 Use a different set of anchors, change anchor nominal values, and/or change oxygen-17 correction parameters
  • 2.4 Process paired Δ47 and Δ48 values
  • -<<<<<<< HEAD -=======
  • API Documentation
  • ->>>>>>> master @@ -791,11 +788,8 @@

    2.4 Process paired Δ -<<<<<<< HEAD -=======

    API Documentation

    ->>>>>>> master

    @@ -815,3053 +809,6 @@

    API Documentation

    11 12.. include:: ../docs/tutorial.md 13.. include:: ../docs/howto.md -<<<<<<< HEAD - 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 -======= 14 15## API Documentation 16''' @@ -6909,7 +3856,6 @@

    API Documentation

    3058 ''' 3059 def __init__(self): 3060 pass ->>>>>>> master
    @@ -6925,15 +3871,6 @@

    API Documentation

    -<<<<<<< HEAD -
    61def fCO2eqD47_Petersen(T):
    -62	'''
    -63	CO2 equilibrium Δ47 value as a function of T (in degrees C)
    -64	according to [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127).
    -65
    -66	'''
    -67	return float(_fCO2eqD47_Petersen(T))
    -=======
                 
    63def fCO2eqD47_Petersen(T):
     64	'''
     65	CO2 equilibrium Δ47 value as a function of T (in degrees C)
    @@ -6941,7 +3878,6 @@ 

    API Documentation

    67 68 ''' 69 return float(_fCO2eqD47_Petersen(T)) ->>>>>>> master
    @@ -6962,15 +3898,6 @@

    API Documentation

    -<<<<<<< HEAD -
    72def fCO2eqD47_Wang(T):
    -73	'''
    -74	CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
    -75	according to [Wang et al. (2004)](https://doi.org/10.1016/j.gca.2004.05.039)
    -76	(supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)).
    -77	'''
    -78	return float(_fCO2eqD47_Wang(T))
    -=======
                 
    74def fCO2eqD47_Wang(T):
     75	'''
     76	CO2 equilibrium Δ47 value as a function of `T` (in degrees C)
    @@ -6978,7 +3905,6 @@ 

    API Documentation

    78 (supplementary data of [Dennis et al., 2011](https://doi.org/10.1016/j.gca.2011.09.025)). 79 ''' 80 return float(_fCO2eqD47_Wang(T)) ->>>>>>> master
    @@ -7000,25 +3926,6 @@

    API Documentation

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

    API Documentation

    97 if w is None: 98 w = [1 for x in X] 99 return np.dot(w,X), (np.dot(w,np.dot(C,w)))**.5 ->>>>>>> master
    @@ -7063,37 +3969,6 @@

    API Documentation

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

    API Documentation

    123 ``` 124 ''' 125 return vsep.join([hsep.join(l) for l in x]) ->>>>>>> master
    @@ -7166,19 +4040,11 @@

    API Documentation

    -<<<<<<< HEAD -
    126def pf(txt):
    -127	'''
    -128	Modify string `txt` to follow `lmfit.Parameter()` naming rules.
    -129	'''
    -130	return txt.replace('-','_').replace('.','_').replace(' ','_')
    -=======
                 
    128def pf(txt):
     129	'''
     130	Modify string `txt` to follow `lmfit.Parameter()` naming rules.
     131	'''
     132	return txt.replace('-','_').replace('.','_').replace(' ','_')
    ->>>>>>> master
     
    @@ -7198,21 +4064,6 @@

    API Documentation

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

    API Documentation

    145 if '.' not in x: 146 return int(y) 147 return y ->>>>>>> master
    @@ -7248,51 +4098,6 @@

    API Documentation

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

    API Documentation

    190 txt += [sepline] 191 txt += [''] 192 return '\n'.join(txt) ->>>>>>> master
    @@ -7379,29 +4183,6 @@

    API Documentation

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

    API Documentation

    208 ``` 209 ''' 210 return [[e for e in c] for c in zip(*x)] ->>>>>>> master
    @@ -7457,37 +4237,6 @@

    API Documentation

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

    API Documentation

    239 Xavg = sum([ w*x for w,x in zip(W,X) ]) 240 sXavg = sum([ w**2*sx**2 for w,sx in zip(W,sX) ])**.5 241 return Xavg, sXavg ->>>>>>> master
    @@ -7558,28 +4306,6 @@

    API Documentation

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

    API Documentation

    261 sep = sorted(',;\t', key = lambda x: - txt.count(x))[0] 262 txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()] 263 return [{k: smart_type(v) for k,v in zip(txt[0], l) if v} for l in txt[1:]] ->>>>>>> master
    @@ -7631,152 +4356,6 @@

    API Documentation

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

    API Documentation

    407 d48 = d48, 408 d49 = d49, 409 ) ->>>>>>> master
    @@ -7963,232 +4541,6 @@

    API Documentation

    -<<<<<<< HEAD -
    410def virtual_data(
    -411	samples = [],
    -412	a47 = 1., b47 = 0., c47 = -0.9,
    -413	a48 = 1., b48 = 0., c48 = -0.45,
    -414	rD47 = 0.015, rD48 = 0.045,
    -415	d13Cwg_VPDB = None, d18Owg_VSMOW = None,
    -416	session = None,
    -417	Nominal_D47 = None, Nominal_D48 = None,
    -418	Nominal_d13C_VPDB = None, Nominal_d18O_VPDB = None,
    -419	ALPHA_18O_ACID_REACTION = None,
    -420	R13_VPDB = None,
    -421	R17_VSMOW = None,
    -422	R18_VSMOW = None,
    -423	LAMBDA_17 = None,
    -424	R18_VPDB = None,
    -425	seed = 0,
    -426	):
    -427	'''
    -428	Return list with simulated analyses from a single session.
    -429	
    -430	**Parameters**
    -431	
    -432	+ `samples`: a list of entries; each entry is a dictionary with the following fields:
    -433	    * `Sample`: the name of the sample
    -434	    * `d13C_VPDB`, `d18O_VPDB`: bulk composition of the carbonate sample
    -435	    * `D47`, `D48`, `D49`, `D17O` (all optional): clumped-isotope and oxygen-17 anomalies of the carbonate sample
    -436	    * `N`: how many analyses to generate for this sample
    -437	+ `a47`: scrambling factor for Δ47
    -438	+ `b47`: compositional nonlinearity for Δ47
    -439	+ `c47`: working gas offset for Δ47
    -440	+ `a48`: scrambling factor for Δ48
    -441	+ `b48`: compositional nonlinearity for Δ48
    -442	+ `c48`: working gas offset for Δ48
    -443	+ `rD47`: analytical repeatability of Δ47
    -444	+ `rD48`: analytical repeatability of Δ48
    -445	+ `d13Cwg_VPDB`, `d18Owg_VSMOW`: bulk composition of the working gas
    -446		(by default equal to the `simulate_single_analysis` default values)
    -447	+ `session`: name of the session (no name by default)
    -448	+ `Nominal_D47`, `Nominal_D48`: where to lookup Δ47 and Δ48 values
    -449		if `D47` or `D48` are not specified (by default equal to the `simulate_single_analysis` defaults)
    -450	+ `Nominal_d13C_VPDB`, `Nominal_d18O_VPDB`: where to lookup δ13C and
    -451		δ18O values if `d13C_VPDB` or `d18O_VPDB` are not specified 
    -452		(by default equal to the `simulate_single_analysis` defaults)
    -453	+ `ALPHA_18O_ACID_REACTION`: 18O/16O acid fractionation factor
    -454		(by default equal to the `simulate_single_analysis` defaults)
    -455	+ `R13_VPDB`, `R17_VSMOW`, `R18_VSMOW`, `LAMBDA_17`, `R18_VPDB`: oxygen-17
    -456		correction parameters (by default equal to the `simulate_single_analysis` default)
    -457	+ `seed`: explicitly set to a non-zero value to achieve random but repeatable simulations
    -458	
    -459		
    -460	Here is an example of using this method to generate an arbitrary combination of
    -461	anchors and unknowns for a bunch of sessions:
    -462
    -463	```py
    -464	args = dict(
    -465		samples = [
    -466			dict(Sample = 'ETH-1', N = 4),
    -467			dict(Sample = 'ETH-2', N = 5),
    -468			dict(Sample = 'ETH-3', N = 6),
    -469			dict(Sample = 'FOO', N = 2,
    -470				d13C_VPDB = -5., d18O_VPDB = -10.,
    -471				D47 = 0.3, D48 = 0.15),
    -472			], rD47 = 0.010, rD48 = 0.030)
    -473
    -474	session1 = virtual_data(session = 'Session_01', **args, seed = 123)
    -475	session2 = virtual_data(session = 'Session_02', **args, seed = 1234)
    -476	session3 = virtual_data(session = 'Session_03', **args, seed = 12345)
    -477	session4 = virtual_data(session = 'Session_04', **args, seed = 123456)
    -478
    -479	D = D47data(session1 + session2 + session3 + session4)
    -480
    -481	D.crunch()
    -482	D.standardize()
    -483
    -484	D.table_of_sessions(verbose = True, save_to_file = False)
    -485	D.table_of_samples(verbose = True, save_to_file = False)
    -486	D.table_of_analyses(verbose = True, save_to_file = False)
    -487	```
    -488	
    -489	This should output something like:
    -490	
    -491	```
    -492	[table_of_sessions] 
    -493	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    -494	Session     Na  Nu  d13Cwg_VPDB  d18Owg_VSMOW  r_d13C  r_d18O   r_D47         a ± SE    1e3 x b ± SE          c ± SE
    -495	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    -496	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
    -497	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
    -498	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
    -499	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
    -500	––––––––––  ––  ––  –––––––––––  ––––––––––––  ––––––  ––––––  ––––––  –––––––––––––  ––––––––––––––  ––––––––––––––
    -501
    -502	[table_of_samples] 
    -503	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    -504	Sample   N  d13C_VPDB  d18O_VSMOW     D47      SE    95% CL      SD  p_Levene
    -505	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    -506	ETH-1   16       2.02       37.02  0.2052                    0.0079          
    -507	ETH-2   20     -10.17       19.88  0.2085                    0.0100          
    -508	ETH-3   24       1.71       37.45  0.6132                    0.0105          
    -509	FOO      8      -5.00       28.91  0.2989  0.0040  ± 0.0080  0.0101     0.638
    -510	––––––  ––  –––––––––  ––––––––––  ––––––  ––––––  ––––––––  ––––––  ––––––––
    -511
    -512	[table_of_analyses] 
    -513	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    -514	UID     Session  Sample  d13Cwg_VPDB  d18Owg_VSMOW        d45        d46         d47         d48         d49   d13C_VPDB  d18O_VSMOW     D47raw     D48raw     D49raw       D47
    -515	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    -516	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
    -517	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
    -518	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
    -519	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
    -520	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
    -521	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
    -522	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
    -523	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
    -524	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
    -525	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
    -526	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
    -527	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
    -528	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
    -529	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
    -530	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
    -531	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
    -532	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
    -533	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
    -534	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
    -535	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
    -536	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
    -537	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
    -538	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
    -539	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
    -540	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
    -541	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
    -542	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
    -543	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
    -544	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
    -545	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
    -546	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
    -547	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
    -548	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
    -549	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
    -550	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
    -551	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
    -552	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
    -553	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
    -554	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
    -555	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
    -556	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
    -557	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
    -558	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
    -559	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
    -560	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
    -561	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
    -562	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
    -563	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
    -564	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
    -565	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
    -566	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
    -567	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
    -568	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
    -569	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
    -570	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
    -571	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
    -572	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
    -573	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
    -574	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
    -575	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
    -576	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
    -577	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
    -578	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
    -579	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
    -580	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
    -581	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
    -582	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
    -583	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
    -584	–––  ––––––––––  ––––––  –––––––––––  ––––––––––––  –––––––––  –––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  ––––––––––  –––––––––  –––––––––  –––––––––  ––––––––
    -585	```
    -586	'''
    -587	
    -588	kwargs = locals().copy()
    -589
    -590	from numpy import random as nprandom
    -591	if seed:
    -592		rng = nprandom.default_rng(seed)
    -593	else:
    -594		rng = nprandom.default_rng()
    -595	
    -596	N = sum([s['N'] for s in samples])
    -597	errors47 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    -598	errors47 *= rD47 / stdev(errors47) # scale errors to rD47
    -599	errors48 = rng.normal(loc = 0, scale = 1, size = N) # generate random measurement errors
    -600	errors48 *= rD48 / stdev(errors48) # scale errors to rD48
    -601	
    -602	k = 0
    -603	out = []
    -604	for s in samples:
    -605		kw = {}
    -606		kw['sample'] = s['Sample']
    -607		kw = {
    -608			**kw,
    -609			**{var: kwargs[var]
    -610				for var in [
    -611					'd13Cwg_VPDB', 'd18Owg_VSMOW', 'ALPHA_18O_ACID_REACTION',
    -612					'Nominal_D47', 'Nominal_D48', 'Nominal_d13C_VPDB', 'Nominal_d18O_VPDB',
    -613					'R13_VPDB', 'R17_VSMOW', 'R18_VSMOW', 'LAMBDA_17', 'R18_VPDB',
    -614					'a47', 'b47', 'c47', 'a48', 'b48', 'c48',
    -615					]
    -616				if kwargs[var] is not None},
    -617			**{var: s[var]
    -618				for var in ['d13C_VPDB', 'd18O_VPDB', 'D47', 'D48', 'D49', 'D17O']
    -619				if var in s},
    -620			}
    -621
    -622		sN = s['N']
    -623		while sN:
    -624			out.append(simulate_single_analysis(**kw))
    -625			out[-1]['d47'] += errors47[k] * a47
    -626			out[-1]['d48'] += errors48[k] * a48
    -627			sN -= 1
    -628			k += 1
    -629
    -630		if session is not None:
    -631			for r in out:
    -632				r['Session'] = session
    -633	return out
    -=======
                 
    412def virtual_data(
     413	samples = [],
     414	a47 = 1., b47 = 0., c47 = -0.9,
    @@ -8413,7 +4765,6 @@ 

    API Documentation

    633 for r in out: 634 r['Session'] = session 635 return out ->>>>>>> master
    @@ -8593,71 +4944,6 @@

    API Documentation

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

    API Documentation

    697 return out 698 elif output == 'pretty': 699 return pretty_table(out) ->>>>>>> master
    @@ -8756,77 +5041,6 @@

    API Documentation

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

    API Documentation

    768 return out 769 elif output == 'pretty': 770 return pretty_table(out) ->>>>>>> master
    @@ -8933,83 +5146,6 @@

    API Documentation

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

    API Documentation

    845 return out 846 elif output == 'pretty': 847 return pretty_table(out) ->>>>>>> master
    @@ -9124,2069 +5259,6 @@

    API Documentation

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

    API Documentation

    2908 return ppl.gca() 2909 elif output == 'fig': 2910 return fig ->>>>>>> master
    @@ -13267,29 +7338,6 @@

    API Documentation

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

    API Documentation

    990 self.Nf = None 991 self.repeatability = {} 992 self.refresh(session = session) ->>>>>>> master
    @@ -13561,26 +7608,6 @@

    API Documentation

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

    API Documentation

    1010 myself.verbose = oldverbose 1011 return out 1012 return newfun ->>>>>>> master
    @@ -13619,15 +7645,6 @@

    API Documentation

    -<<<<<<< HEAD -
    1013	def msg(self, txt):
    -1014		'''
    -1015		Log a message to `self.logfile`, and print it out if `verbose = True`
    -1016		'''
    -1017		self.log(txt)
    -1018		if self.verbose:
    -1019			print(f'{f"[{self.prefix}]":<16} {txt}')
    -=======
                 
    1015	def msg(self, txt):
     1016		'''
     1017		Log a message to `self.logfile`, and print it out if `verbose = True`
    @@ -13635,7 +7652,6 @@ 

    API Documentation

    1019 self.log(txt) 1020 if self.verbose: 1021 print(f'{f"[{self.prefix}]":<16} {txt}') ->>>>>>> master
    @@ -13655,21 +7671,12 @@

    API Documentation

    -<<<<<<< HEAD -
    1022	def vmsg(self, txt):
    -1023		'''
    -1024		Log a message to `self.logfile` and print it out
    -1025		'''
    -1026		self.log(txt)
    -1027		print(txt)
    -=======
                 
    1024	def vmsg(self, txt):
     1025		'''
     1026		Log a message to `self.logfile` and print it out
     1027		'''
     1028		self.log(txt)
     1029		print(txt)
    ->>>>>>> master
     
    @@ -13689,16 +7696,6 @@

    API Documentation

    -<<<<<<< HEAD -
    1030	def log(self, *txts):
    -1031		'''
    -1032		Log a message to `self.logfile`
    -1033		'''
    -1034		if self.logfile:
    -1035			with open(self.logfile, 'a') as fid:
    -1036				for txt in txts:
    -1037					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
    -=======
                 
    1032	def log(self, *txts):
     1033		'''
     1034		Log a message to `self.logfile`
    @@ -13707,7 +7704,6 @@ 

    API Documentation

    1037 with open(self.logfile, 'a') as fid: 1038 for txt in txts: 1039 fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}') ->>>>>>> master
    @@ -13727,15 +7723,6 @@

    API Documentation

    -<<<<<<< HEAD -
    1040	def refresh(self, session = 'mySession'):
    -1041		'''
    -1042		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    -1043		'''
    -1044		self.fill_in_missing_info(session = session)
    -1045		self.refresh_sessions()
    -1046		self.refresh_samples()
    -=======
                 
    1042	def refresh(self, session = 'mySession'):
     1043		'''
     1044		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    @@ -13743,7 +7730,6 @@ 

    API Documentation

    1046 self.fill_in_missing_info(session = session) 1047 self.refresh_sessions() 1048 self.refresh_samples() ->>>>>>> master
    @@ -13763,23 +7749,6 @@

    API Documentation

    -<<<<<<< HEAD -
    1049	def refresh_sessions(self):
    -1050		'''
    -1051		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    -1052		to `False` for all sessions.
    -1053		'''
    -1054		self.sessions = {
    -1055			s: {'data': [r for r in self if r['Session'] == s]}
    -1056			for s in sorted({r['Session'] for r in self})
    -1057			}
    -1058		for s in self.sessions:
    -1059			self.sessions[s]['scrambling_drift'] = False
    -1060			self.sessions[s]['slope_drift'] = False
    -1061			self.sessions[s]['wg_drift'] = False
    -1062			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    -1063			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
    -=======
                 
    1051	def refresh_sessions(self):
     1052		'''
     1053		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    @@ -13795,7 +7764,6 @@ 

    API Documentation

    1063 self.sessions[s]['wg_drift'] = False 1064 self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD 1065 self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD ->>>>>>> master
    @@ -13816,18 +7784,6 @@

    API Documentation

    -<<<<<<< HEAD -
    1066	def refresh_samples(self):
    -1067		'''
    -1068		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    -1069		'''
    -1070		self.samples = {
    -1071			s: {'data': [r for r in self if r['Sample'] == s]}
    -1072			for s in sorted({r['Sample'] for r in self})
    -1073			}
    -1074		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    -1075		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
    -=======
                 
    1068	def refresh_samples(self):
     1069		'''
     1070		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    @@ -13838,7 +7794,6 @@ 

    API Documentation

    1075 } 1076 self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x} 1077 self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x} ->>>>>>> master
    @@ -13853,39 +7808,6 @@

    API Documentation

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

    API Documentation

    1103 ''' 1104 with open(filename) as fid: 1105 self.input(fid.read(), sep = sep, session = session) ->>>>>>> master
    @@ -13956,49 +7877,6 @@

    API Documentation

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

    API Documentation

    1141 1142 self += data 1143 self.refresh() ->>>>>>> master
    @@ -14086,97 +7963,6 @@

    API Documentation

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

    API Documentation

    1232 for r in self.sessions[s]['data']: 1233 r['d13Cwg_VPDB'] = d13Cwg_VPDB 1234 r['d18Owg_VSMOW'] = d18Owg_VSMOW ->>>>>>> master
    @@ -14283,43 +8068,6 @@

    API Documentation

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

    API Documentation

    1264 d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1) 1265 1266 return d13C_VPDB, d18O_VSMOW ->>>>>>> master
    @@ -14373,33 +8120,14 @@

    API Documentation

    @make_verbal
    -<<<<<<< HEAD - - def - crunch(self, verbose=''): - -======= def crunch(self, verbose=''): ->>>>>>> master
    -<<<<<<< HEAD -
    1267	@make_verbal
    -1268	def crunch(self, verbose = ''):
    -1269		'''
    -1270		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    -1271		'''
    -1272		for r in self:
    -1273			self.compute_bulk_and_clumping_deltas(r)
    -1274		self.standardize_d13C()
    -1275		self.standardize_d18O()
    -1276		self.msg(f"Crunched {len(self)} analyses.")
    -=======
                 
    1269	@make_verbal
     1270	def crunch(self, verbose = ''):
     1271		'''
    @@ -14410,7 +8138,6 @@ 

    API Documentation

    1276 self.standardize_d13C() 1277 self.standardize_d18O() 1278 self.msg(f"Crunched {len(self)} analyses.") ->>>>>>> master
    @@ -14430,22 +8157,6 @@

    API Documentation

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

    API Documentation

    1292 for k in ['d47', 'd48', 'd49']: 1293 if k not in r: 1294 r[k] = np.nan ->>>>>>> master
    @@ -14480,27 +8190,6 @@

    API Documentation

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

    API Documentation

    1313 a,b = np.polyfit(X,Y,1) 1314 for r in self.sessions[s]['data']: 1315 r['d13C_VPDB'] = a * r['d13C_VPDB'] + b ->>>>>>> master
    @@ -14543,28 +8231,6 @@

    API Documentation

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

    API Documentation

    1334 a,b = np.polyfit(X,Y,1) 1335 for r in self.sessions[s]['data']: 1336 r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b ->>>>>>> master
    @@ -14608,45 +8273,6 @@

    API Documentation

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

    API Documentation

    1373 r['D47raw'] = 1000 * (R47 / R47stoch - 1) 1374 r['D48raw'] = 1000 * (R48 / R48stoch - 1) 1375 r['D49raw'] = 1000 * (R49 / R49stoch - 1) ->>>>>>> master
    @@ -14704,53 +8329,6 @@

    API Documentation

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

    API Documentation

    1420 1421 # Return isobar ratios 1422 return R45, R46, R47, R48, R49 ->>>>>>> master
    @@ -14818,32 +8395,6 @@

    API Documentation

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

    API Documentation

    1446 elif r['Sample'] in self.unknowns: 1447 r['Sample_original'] = r['Sample'] 1448 self.refresh_samples() ->>>>>>> master
    @@ -14897,63 +8447,6 @@

    API Documentation

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

    API Documentation

    1503 if tables: 1504 self.table_of_analyses() 1505 self.table_of_samples() ->>>>>>> master
    @@ -15037,27 +8529,6 @@

    API Documentation

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

    API Documentation

    1523 t0 = (len(sdata)-1)/2 1524 for t,r in enumerate(sdata): 1525 r['t'] = t - t0 ->>>>>>> master
    @@ -15102,21 +8572,12 @@

    API Documentation

    -<<<<<<< HEAD -
    1526	def report(self):
    -1527		'''
    -1528		Prints a report on the standardization fit.
    -1529		Only applicable after `D4xdata.standardize(method='pooled')`.
    -1530		'''
    -1531		report_fit(self.standardization)
    -=======
                 
    1528	def report(self):
     1529		'''
     1530		Prints a report on the standardization fit.
     1531		Only applicable after `D4xdata.standardize(method='pooled')`.
     1532		'''
     1533		report_fit(self.standardization)
    ->>>>>>> master
     
    @@ -15137,45 +8598,6 @@

    API Documentation

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

    API Documentation

    1570 CM_new = W @ CM_old @ W.T 1571 1572 return groups, D4x_new[:,0], CM_new ->>>>>>> master
    @@ -15260,226 +8681,6 @@

    API Documentation

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

    API Documentation

    1790 1791 if consolidate: 1792 self.consolidate(tables = consolidate_tables, plots = consolidate_plots) ->>>>>>> master
    @@ -15724,35 +8924,6 @@

    API Documentation

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

    API Documentation

    1819 V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2]) 1820 sx = (V @ CM @ V.T) ** .5 1821 return sx ->>>>>>> master
    @@ -15802,47 +8972,6 @@

    API Documentation

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

    API Documentation

    1860 fid.write(make_csv(out)) 1861 if print_out: 1862 self.msg('\n' + pretty_table(out, header = 0)) ->>>>>>> master
    @@ -15912,83 +9040,6 @@

    API Documentation

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

    API Documentation

    1937 return out 1938 elif output == 'pretty': 1939 return pretty_table(out) ->>>>>>> master
    @@ -16097,65 +9147,6 @@

    API Documentation

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

    API Documentation

    1996 if print_out: 1997 self.msg('\n' + pretty_table(out)) 1998 return out ->>>>>>> master
    @@ -16246,58 +9236,6 @@

    API Documentation

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

    API Documentation

    2047 return out 2048 elif output == 'pretty': 2049 return pretty_table(out) ->>>>>>> master
    @@ -16382,66 +9319,6 @@

    API Documentation

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

    API Documentation

    2106 return out 2107 elif output == 'pretty': 2108 return pretty_table(out) ->>>>>>> master
    @@ -16532,24 +9408,6 @@

    API Documentation

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

    API Documentation

    2124 sp = self.plot_single_session(session, xylimits = 'constant') 2125 ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf') 2126 ppl.close(sp.fig) ->>>>>>> master
    @@ -16594,84 +9451,6 @@

    API Documentation

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

    API Documentation

    2202 wsum = sum([weights[s] for s in weights]) 2203 for s in weights: 2204 self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum] ->>>>>>> master
    @@ -16794,129 +9572,6 @@

    API Documentation

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

    API Documentation

    2325 2326 elif self.standardization_method == 'indep_sessions': 2327 pass # Not implemented yet ->>>>>>> master
    @@ -17083,21 +9737,6 @@

    API Documentation

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

    API Documentation

    2340 self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors') 2341 self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns') 2342 self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples') ->>>>>>> master
    @@ -17133,25 +9771,6 @@

    API Documentation

    -<<<<<<< HEAD -
    2343	@make_verbal
    -2344	def consolidate(self, tables = True, plots = True):
    -2345		'''
    -2346		Collect information about samples, sessions and repeatabilities.
    -2347		'''
    -2348		self.consolidate_samples()
    -2349		self.consolidate_sessions()
    -2350		self.repeatabilities()
    -2351
    -2352		if tables:
    -2353			self.summary()
    -2354			self.table_of_sessions()
    -2355			self.table_of_analyses()
    -2356			self.table_of_samples()
    -2357
    -2358		if plots:
    -2359			self.plot_sessions()
    -=======
                 
    2345	@make_verbal
     2346	def consolidate(self, tables = True, plots = True):
     2347		'''
    @@ -17169,7 +9788,6 @@ 

    API Documentation

    2359 2360 if plots: 2361 self.plot_sessions() ->>>>>>> master
    @@ -17190,42 +9808,6 @@

    API Documentation

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

    API Documentation

    2395 r = (chisq / Nf)**.5 if Nf > 0 else 0 2396 self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.') 2397 return {'rmswd': r, 'chisq': chisq, 'Nf': Nf} ->>>>>>> master
    @@ -17285,54 +9866,6 @@

    API Documentation

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

    API Documentation

    2443 2444 self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.') 2445 return r ->>>>>>> master
    @@ -17399,48 +9931,6 @@

    API Documentation

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

    API Documentation

    2484 return correlated_sum(X, C, weights) 2485 except ValueError: 2486 return (0., 0.) ->>>>>>> master
    @@ -17523,46 +10012,6 @@

    API Documentation

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

    API Documentation

    2524 @ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T 2525 ) / a**2 2526 return float(c) ->>>>>>> master
    @@ -17625,21 +10073,6 @@

    API Documentation

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

    API Documentation

    2538 / self.unknowns[sample1][f'SE_D{self._4x}'] 2539 / self.unknowns[sample2][f'SE_D{self._4x}'] 2540 ) ->>>>>>> master
    @@ -17675,106 +10107,6 @@

    API Documentation

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

    API Documentation

    2637 out.ax = ppl.gca() 2638 2639 return out ->>>>>>> master
    @@ -17893,195 +10224,6 @@

    API Documentation

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

    API Documentation

    2825 filename = f'D{self._4x}_residuals.pdf' 2826 ppl.savefig(f'{dir}/{filename}') 2827 ppl.close(fig) ->>>>>>> master
    @@ -18299,19 +10440,11 @@

    API Documentation

    -<<<<<<< HEAD -
    2828	def simulate(self, *args, **kwargs):
    -2829		'''
    -2830		Legacy function with warning message pointing to `virtual_data()`
    -2831		'''
    -2832		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
    -=======
                 
    2830	def simulate(self, *args, **kwargs):
     2831		'''
     2832		Legacy function with warning message pointing to `virtual_data()`
     2833		'''
     2834		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
    ->>>>>>> master
     
    @@ -18331,83 +10464,6 @@

    API Documentation

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

    API Documentation

    2908 return ppl.gca() 2909 elif output == 'fig': 2910 return fig ->>>>>>> master
    @@ -18529,96 +10584,6 @@
    Inherited Members
    -<<<<<<< HEAD -
    2911class D47data(D4xdata):
    -2912	'''
    -2913	Store and process data for a large set of Δ47 analyses,
    -2914	usually comprising more than one analytical session.
    -2915	'''
    -2916
    -2917	Nominal_D4x = {
    -2918		'ETH-1':   0.2052,
    -2919		'ETH-2':   0.2085,
    -2920		'ETH-3':   0.6132,
    -2921		'ETH-4':   0.4511,
    -2922		'IAEA-C1': 0.3018,
    -2923		'IAEA-C2': 0.6409,
    -2924		'MERCK':   0.5135,
    -2925		} # I-CDES (Bernasconi et al., 2021)
    -2926	'''
    -2927	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
    -2928	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
    -2929	reference frame.
    -2930
    -2931	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
    -2932	```py
    -2933	{
    -2934		'ETH-1'   : 0.2052,
    -2935		'ETH-2'   : 0.2085,
    -2936		'ETH-3'   : 0.6132,
    -2937		'ETH-4'   : 0.4511,
    -2938		'IAEA-C1' : 0.3018,
    -2939		'IAEA-C2' : 0.6409,
    -2940		'MERCK'   : 0.5135,
    -2941	}
    -2942	```
    -2943	'''
    -2944
    -2945
    -2946	@property
    -2947	def Nominal_D47(self):
    -2948		return self.Nominal_D4x
    -2949	
    -2950
    -2951	@Nominal_D47.setter
    -2952	def Nominal_D47(self, new):
    -2953		self.Nominal_D4x = dict(**new)
    -2954		self.refresh()
    -2955
    -2956
    -2957	def __init__(self, l = [], **kwargs):
    -2958		'''
    -2959		**Parameters:** same as `D4xdata.__init__()`
    -2960		'''
    -2961		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    -2962
    -2963
    -2964	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    -2965		'''
    -2966		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    -2967		value for that temperature, and add treat these samples as additional anchors.
    -2968
    -2969		**Parameters**
    -2970
    -2971		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    -2972		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    -2973		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    -2974		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    -2975		if `new`: keep pre-existing anchors but update them in case of conflict
    -2976		between old and new Δ47 values;
    -2977		if `old`: keep pre-existing anchors but preserve their original Δ47
    -2978		values in case of conflict.
    -2979		'''
    -2980		f = {
    -2981			'petersen': fCO2eqD47_Petersen,
    -2982			'wang': fCO2eqD47_Wang,
    -2983			}[fCo2eqD47]
    -2984		foo = {}
    -2985		for r in self:
    -2986			if 'Teq' in r:
    -2987				if r['Sample'] in foo:
    -2988					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    -2989				else:
    -2990					foo[r['Sample']] = f(r['Teq'])
    -2991			else:
    -2992					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    -2993
    -2994		if priority == 'replace':
    -2995			self.Nominal_D47 = {}
    -2996		for s in foo:
    -2997			if priority != 'old' or s not in self.Nominal_D47:
    -2998				self.Nominal_D47[s] = foo[s]
    -=======
                 
    2913class D47data(D4xdata):
     2914	'''
     2915	Store and process data for a large set of Δ47 analyses,
    @@ -18707,7 +10672,6 @@ 
    Inherited Members
    2998 for s in foo: 2999 if priority != 'old' or s not in self.Nominal_D47: 3000 self.Nominal_D47[s] = foo[s] ->>>>>>> master
    @@ -18726,19 +10690,11 @@
    Inherited Members
    -<<<<<<< HEAD -
    2957	def __init__(self, l = [], **kwargs):
    -2958		'''
    -2959		**Parameters:** same as `D4xdata.__init__()`
    -2960		'''
    -2961		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    -=======
                 
    2959	def __init__(self, l = [], **kwargs):
     2960		'''
     2961		**Parameters:** same as `D4xdata.__init__()`
     2962		'''
     2963		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    ->>>>>>> master
     
    @@ -18790,43 +10746,6 @@
    Inherited Members
    -<<<<<<< HEAD -
    2964	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    -2965		'''
    -2966		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    -2967		value for that temperature, and add treat these samples as additional anchors.
    -2968
    -2969		**Parameters**
    -2970
    -2971		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    -2972		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    -2973		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    -2974		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    -2975		if `new`: keep pre-existing anchors but update them in case of conflict
    -2976		between old and new Δ47 values;
    -2977		if `old`: keep pre-existing anchors but preserve their original Δ47
    -2978		values in case of conflict.
    -2979		'''
    -2980		f = {
    -2981			'petersen': fCO2eqD47_Petersen,
    -2982			'wang': fCO2eqD47_Wang,
    -2983			}[fCo2eqD47]
    -2984		foo = {}
    -2985		for r in self:
    -2986			if 'Teq' in r:
    -2987				if r['Sample'] in foo:
    -2988					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    -2989				else:
    -2990					foo[r['Sample']] = f(r['Teq'])
    -2991			else:
    -2992					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    -2993
    -2994		if priority == 'replace':
    -2995			self.Nominal_D47 = {}
    -2996		for s in foo:
    -2997			if priority != 'old' or s not in self.Nominal_D47:
    -2998				self.Nominal_D47[s] = foo[s]
    -=======
                 
    2966	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
     2967		'''
     2968		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    @@ -18862,7 +10781,6 @@ 
    Inherited Members
    2998 for s in foo: 2999 if priority != 'old' or s not in self.Nominal_D47: 3000 self.Nominal_D47[s] = foo[s] ->>>>>>> master
    @@ -18974,57 +10892,6 @@
    Inherited Members
    -<<<<<<< HEAD -
    3003class D48data(D4xdata):
    -3004	'''
    -3005	Store and process data for a large set of Δ48 analyses,
    -3006	usually comprising more than one analytical session.
    -3007	'''
    -3008
    -3009	Nominal_D4x = {
    -3010		'ETH-1':  0.138,
    -3011		'ETH-2':  0.138,
    -3012		'ETH-3':  0.270,
    -3013		'ETH-4':  0.223,
    -3014		'GU-1':  -0.419,
    -3015		} # (Fiebig et al., 2019, 2021)
    -3016	'''
    -3017	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
    -3018	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
    -3019	reference frame.
    -3020
    -3021	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
    -3022	Fiebig et al. (in press)):
    -3023
    -3024	```py
    -3025	{
    -3026		'ETH-1' :  0.138,
    -3027		'ETH-2' :  0.138,
    -3028		'ETH-3' :  0.270,
    -3029		'ETH-4' :  0.223,
    -3030		'GU-1'  : -0.419,
    -3031	}
    -3032	```
    -3033	'''
    -3034
    -3035
    -3036	@property
    -3037	def Nominal_D48(self):
    -3038		return self.Nominal_D4x
    -3039
    -3040	
    -3041	@Nominal_D48.setter
    -3042	def Nominal_D48(self, new):
    -3043		self.Nominal_D4x = dict(**new)
    -3044		self.refresh()
    -3045
    -3046
    -3047	def __init__(self, l = [], **kwargs):
    -3048		'''
    -3049		**Parameters:** same as `D4xdata.__init__()`
    -3050		'''
    -3051		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
    -=======
                 
    3005class D48data(D4xdata):
     3006	'''
     3007	Store and process data for a large set of Δ48 analyses,
    @@ -19074,7 +10941,6 @@ 
    Inherited Members
    3051 **Parameters:** same as `D4xdata.__init__()` 3052 ''' 3053 D4xdata.__init__(self, l = l, mass = '48', **kwargs) ->>>>>>> master
    @@ -19093,19 +10959,11 @@
    Inherited Members
    -<<<<<<< HEAD -
    3047	def __init__(self, l = [], **kwargs):
    -3048		'''
    -3049		**Parameters:** same as `D4xdata.__init__()`
    -3050		'''
    -3051		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
    -=======
                 
    3049	def __init__(self, l = [], **kwargs):
     3050		'''
     3051		**Parameters:** same as `D4xdata.__init__()`
     3052		'''
     3053		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
    ->>>>>>> master
     
    From e47b886c82b9364301d885b32342e46de9dd53ab Mon Sep 17 00:00:00 2001 From: mdaeron Date: Thu, 11 May 2023 16:27:13 +0200 Subject: [PATCH 11/16] Bump version --- D47crunch/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 23e1d4c..9692fb8 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -21,7 +21,7 @@ __copyright__ = 'Copyright (c) 2023 Mathieu Daëron' __license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause' __date__ = '2023-05-11' -__version__ = '2.0.4' +__version__ = '2.0.5.dev0' import os import numpy as np From 2c04e97d5174e29b5008daf6aef175935839bf70 Mon Sep 17 00:00:00 2001 From: mdaeron Date: Thu, 11 May 2023 23:04:54 +0200 Subject: [PATCH 12/16] Fully propagated (co)variance for constrained parameters --- D47crunch/__init__.py | 74 +- changelog.md | 6 + docs/index.html | 12578 ++++++++++---------- tests/test_covar_for_constained_params.py | 36 + 4 files changed, 6446 insertions(+), 6248 deletions(-) create mode 100755 tests/test_covar_for_constained_params.py diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 9692fb8..556ddb4 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -846,6 +846,56 @@ def table_of_analyses( return pretty_table(out) +def _fullcovar(minresult, epsilon = 0.01, named = False): + ''' + Construct full covariance matrix in the case of constrained parameters + ''' + + import asteval + + def f(values): + interp = asteval.Interpreter() + print(minresult.var_names, values) + for n,v in zip(minresult.var_names, values): + interp(f'{n} = {v}') + print(f'{n} = {v}') + for q in minresult.params: + print(q, minresult.params[q].expr) + if minresult.params[q].expr: + interp(f'{q} = {minresult.params[q].expr}') + print(f'{q} = {minresult.params[q].expr}') + print() + return np.array([interp.symtable[q] for q in minresult.params]) + + # construct Jacobian + J = np.zeros((minresult.nvarys, len(minresult.params))) + X = np.array([minresult.params[p].value for p in minresult.var_names]) + sX = np.array([minresult.params[p].stderr for p in minresult.var_names]) + + for j in range(minresult.nvarys): + x1 = [_ for _ in X] + x1[j] += epsilon * sX[j] + x2 = [_ for _ in X] + x2[j] -= epsilon * sX[j] + J[j,:] = (f(x1) - f(x2)) / (2 * epsilon * sX[j]) + + _names = [q for q in minresult.params] + _covar = J.T @ minresult.covar @ J + _se = np.diag(_covar)**.5 + _correl = _covar.copy() + for k,s in enumerate(_se): + if s: + _correl[k,:] /= s + _correl[:,k] /= s + + if named: + _covar = {i: {j:_covar[i,j] for j in minresult.params} for i in minresult.params} + _se = {i: _se[i] for i in minresult.params} + _correl = {i: {j:_correl[i,j] for j in minresult.params} for i in minresult.params} + + return _names, _covar, _se, _correl + + class D4xdata(list): ''' Store and process data for a large set of Δ47 and/or Δ48 @@ -1621,9 +1671,22 @@ def standardize(self, 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']) + 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'], + ) + if not self.sessions[session]['scrambling_drift']: + params[f'a2_{s}'].expr = '0' + if not self.sessions[session]['slope_drift']: + params[f'b2_{s}'].expr = '0' + if not self.sessions[session]['wg_drift']: + params[f'c2_{s}'].expr = '0' + for sample in self.unknowns: params.add(f'D{self._4x}_{pf(sample)}', value = 0.5) @@ -1667,8 +1730,9 @@ def residuals(p): result = M.least_squares() self.Nf = result.nfree self.t95 = tstudent.ppf(1 - 0.05/2, self.Nf) -# if self.verbose: -# report_fit(result) + new_names, new_covar, new_se = _fullcovar(result)[:3] + result.var_names = new_names + result.covar = new_covar for r in self: s = pf(r["Session"]) diff --git a/changelog.md b/changelog.md index 22d83d5..30201dd 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## v2.0.5.dev0 +*Unreleased* + +### Changes +* Under the hood: constrained parameters in pooled standardization now get fully propagated variance and covariance, allowing for truly arbitrary constraints without having book-keeping problems further down the line. + ## v2.0.4 *Released on 2023-05-11* diff --git a/docs/index.html b/docs/index.html index fd81ee0..cca813c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -819,7 +819,7 @@

    API Documentation

    21__copyright__ = 'Copyright (c) 2023 Mathieu Daëron' 22__license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause' 23__date__ = '2023-05-11' - 24__version__ = '2.0.4' + 24__version__ = '2.0.5.dev0' 25 26import os 27import numpy as np @@ -1644,2218 +1644,2282 @@

    API Documentation

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

    API Documentation

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

    API Documentation

    -
    972	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
    -973		'''
    -974		**Parameters**
    -975
    -976		+ `l`: a list of dictionaries, with each dictionary including at least the keys
    -977		`Sample`, `d45`, `d46`, and `d47` or `d48`.
    -978		+ `mass`: `'47'` or `'48'`
    -979		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
    -980		+ `session`: define session name for analyses without a `Session` key
    -981		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
    -982
    -983		Returns a `D4xdata` object derived from `list`.
    -984		'''
    -985		self._4x = mass
    -986		self.verbose = verbose
    -987		self.prefix = 'D4xdata'
    -988		self.logfile = logfile
    -989		list.__init__(self, l)
    -990		self.Nf = None
    -991		self.repeatability = {}
    -992		self.refresh(session = session)
    +            
    1022	def __init__(self, l = [], mass = '47', logfile = '', session = 'mySession', verbose = False):
    +1023		'''
    +1024		**Parameters**
    +1025
    +1026		+ `l`: a list of dictionaries, with each dictionary including at least the keys
    +1027		`Sample`, `d45`, `d46`, and `d47` or `d48`.
    +1028		+ `mass`: `'47'` or `'48'`
    +1029		+ `logfile`: if specified, write detailed logs to this file path when calling `D4xdata` methods.
    +1030		+ `session`: define session name for analyses without a `Session` key
    +1031		+ `verbose`: if `True`, print out detailed logs when calling `D4xdata` methods.
    +1032
    +1033		Returns a `D4xdata` object derived from `list`.
    +1034		'''
    +1035		self._4x = mass
    +1036		self.verbose = verbose
    +1037		self.prefix = 'D4xdata'
    +1038		self.logfile = logfile
    +1039		list.__init__(self, l)
    +1040		self.Nf = None
    +1041		self.repeatability = {}
    +1042		self.refresh(session = session)
     
    @@ -7608,24 +7686,24 @@

    API Documentation

    -
     995	def make_verbal(oldfun):
    - 996		'''
    - 997		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
    - 998		'''
    - 999		@wraps(oldfun)
    -1000		def newfun(*args, verbose = '', **kwargs):
    -1001			myself = args[0]
    -1002			oldprefix = myself.prefix
    -1003			myself.prefix = oldfun.__name__
    -1004			if verbose != '':
    -1005				oldverbose = myself.verbose
    -1006				myself.verbose = verbose
    -1007			out = oldfun(*args, **kwargs)
    -1008			myself.prefix = oldprefix
    -1009			if verbose != '':
    -1010				myself.verbose = oldverbose
    -1011			return out
    -1012		return newfun
    +            
    1045	def make_verbal(oldfun):
    +1046		'''
    +1047		Decorator: allow temporarily changing `self.prefix` and overriding `self.verbose`.
    +1048		'''
    +1049		@wraps(oldfun)
    +1050		def newfun(*args, verbose = '', **kwargs):
    +1051			myself = args[0]
    +1052			oldprefix = myself.prefix
    +1053			myself.prefix = oldfun.__name__
    +1054			if verbose != '':
    +1055				oldverbose = myself.verbose
    +1056				myself.verbose = verbose
    +1057			out = oldfun(*args, **kwargs)
    +1058			myself.prefix = oldprefix
    +1059			if verbose != '':
    +1060				myself.verbose = oldverbose
    +1061			return out
    +1062		return newfun
     
    @@ -7645,13 +7723,13 @@

    API Documentation

    -
    1015	def msg(self, txt):
    -1016		'''
    -1017		Log a message to `self.logfile`, and print it out if `verbose = True`
    -1018		'''
    -1019		self.log(txt)
    -1020		if self.verbose:
    -1021			print(f'{f"[{self.prefix}]":<16} {txt}')
    +            
    1065	def msg(self, txt):
    +1066		'''
    +1067		Log a message to `self.logfile`, and print it out if `verbose = True`
    +1068		'''
    +1069		self.log(txt)
    +1070		if self.verbose:
    +1071			print(f'{f"[{self.prefix}]":<16} {txt}')
     
    @@ -7671,12 +7749,12 @@

    API Documentation

    -
    1024	def vmsg(self, txt):
    -1025		'''
    -1026		Log a message to `self.logfile` and print it out
    -1027		'''
    -1028		self.log(txt)
    -1029		print(txt)
    +            
    1074	def vmsg(self, txt):
    +1075		'''
    +1076		Log a message to `self.logfile` and print it out
    +1077		'''
    +1078		self.log(txt)
    +1079		print(txt)
     
    @@ -7696,14 +7774,14 @@

    API Documentation

    -
    1032	def log(self, *txts):
    -1033		'''
    -1034		Log a message to `self.logfile`
    -1035		'''
    -1036		if self.logfile:
    -1037			with open(self.logfile, 'a') as fid:
    -1038				for txt in txts:
    -1039					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
    +            
    1082	def log(self, *txts):
    +1083		'''
    +1084		Log a message to `self.logfile`
    +1085		'''
    +1086		if self.logfile:
    +1087			with open(self.logfile, 'a') as fid:
    +1088				for txt in txts:
    +1089					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
     
    @@ -7723,13 +7801,13 @@

    API Documentation

    -
    1042	def refresh(self, session = 'mySession'):
    -1043		'''
    -1044		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    -1045		'''
    -1046		self.fill_in_missing_info(session = session)
    -1047		self.refresh_sessions()
    -1048		self.refresh_samples()
    +            
    1092	def refresh(self, session = 'mySession'):
    +1093		'''
    +1094		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    +1095		'''
    +1096		self.fill_in_missing_info(session = session)
    +1097		self.refresh_sessions()
    +1098		self.refresh_samples()
     
    @@ -7749,21 +7827,21 @@

    API Documentation

    -
    1051	def refresh_sessions(self):
    -1052		'''
    -1053		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    -1054		to `False` for all sessions.
    -1055		'''
    -1056		self.sessions = {
    -1057			s: {'data': [r for r in self if r['Session'] == s]}
    -1058			for s in sorted({r['Session'] for r in self})
    -1059			}
    -1060		for s in self.sessions:
    -1061			self.sessions[s]['scrambling_drift'] = False
    -1062			self.sessions[s]['slope_drift'] = False
    -1063			self.sessions[s]['wg_drift'] = False
    -1064			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    -1065			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
    +            
    1101	def refresh_sessions(self):
    +1102		'''
    +1103		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    +1104		to `False` for all sessions.
    +1105		'''
    +1106		self.sessions = {
    +1107			s: {'data': [r for r in self if r['Session'] == s]}
    +1108			for s in sorted({r['Session'] for r in self})
    +1109			}
    +1110		for s in self.sessions:
    +1111			self.sessions[s]['scrambling_drift'] = False
    +1112			self.sessions[s]['slope_drift'] = False
    +1113			self.sessions[s]['wg_drift'] = False
    +1114			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    +1115			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
     
    @@ -7784,16 +7862,16 @@

    API Documentation

    -
    1068	def refresh_samples(self):
    -1069		'''
    -1070		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    -1071		'''
    -1072		self.samples = {
    -1073			s: {'data': [r for r in self if r['Sample'] == s]}
    -1074			for s in sorted({r['Sample'] for r in self})
    -1075			}
    -1076		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    -1077		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
    +            
    1118	def refresh_samples(self):
    +1119		'''
    +1120		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    +1121		'''
    +1122		self.samples = {
    +1123			s: {'data': [r for r in self if r['Sample'] == s]}
    +1124			for s in sorted({r['Sample'] for r in self})
    +1125			}
    +1126		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    +1127		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
     
    @@ -7813,32 +7891,32 @@

    API Documentation

    -
    1080	def read(self, filename, sep = '', session = ''):
    -1081		'''
    -1082		Read file in csv format to load data into a `D47data` object.
    -1083
    -1084		In the csv file, spaces before and after field separators (`','` by default)
    -1085		are optional. Each line corresponds to a single analysis.
    -1086
    -1087		The required fields are:
    -1088
    -1089		+ `UID`: a unique identifier
    -1090		+ `Session`: an identifier for the analytical session
    -1091		+ `Sample`: a sample identifier
    -1092		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    -1093
    -1094		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    -1095		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    -1096		and `d49` are optional, and set to NaN by default.
    -1097
    -1098		**Parameters**
    -1099
    -1100		+ `fileneme`: the path of the file to read
    -1101		+ `sep`: csv separator delimiting the fields
    -1102		+ `session`: set `Session` field to this string for all analyses
    -1103		'''
    -1104		with open(filename) as fid:
    -1105			self.input(fid.read(), sep = sep, session = session)
    +            
    1130	def read(self, filename, sep = '', session = ''):
    +1131		'''
    +1132		Read file in csv format to load data into a `D47data` object.
    +1133
    +1134		In the csv file, spaces before and after field separators (`','` by default)
    +1135		are optional. Each line corresponds to a single analysis.
    +1136
    +1137		The required fields are:
    +1138
    +1139		+ `UID`: a unique identifier
    +1140		+ `Session`: an identifier for the analytical session
    +1141		+ `Sample`: a sample identifier
    +1142		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    +1143
    +1144		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    +1145		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    +1146		and `d49` are optional, and set to NaN by default.
    +1147
    +1148		**Parameters**
    +1149
    +1150		+ `fileneme`: the path of the file to read
    +1151		+ `sep`: csv separator delimiting the fields
    +1152		+ `session`: set `Session` field to this string for all analyses
    +1153		'''
    +1154		with open(filename) as fid:
    +1155			self.input(fid.read(), sep = sep, session = session)
     
    @@ -7882,42 +7960,42 @@

    API Documentation

    -
    1108	def input(self, txt, sep = '', session = ''):
    -1109		'''
    -1110		Read `txt` string in csv format to load analysis data into a `D47data` object.
    -1111
    -1112		In the csv string, spaces before and after field separators (`','` by default)
    -1113		are optional. Each line corresponds to a single analysis.
    -1114
    -1115		The required fields are:
    -1116
    -1117		+ `UID`: a unique identifier
    -1118		+ `Session`: an identifier for the analytical session
    -1119		+ `Sample`: a sample identifier
    -1120		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    -1121
    -1122		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    -1123		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    -1124		and `d49` are optional, and set to NaN by default.
    -1125
    -1126		**Parameters**
    -1127
    -1128		+ `txt`: the csv string to read
    -1129		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    -1130		whichever appers most often in `txt`.
    -1131		+ `session`: set `Session` field to this string for all analyses
    -1132		'''
    -1133		if sep == '':
    -1134			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    -1135		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    -1136		data = [{k: v if k in ['UID', 'Session', 'Sample'] else smart_type(v) for k,v in zip(txt[0], l) if v != ''} for l in txt[1:]]
    -1137
    -1138		if session != '':
    -1139			for r in data:
    -1140				r['Session'] = session
    -1141
    -1142		self += data
    -1143		self.refresh()
    +            
    1158	def input(self, txt, sep = '', session = ''):
    +1159		'''
    +1160		Read `txt` string in csv format to load analysis data into a `D47data` object.
    +1161
    +1162		In the csv string, spaces before and after field separators (`','` by default)
    +1163		are optional. Each line corresponds to a single analysis.
    +1164
    +1165		The required fields are:
    +1166
    +1167		+ `UID`: a unique identifier
    +1168		+ `Session`: an identifier for the analytical session
    +1169		+ `Sample`: a sample identifier
    +1170		+ `d45`, `d46`, and at least one of `d47` or `d48`: the working-gas delta values
    +1171
    +1172		Independently known oxygen-17 anomalies may be provided as `D17O` (in ‰ relative to
    +1173		VSMOW, λ = `self.LAMBDA_17`), and are otherwise assumed to be zero. Working-gas deltas `d47`, `d48`
    +1174		and `d49` are optional, and set to NaN by default.
    +1175
    +1176		**Parameters**
    +1177
    +1178		+ `txt`: the csv string to read
    +1179		+ `sep`: csv separator delimiting the fields. By default, use `,`, `;`, or `\t`,
    +1180		whichever appers most often in `txt`.
    +1181		+ `session`: set `Session` field to this string for all analyses
    +1182		'''
    +1183		if sep == '':
    +1184			sep = sorted(',;\t', key = lambda x: - txt.count(x))[0]
    +1185		txt = [[x.strip() for x in l.split(sep)] for l in txt.splitlines() if l.strip()]
    +1186		data = [{k: v if k in ['UID', 'Session', 'Sample'] else smart_type(v) for k,v in zip(txt[0], l) if v != ''} for l in txt[1:]]
    +1187
    +1188		if session != '':
    +1189			for r in data:
    +1190				r['Session'] = session
    +1191
    +1192		self += data
    +1193		self.refresh()
     
    @@ -7963,95 +8041,95 @@

    API Documentation

    -
    1146	@make_verbal
    -1147	def wg(self, samples = None, a18_acid = None):
    -1148		'''
    -1149		Compute bulk composition of the working gas for each session based on
    -1150		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
    -1151		`self.Nominal_d18O_VPDB`.
    -1152		'''
    -1153
    -1154		self.msg('Computing WG composition:')
    -1155
    -1156		if a18_acid is None:
    -1157			a18_acid = self.ALPHA_18O_ACID_REACTION
    -1158		if samples is None:
    -1159			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
    -1160
    -1161		assert a18_acid, f'Acid fractionation factor should not be zero.'
    -1162
    -1163		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
    -1164		R45R46_standards = {}
    -1165		for sample in samples:
    -1166			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
    -1167			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
    -1168			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
    -1169			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
    -1170			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
    -1171
    -1172			C12_s = 1 / (1 + R13_s)
    -1173			C13_s = R13_s / (1 + R13_s)
    -1174			C16_s = 1 / (1 + R17_s + R18_s)
    -1175			C17_s = R17_s / (1 + R17_s + R18_s)
    -1176			C18_s = R18_s / (1 + R17_s + R18_s)
    -1177
    -1178			C626_s = C12_s * C16_s ** 2
    -1179			C627_s = 2 * C12_s * C16_s * C17_s
    -1180			C628_s = 2 * C12_s * C16_s * C18_s
    -1181			C636_s = C13_s * C16_s ** 2
    -1182			C637_s = 2 * C13_s * C16_s * C17_s
    -1183			C727_s = C12_s * C17_s ** 2
    -1184
    -1185			R45_s = (C627_s + C636_s) / C626_s
    -1186			R46_s = (C628_s + C637_s + C727_s) / C626_s
    -1187			R45R46_standards[sample] = (R45_s, R46_s)
    -1188		
    -1189		for s in self.sessions:
    -1190			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
    -1191			assert db, f'No sample from {samples} found in session "{s}".'
    -1192# 			dbsamples = sorted({r['Sample'] for r in db})
    -1193
    -1194			X = [r['d45'] for r in db]
    -1195			Y = [R45R46_standards[r['Sample']][0] for r in db]
    -1196			x1, x2 = np.min(X), np.max(X)
    -1197
    -1198			if x1 < x2:
    -1199				wgcoord = x1/(x1-x2)
    -1200			else:
    -1201				wgcoord = 999
    -1202
    -1203			if wgcoord < -.5 or wgcoord > 1.5:
    -1204				# unreasonable to extrapolate to d45 = 0
    -1205				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    -1206			else :
    -1207				# d45 = 0 is reasonably well bracketed
    -1208				R45_wg = np.polyfit(X, Y, 1)[1]
    -1209
    -1210			X = [r['d46'] for r in db]
    -1211			Y = [R45R46_standards[r['Sample']][1] for r in db]
    -1212			x1, x2 = np.min(X), np.max(X)
    -1213
    -1214			if x1 < x2:
    -1215				wgcoord = x1/(x1-x2)
    -1216			else:
    -1217				wgcoord = 999
    -1218
    -1219			if wgcoord < -.5 or wgcoord > 1.5:
    -1220				# unreasonable to extrapolate to d46 = 0
    -1221				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    -1222			else :
    -1223				# d46 = 0 is reasonably well bracketed
    -1224				R46_wg = np.polyfit(X, Y, 1)[1]
    -1225
    -1226			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
    +            
    1196	@make_verbal
    +1197	def wg(self, samples = None, a18_acid = None):
    +1198		'''
    +1199		Compute bulk composition of the working gas for each session based on
    +1200		the carbonate standards defined in both `self.Nominal_d13C_VPDB` and
    +1201		`self.Nominal_d18O_VPDB`.
    +1202		'''
    +1203
    +1204		self.msg('Computing WG composition:')
    +1205
    +1206		if a18_acid is None:
    +1207			a18_acid = self.ALPHA_18O_ACID_REACTION
    +1208		if samples is None:
    +1209			samples = [s for s in self.Nominal_d13C_VPDB if s in self.Nominal_d18O_VPDB]
    +1210
    +1211		assert a18_acid, f'Acid fractionation factor should not be zero.'
    +1212
    +1213		samples = [s for s in samples if s in self.Nominal_d13C_VPDB and s in self.Nominal_d18O_VPDB]
    +1214		R45R46_standards = {}
    +1215		for sample in samples:
    +1216			d13C_vpdb = self.Nominal_d13C_VPDB[sample]
    +1217			d18O_vpdb = self.Nominal_d18O_VPDB[sample]
    +1218			R13_s = self.R13_VPDB * (1 + d13C_vpdb / 1000)
    +1219			R17_s = self.R17_VPDB * ((1 + d18O_vpdb / 1000) * a18_acid) ** self.LAMBDA_17
    +1220			R18_s = self.R18_VPDB * (1 + d18O_vpdb / 1000) * a18_acid
    +1221
    +1222			C12_s = 1 / (1 + R13_s)
    +1223			C13_s = R13_s / (1 + R13_s)
    +1224			C16_s = 1 / (1 + R17_s + R18_s)
    +1225			C17_s = R17_s / (1 + R17_s + R18_s)
    +1226			C18_s = R18_s / (1 + R17_s + R18_s)
     1227
    -1228			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
    -1229
    -1230			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
    -1231			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
    -1232			for r in self.sessions[s]['data']:
    -1233				r['d13Cwg_VPDB'] = d13Cwg_VPDB
    -1234				r['d18Owg_VSMOW'] = d18Owg_VSMOW
    +1228			C626_s = C12_s * C16_s ** 2
    +1229			C627_s = 2 * C12_s * C16_s * C17_s
    +1230			C628_s = 2 * C12_s * C16_s * C18_s
    +1231			C636_s = C13_s * C16_s ** 2
    +1232			C637_s = 2 * C13_s * C16_s * C17_s
    +1233			C727_s = C12_s * C17_s ** 2
    +1234
    +1235			R45_s = (C627_s + C636_s) / C626_s
    +1236			R46_s = (C628_s + C637_s + C727_s) / C626_s
    +1237			R45R46_standards[sample] = (R45_s, R46_s)
    +1238		
    +1239		for s in self.sessions:
    +1240			db = [r for r in self.sessions[s]['data'] if r['Sample'] in samples]
    +1241			assert db, f'No sample from {samples} found in session "{s}".'
    +1242# 			dbsamples = sorted({r['Sample'] for r in db})
    +1243
    +1244			X = [r['d45'] for r in db]
    +1245			Y = [R45R46_standards[r['Sample']][0] for r in db]
    +1246			x1, x2 = np.min(X), np.max(X)
    +1247
    +1248			if x1 < x2:
    +1249				wgcoord = x1/(x1-x2)
    +1250			else:
    +1251				wgcoord = 999
    +1252
    +1253			if wgcoord < -.5 or wgcoord > 1.5:
    +1254				# unreasonable to extrapolate to d45 = 0
    +1255				R45_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    +1256			else :
    +1257				# d45 = 0 is reasonably well bracketed
    +1258				R45_wg = np.polyfit(X, Y, 1)[1]
    +1259
    +1260			X = [r['d46'] for r in db]
    +1261			Y = [R45R46_standards[r['Sample']][1] for r in db]
    +1262			x1, x2 = np.min(X), np.max(X)
    +1263
    +1264			if x1 < x2:
    +1265				wgcoord = x1/(x1-x2)
    +1266			else:
    +1267				wgcoord = 999
    +1268
    +1269			if wgcoord < -.5 or wgcoord > 1.5:
    +1270				# unreasonable to extrapolate to d46 = 0
    +1271				R46_wg = np.mean([y/(1+x/1000) for x,y in zip(X,Y)])
    +1272			else :
    +1273				# d46 = 0 is reasonably well bracketed
    +1274				R46_wg = np.polyfit(X, Y, 1)[1]
    +1275
    +1276			d13Cwg_VPDB, d18Owg_VSMOW = self.compute_bulk_delta(R45_wg, R46_wg)
    +1277
    +1278			self.msg(f'Session {s} WG:   δ13C_VPDB = {d13Cwg_VPDB:.3f}   δ18O_VSMOW = {d18Owg_VSMOW:.3f}')
    +1279
    +1280			self.sessions[s]['d13Cwg_VPDB'] = d13Cwg_VPDB
    +1281			self.sessions[s]['d18Owg_VSMOW'] = d18Owg_VSMOW
    +1282			for r in self.sessions[s]['data']:
    +1283				r['d13Cwg_VPDB'] = d13Cwg_VPDB
    +1284				r['d18Owg_VSMOW'] = d18Owg_VSMOW
     
    @@ -8073,36 +8151,36 @@

    API Documentation

    -
    1237	def compute_bulk_delta(self, R45, R46, D17O = 0):
    -1238		'''
    -1239		Compute δ13C_VPDB and δ18O_VSMOW,
    -1240		by solving the generalized form of equation (17) from
    -1241		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
    -1242		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
    -1243		solving the corresponding second-order Taylor polynomial.
    -1244		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
    -1245		'''
    -1246
    -1247		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
    -1248
    -1249		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
    -1250		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
    -1251		C = 2 * self.R18_VSMOW
    -1252		D = -R46
    -1253
    -1254		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
    -1255		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
    -1256		cc = A + B + C + D
    -1257
    -1258		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
    -1259
    -1260		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
    -1261		R17 = K * R18 ** self.LAMBDA_17
    -1262		R13 = R45 - 2 * R17
    -1263
    -1264		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
    -1265
    -1266		return d13C_VPDB, d18O_VSMOW
    +            
    1287	def compute_bulk_delta(self, R45, R46, D17O = 0):
    +1288		'''
    +1289		Compute δ13C_VPDB and δ18O_VSMOW,
    +1290		by solving the generalized form of equation (17) from
    +1291		[Brand et al. (2010)](https://doi.org/10.1351/PAC-REP-09-01-05),
    +1292		assuming that δ18O_VSMOW is not too big (0 ± 50 ‰) and
    +1293		solving the corresponding second-order Taylor polynomial.
    +1294		(Appendix A of [Daëron et al., 2016](https://doi.org/10.1016/j.chemgeo.2016.08.014))
    +1295		'''
    +1296
    +1297		K = np.exp(D17O / 1000) * self.R17_VSMOW * self.R18_VSMOW ** -self.LAMBDA_17
    +1298
    +1299		A = -3 * K ** 2 * self.R18_VSMOW ** (2 * self.LAMBDA_17)
    +1300		B = 2 * K * R45 * self.R18_VSMOW ** self.LAMBDA_17
    +1301		C = 2 * self.R18_VSMOW
    +1302		D = -R46
    +1303
    +1304		aa = A * self.LAMBDA_17 * (2 * self.LAMBDA_17 - 1) + B * self.LAMBDA_17 * (self.LAMBDA_17 - 1) / 2
    +1305		bb = 2 * A * self.LAMBDA_17 + B * self.LAMBDA_17 + C
    +1306		cc = A + B + C + D
    +1307
    +1308		d18O_VSMOW = 1000 * (-bb + (bb ** 2 - 4 * aa * cc) ** .5) / (2 * aa)
    +1309
    +1310		R18 = (1 + d18O_VSMOW / 1000) * self.R18_VSMOW
    +1311		R17 = K * R18 ** self.LAMBDA_17
    +1312		R13 = R45 - 2 * R17
    +1313
    +1314		d13C_VPDB = 1000 * (R13 / self.R13_VPDB - 1)
    +1315
    +1316		return d13C_VPDB, d18O_VSMOW
     
    @@ -8128,16 +8206,16 @@

    API Documentation

    -
    1269	@make_verbal
    -1270	def crunch(self, verbose = ''):
    -1271		'''
    -1272		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    -1273		'''
    -1274		for r in self:
    -1275			self.compute_bulk_and_clumping_deltas(r)
    -1276		self.standardize_d13C()
    -1277		self.standardize_d18O()
    -1278		self.msg(f"Crunched {len(self)} analyses.")
    +            
    1319	@make_verbal
    +1320	def crunch(self, verbose = ''):
    +1321		'''
    +1322		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    +1323		'''
    +1324		for r in self:
    +1325			self.compute_bulk_and_clumping_deltas(r)
    +1326		self.standardize_d13C()
    +1327		self.standardize_d18O()
    +1328		self.msg(f"Crunched {len(self)} analyses.")
     
    @@ -8157,20 +8235,20 @@

    API Documentation

    -
    1281	def fill_in_missing_info(self, session = 'mySession'):
    -1282		'''
    -1283		Fill in optional fields with default values
    -1284		'''
    -1285		for i,r in enumerate(self):
    -1286			if 'D17O' not in r:
    -1287				r['D17O'] = 0.
    -1288			if 'UID' not in r:
    -1289				r['UID'] = f'{i+1}'
    -1290			if 'Session' not in r:
    -1291				r['Session'] = session
    -1292			for k in ['d47', 'd48', 'd49']:
    -1293				if k not in r:
    -1294					r[k] = np.nan
    +            
    1331	def fill_in_missing_info(self, session = 'mySession'):
    +1332		'''
    +1333		Fill in optional fields with default values
    +1334		'''
    +1335		for i,r in enumerate(self):
    +1336			if 'D17O' not in r:
    +1337				r['D17O'] = 0.
    +1338			if 'UID' not in r:
    +1339				r['UID'] = f'{i+1}'
    +1340			if 'Session' not in r:
    +1341				r['Session'] = session
    +1342			for k in ['d47', 'd48', 'd49']:
    +1343				if k not in r:
    +1344					r[k] = np.nan
     
    @@ -8190,25 +8268,25 @@

    API Documentation

    -
    1297	def standardize_d13C(self):
    -1298		'''
    -1299		Perform δ13C standadization within each session `s` according to
    -1300		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
    -1301		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
    -1302		may be redefined abitrarily at a later stage.
    -1303		'''
    -1304		for s in self.sessions:
    -1305			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
    -1306				XY = [(r['d13C_VPDB'], self.Nominal_d13C_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d13C_VPDB]
    -1307				X,Y = zip(*XY)
    -1308				if self.sessions[s]['d13C_standardization_method'] == '1pt':
    -1309					offset = np.mean(Y) - np.mean(X)
    -1310					for r in self.sessions[s]['data']:
    -1311						r['d13C_VPDB'] += offset				
    -1312				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
    -1313					a,b = np.polyfit(X,Y,1)
    -1314					for r in self.sessions[s]['data']:
    -1315						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
    +            
    1347	def standardize_d13C(self):
    +1348		'''
    +1349		Perform δ13C standadization within each session `s` according to
    +1350		`self.sessions[s]['d13C_standardization_method']`, which is defined by default
    +1351		by `D47data.refresh_sessions()`as equal to `self.d13C_STANDARDIZATION_METHOD`, but
    +1352		may be redefined abitrarily at a later stage.
    +1353		'''
    +1354		for s in self.sessions:
    +1355			if self.sessions[s]['d13C_standardization_method'] in ['1pt', '2pt']:
    +1356				XY = [(r['d13C_VPDB'], self.Nominal_d13C_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d13C_VPDB]
    +1357				X,Y = zip(*XY)
    +1358				if self.sessions[s]['d13C_standardization_method'] == '1pt':
    +1359					offset = np.mean(Y) - np.mean(X)
    +1360					for r in self.sessions[s]['data']:
    +1361						r['d13C_VPDB'] += offset				
    +1362				elif self.sessions[s]['d13C_standardization_method'] == '2pt':
    +1363					a,b = np.polyfit(X,Y,1)
    +1364					for r in self.sessions[s]['data']:
    +1365						r['d13C_VPDB'] = a * r['d13C_VPDB'] + b
     
    @@ -8231,26 +8309,26 @@

    API Documentation

    -
    1317	def standardize_d18O(self):
    -1318		'''
    -1319		Perform δ18O standadization within each session `s` according to
    -1320		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
    -1321		which is defined by default by `D47data.refresh_sessions()`as equal to
    -1322		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
    -1323		'''
    -1324		for s in self.sessions:
    -1325			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
    -1326				XY = [(r['d18O_VSMOW'], self.Nominal_d18O_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d18O_VPDB]
    -1327				X,Y = zip(*XY)
    -1328				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
    -1329				if self.sessions[s]['d18O_standardization_method'] == '1pt':
    -1330					offset = np.mean(Y) - np.mean(X)
    -1331					for r in self.sessions[s]['data']:
    -1332						r['d18O_VSMOW'] += offset				
    -1333				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
    -1334					a,b = np.polyfit(X,Y,1)
    -1335					for r in self.sessions[s]['data']:
    -1336						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
    +            
    1367	def standardize_d18O(self):
    +1368		'''
    +1369		Perform δ18O standadization within each session `s` according to
    +1370		`self.ALPHA_18O_ACID_REACTION` and `self.sessions[s]['d18O_standardization_method']`,
    +1371		which is defined by default by `D47data.refresh_sessions()`as equal to
    +1372		`self.d18O_STANDARDIZATION_METHOD`, but may be redefined abitrarily at a later stage.
    +1373		'''
    +1374		for s in self.sessions:
    +1375			if self.sessions[s]['d18O_standardization_method'] in ['1pt', '2pt']:
    +1376				XY = [(r['d18O_VSMOW'], self.Nominal_d18O_VPDB[r['Sample']]) for r in self.sessions[s]['data'] if r['Sample'] in self.Nominal_d18O_VPDB]
    +1377				X,Y = zip(*XY)
    +1378				Y = [(1000+y) * self.R18_VPDB * self.ALPHA_18O_ACID_REACTION / self.R18_VSMOW - 1000 for y in Y]
    +1379				if self.sessions[s]['d18O_standardization_method'] == '1pt':
    +1380					offset = np.mean(Y) - np.mean(X)
    +1381					for r in self.sessions[s]['data']:
    +1382						r['d18O_VSMOW'] += offset				
    +1383				elif self.sessions[s]['d18O_standardization_method'] == '2pt':
    +1384					a,b = np.polyfit(X,Y,1)
    +1385					for r in self.sessions[s]['data']:
    +1386						r['d18O_VSMOW'] = a * r['d18O_VSMOW'] + b
     
    @@ -8273,43 +8351,43 @@

    API Documentation

    -
    1339	def compute_bulk_and_clumping_deltas(self, r):
    -1340		'''
    -1341		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
    -1342		'''
    -1343
    -1344		# Compute working gas R13, R18, and isobar ratios
    -1345		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
    -1346		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
    -1347		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
    -1348
    -1349		# Compute analyte isobar ratios
    -1350		R45 = (1 + r['d45'] / 1000) * R45_wg
    -1351		R46 = (1 + r['d46'] / 1000) * R46_wg
    -1352		R47 = (1 + r['d47'] / 1000) * R47_wg
    -1353		R48 = (1 + r['d48'] / 1000) * R48_wg
    -1354		R49 = (1 + r['d49'] / 1000) * R49_wg
    -1355
    -1356		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
    -1357		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
    -1358		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
    -1359
    -1360		# Compute stochastic isobar ratios of the analyte
    -1361		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
    -1362			R13, R18, D17O = r['D17O']
    -1363		)
    -1364
    -1365		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
    -1366		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
    -1367		if (R45 / R45stoch - 1) > 5e-8:
    -1368			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
    -1369		if (R46 / R46stoch - 1) > 5e-8:
    -1370			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
    -1371
    -1372		# Compute raw clumped isotope anomalies
    -1373		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
    -1374		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
    -1375		r['D49raw'] = 1000 * (R49 / R49stoch - 1)
    +            
    1389	def compute_bulk_and_clumping_deltas(self, r):
    +1390		'''
    +1391		Compute δ13C_VPDB, δ18O_VSMOW, and raw Δ47, Δ48, Δ49 values for a single analysis `r`.
    +1392		'''
    +1393
    +1394		# Compute working gas R13, R18, and isobar ratios
    +1395		R13_wg = self.R13_VPDB * (1 + r['d13Cwg_VPDB'] / 1000)
    +1396		R18_wg = self.R18_VSMOW * (1 + r['d18Owg_VSMOW'] / 1000)
    +1397		R45_wg, R46_wg, R47_wg, R48_wg, R49_wg = self.compute_isobar_ratios(R13_wg, R18_wg)
    +1398
    +1399		# Compute analyte isobar ratios
    +1400		R45 = (1 + r['d45'] / 1000) * R45_wg
    +1401		R46 = (1 + r['d46'] / 1000) * R46_wg
    +1402		R47 = (1 + r['d47'] / 1000) * R47_wg
    +1403		R48 = (1 + r['d48'] / 1000) * R48_wg
    +1404		R49 = (1 + r['d49'] / 1000) * R49_wg
    +1405
    +1406		r['d13C_VPDB'], r['d18O_VSMOW'] = self.compute_bulk_delta(R45, R46, D17O = r['D17O'])
    +1407		R13 = (1 + r['d13C_VPDB'] / 1000) * self.R13_VPDB
    +1408		R18 = (1 + r['d18O_VSMOW'] / 1000) * self.R18_VSMOW
    +1409
    +1410		# Compute stochastic isobar ratios of the analyte
    +1411		R45stoch, R46stoch, R47stoch, R48stoch, R49stoch = self.compute_isobar_ratios(
    +1412			R13, R18, D17O = r['D17O']
    +1413		)
    +1414
    +1415		# Check that R45/R45stoch and R46/R46stoch are undistinguishable from 1,
    +1416		# and raise a warning if the corresponding anomalies exceed 0.02 ppm.
    +1417		if (R45 / R45stoch - 1) > 5e-8:
    +1418			self.vmsg(f'This is unexpected: R45/R45stoch - 1 = {1e6 * (R45 / R45stoch - 1):.3f} ppm')
    +1419		if (R46 / R46stoch - 1) > 5e-8:
    +1420			self.vmsg(f'This is unexpected: R46/R46stoch - 1 = {1e6 * (R46 / R46stoch - 1):.3f} ppm')
    +1421
    +1422		# Compute raw clumped isotope anomalies
    +1423		r['D47raw'] = 1000 * (R47 / R47stoch - 1)
    +1424		r['D48raw'] = 1000 * (R48 / R48stoch - 1)
    +1425		r['D49raw'] = 1000 * (R49 / R49stoch - 1)
     
    @@ -8329,51 +8407,51 @@

    API Documentation

    -
    1378	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
    -1379		'''
    -1380		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
    -1381		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
    -1382		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
    -1383		'''
    -1384
    -1385		# Compute R17
    -1386		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
    -1387
    -1388		# Compute isotope concentrations
    -1389		C12 = (1 + R13) ** -1
    -1390		C13 = C12 * R13
    -1391		C16 = (1 + R17 + R18) ** -1
    -1392		C17 = C16 * R17
    -1393		C18 = C16 * R18
    -1394
    -1395		# Compute stochastic isotopologue concentrations
    -1396		C626 = C16 * C12 * C16
    -1397		C627 = C16 * C12 * C17 * 2
    -1398		C628 = C16 * C12 * C18 * 2
    -1399		C636 = C16 * C13 * C16
    -1400		C637 = C16 * C13 * C17 * 2
    -1401		C638 = C16 * C13 * C18 * 2
    -1402		C727 = C17 * C12 * C17
    -1403		C728 = C17 * C12 * C18 * 2
    -1404		C737 = C17 * C13 * C17
    -1405		C738 = C17 * C13 * C18 * 2
    -1406		C828 = C18 * C12 * C18
    -1407		C838 = C18 * C13 * C18
    -1408
    -1409		# Compute stochastic isobar ratios
    -1410		R45 = (C636 + C627) / C626
    -1411		R46 = (C628 + C637 + C727) / C626
    -1412		R47 = (C638 + C728 + C737) / C626
    -1413		R48 = (C738 + C828) / C626
    -1414		R49 = C838 / C626
    -1415
    -1416		# Account for stochastic anomalies
    -1417		R47 *= 1 + D47 / 1000
    -1418		R48 *= 1 + D48 / 1000
    -1419		R49 *= 1 + D49 / 1000
    -1420
    -1421		# Return isobar ratios
    -1422		return R45, R46, R47, R48, R49
    +            
    1428	def compute_isobar_ratios(self, R13, R18, D17O=0, D47=0, D48=0, D49=0):
    +1429		'''
    +1430		Compute isobar ratios for a sample with isotopic ratios `R13` and `R18`,
    +1431		optionally accounting for non-zero values of Δ17O (`D17O`) and clumped isotope
    +1432		anomalies (`D47`, `D48`, `D49`), all expressed in permil.
    +1433		'''
    +1434
    +1435		# Compute R17
    +1436		R17 = self.R17_VSMOW * np.exp(D17O / 1000) * (R18 / self.R18_VSMOW) ** self.LAMBDA_17
    +1437
    +1438		# Compute isotope concentrations
    +1439		C12 = (1 + R13) ** -1
    +1440		C13 = C12 * R13
    +1441		C16 = (1 + R17 + R18) ** -1
    +1442		C17 = C16 * R17
    +1443		C18 = C16 * R18
    +1444
    +1445		# Compute stochastic isotopologue concentrations
    +1446		C626 = C16 * C12 * C16
    +1447		C627 = C16 * C12 * C17 * 2
    +1448		C628 = C16 * C12 * C18 * 2
    +1449		C636 = C16 * C13 * C16
    +1450		C637 = C16 * C13 * C17 * 2
    +1451		C638 = C16 * C13 * C18 * 2
    +1452		C727 = C17 * C12 * C17
    +1453		C728 = C17 * C12 * C18 * 2
    +1454		C737 = C17 * C13 * C17
    +1455		C738 = C17 * C13 * C18 * 2
    +1456		C828 = C18 * C12 * C18
    +1457		C838 = C18 * C13 * C18
    +1458
    +1459		# Compute stochastic isobar ratios
    +1460		R45 = (C636 + C627) / C626
    +1461		R46 = (C628 + C637 + C727) / C626
    +1462		R47 = (C638 + C728 + C737) / C626
    +1463		R48 = (C738 + C828) / C626
    +1464		R49 = C838 / C626
    +1465
    +1466		# Account for stochastic anomalies
    +1467		R47 *= 1 + D47 / 1000
    +1468		R48 *= 1 + D48 / 1000
    +1469		R49 *= 1 + D49 / 1000
    +1470
    +1471		# Return isobar ratios
    +1472		return R45, R46, R47, R48, R49
     
    @@ -8395,30 +8473,30 @@

    API Documentation

    -
    1425	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
    -1426		'''
    -1427		Split unknown samples by UID (treat all analyses as different samples)
    -1428		or by session (treat analyses of a given sample in different sessions as
    -1429		different samples).
    -1430
    -1431		**Parameters**
    -1432
    -1433		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
    -1434		+ `grouping`: `by_uid` | `by_session`
    -1435		'''
    -1436		if samples_to_split == 'all':
    -1437			samples_to_split = [s for s in self.unknowns]
    -1438		gkeys = {'by_uid':'UID', 'by_session':'Session'}
    -1439		self.grouping = grouping.lower()
    -1440		if self.grouping in gkeys:
    -1441			gkey = gkeys[self.grouping]
    -1442		for r in self:
    -1443			if r['Sample'] in samples_to_split:
    -1444				r['Sample_original'] = r['Sample']
    -1445				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
    -1446			elif r['Sample'] in self.unknowns:
    -1447				r['Sample_original'] = r['Sample']
    -1448		self.refresh_samples()
    +            
    1475	def split_samples(self, samples_to_split = 'all', grouping = 'by_session'):
    +1476		'''
    +1477		Split unknown samples by UID (treat all analyses as different samples)
    +1478		or by session (treat analyses of a given sample in different sessions as
    +1479		different samples).
    +1480
    +1481		**Parameters**
    +1482
    +1483		+ `samples_to_split`: a list of samples to split, e.g., `['IAEA-C1', 'IAEA-C2']`
    +1484		+ `grouping`: `by_uid` | `by_session`
    +1485		'''
    +1486		if samples_to_split == 'all':
    +1487			samples_to_split = [s for s in self.unknowns]
    +1488		gkeys = {'by_uid':'UID', 'by_session':'Session'}
    +1489		self.grouping = grouping.lower()
    +1490		if self.grouping in gkeys:
    +1491			gkey = gkeys[self.grouping]
    +1492		for r in self:
    +1493			if r['Sample'] in samples_to_split:
    +1494				r['Sample_original'] = r['Sample']
    +1495				r['Sample'] = f"{r['Sample']}__{r[gkey]}"
    +1496			elif r['Sample'] in self.unknowns:
    +1497				r['Sample_original'] = r['Sample']
    +1498		self.refresh_samples()
     
    @@ -8447,61 +8525,61 @@

    API Documentation

    -
    1451	def unsplit_samples(self, tables = False):
    -1452		'''
    -1453		Reverse the effects of `D47data.split_samples()`.
    -1454		
    -1455		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
    -1456		
    -1457		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
    -1458		probably use `D4xdata.combine_samples()` instead to reverse the effects of
    -1459		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
    -1460		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
    -1461		that case session-averaged Δ4x values are statistically independent).
    -1462		'''
    -1463		unknowns_old = sorted({s for s in self.unknowns})
    -1464		CM_old = self.standardization.covar[:,:]
    -1465		VD_old = self.standardization.params.valuesdict().copy()
    -1466		vars_old = self.standardization.var_names
    -1467
    -1468		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
    -1469
    -1470		Ns = len(vars_old) - len(unknowns_old)
    -1471		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
    -1472		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
    -1473
    -1474		W = np.zeros((len(vars_new), len(vars_old)))
    -1475		W[:Ns,:Ns] = np.eye(Ns)
    -1476		for u in unknowns_new:
    -1477			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
    -1478			if self.grouping == 'by_session':
    -1479				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
    -1480			elif self.grouping == 'by_uid':
    -1481				weights = [1 for s in splits]
    -1482			sw = sum(weights)
    -1483			weights = [w/sw for w in weights]
    -1484			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
    -1485
    -1486		CM_new = W @ CM_old @ W.T
    -1487		V = W @ np.array([[VD_old[k]] for k in vars_old])
    -1488		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
    -1489
    -1490		self.standardization.covar = CM_new
    -1491		self.standardization.params.valuesdict = lambda : VD_new
    -1492		self.standardization.var_names = vars_new
    -1493
    -1494		for r in self:
    -1495			if r['Sample'] in self.unknowns:
    -1496				r['Sample_split'] = r['Sample']
    -1497				r['Sample'] = r['Sample_original']
    -1498
    -1499		self.refresh_samples()
    -1500		self.consolidate_samples()
    -1501		self.repeatabilities()
    -1502
    -1503		if tables:
    -1504			self.table_of_analyses()
    -1505			self.table_of_samples()
    +            
    1501	def unsplit_samples(self, tables = False):
    +1502		'''
    +1503		Reverse the effects of `D47data.split_samples()`.
    +1504		
    +1505		This should only be used after `D4xdata.standardize()` with `method='pooled'`.
    +1506		
    +1507		After `D4xdata.standardize()` with `method='indep_sessions'`, one should
    +1508		probably use `D4xdata.combine_samples()` instead to reverse the effects of
    +1509		`D47data.split_samples()` with `grouping='by_uid'`, or `w_avg()` to reverse the
    +1510		effects of `D47data.split_samples()` with `grouping='by_sessions'` (because in
    +1511		that case session-averaged Δ4x values are statistically independent).
    +1512		'''
    +1513		unknowns_old = sorted({s for s in self.unknowns})
    +1514		CM_old = self.standardization.covar[:,:]
    +1515		VD_old = self.standardization.params.valuesdict().copy()
    +1516		vars_old = self.standardization.var_names
    +1517
    +1518		unknowns_new = sorted({r['Sample_original'] for r in self if 'Sample_original' in r})
    +1519
    +1520		Ns = len(vars_old) - len(unknowns_old)
    +1521		vars_new = vars_old[:Ns] + [f'D{self._4x}_{pf(u)}' for u in unknowns_new]
    +1522		VD_new = {k: VD_old[k] for k in vars_old[:Ns]}
    +1523
    +1524		W = np.zeros((len(vars_new), len(vars_old)))
    +1525		W[:Ns,:Ns] = np.eye(Ns)
    +1526		for u in unknowns_new:
    +1527			splits = sorted({r['Sample'] for r in self if 'Sample_original' in r and r['Sample_original'] == u})
    +1528			if self.grouping == 'by_session':
    +1529				weights = [self.samples[s][f'SE_D{self._4x}']**-2 for s in splits]
    +1530			elif self.grouping == 'by_uid':
    +1531				weights = [1 for s in splits]
    +1532			sw = sum(weights)
    +1533			weights = [w/sw for w in weights]
    +1534			W[vars_new.index(f'D{self._4x}_{pf(u)}'),[vars_old.index(f'D{self._4x}_{pf(s)}') for s in splits]] = weights[:]
    +1535
    +1536		CM_new = W @ CM_old @ W.T
    +1537		V = W @ np.array([[VD_old[k]] for k in vars_old])
    +1538		VD_new = {k:v[0] for k,v in zip(vars_new, V)}
    +1539
    +1540		self.standardization.covar = CM_new
    +1541		self.standardization.params.valuesdict = lambda : VD_new
    +1542		self.standardization.var_names = vars_new
    +1543
    +1544		for r in self:
    +1545			if r['Sample'] in self.unknowns:
    +1546				r['Sample_split'] = r['Sample']
    +1547				r['Sample'] = r['Sample_original']
    +1548
    +1549		self.refresh_samples()
    +1550		self.consolidate_samples()
    +1551		self.repeatabilities()
    +1552
    +1553		if tables:
    +1554			self.table_of_analyses()
    +1555			self.table_of_samples()
     
    @@ -8529,25 +8607,25 @@

    API Documentation

    -
    1507	def assign_timestamps(self):
    -1508		'''
    -1509		Assign a time field `t` of type `float` to each analysis.
    -1510
    -1511		If `TimeTag` is one of the data fields, `t` is equal within a given session
    -1512		to `TimeTag` minus the mean value of `TimeTag` for that session.
    -1513		Otherwise, `TimeTag` is by default equal to the index of each analysis
    -1514		in the dataset and `t` is defined as above.
    -1515		'''
    -1516		for session in self.sessions:
    -1517			sdata = self.sessions[session]['data']
    -1518			try:
    -1519				t0 = np.mean([r['TimeTag'] for r in sdata])
    -1520				for r in sdata:
    -1521					r['t'] = r['TimeTag'] - t0
    -1522			except KeyError:
    -1523				t0 = (len(sdata)-1)/2
    -1524				for t,r in enumerate(sdata):
    -1525					r['t'] = t - t0
    +            
    1557	def assign_timestamps(self):
    +1558		'''
    +1559		Assign a time field `t` of type `float` to each analysis.
    +1560
    +1561		If `TimeTag` is one of the data fields, `t` is equal within a given session
    +1562		to `TimeTag` minus the mean value of `TimeTag` for that session.
    +1563		Otherwise, `TimeTag` is by default equal to the index of each analysis
    +1564		in the dataset and `t` is defined as above.
    +1565		'''
    +1566		for session in self.sessions:
    +1567			sdata = self.sessions[session]['data']
    +1568			try:
    +1569				t0 = np.mean([r['TimeTag'] for r in sdata])
    +1570				for r in sdata:
    +1571					r['t'] = r['TimeTag'] - t0
    +1572			except KeyError:
    +1573				t0 = (len(sdata)-1)/2
    +1574				for t,r in enumerate(sdata):
    +1575					r['t'] = t - t0
     
    @@ -8572,12 +8650,12 @@

    API Documentation

    -
    1528	def report(self):
    -1529		'''
    -1530		Prints a report on the standardization fit.
    -1531		Only applicable after `D4xdata.standardize(method='pooled')`.
    -1532		'''
    -1533		report_fit(self.standardization)
    +            
    1578	def report(self):
    +1579		'''
    +1580		Prints a report on the standardization fit.
    +1581		Only applicable after `D4xdata.standardize(method='pooled')`.
    +1582		'''
    +1583		report_fit(self.standardization)
     
    @@ -8598,43 +8676,43 @@

    API Documentation

    -
    1536	def combine_samples(self, sample_groups):
    -1537		'''
    -1538		Combine analyses of different samples to compute weighted average Δ4x
    -1539		and new error (co)variances corresponding to the groups defined by the `sample_groups`
    -1540		dictionary.
    -1541		
    -1542		Caution: samples are weighted by number of replicate analyses, which is a
    -1543		reasonable default behavior but is not always optimal (e.g., in the case of strongly
    -1544		correlated analytical errors for one or more samples).
    -1545		
    -1546		Returns a tuplet of:
    -1547		
    -1548		+ the list of group names
    -1549		+ an array of the corresponding Δ4x values
    -1550		+ the corresponding (co)variance matrix
    -1551		
    -1552		**Parameters**
    -1553
    -1554		+ `sample_groups`: a dictionary of the form:
    -1555		```py
    -1556		{'group1': ['sample_1', 'sample_2'],
    -1557		 'group2': ['sample_3', 'sample_4', 'sample_5']}
    -1558		```
    -1559		'''
    -1560		
    -1561		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
    -1562		groups = sorted(sample_groups.keys())
    -1563		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
    -1564		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
    -1565		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
    -1566		W = np.array([
    -1567			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
    -1568			for j in groups])
    -1569		D4x_new = W @ D4x_old
    -1570		CM_new = W @ CM_old @ W.T
    -1571
    -1572		return groups, D4x_new[:,0], CM_new
    +            
    1586	def combine_samples(self, sample_groups):
    +1587		'''
    +1588		Combine analyses of different samples to compute weighted average Δ4x
    +1589		and new error (co)variances corresponding to the groups defined by the `sample_groups`
    +1590		dictionary.
    +1591		
    +1592		Caution: samples are weighted by number of replicate analyses, which is a
    +1593		reasonable default behavior but is not always optimal (e.g., in the case of strongly
    +1594		correlated analytical errors for one or more samples).
    +1595		
    +1596		Returns a tuplet of:
    +1597		
    +1598		+ the list of group names
    +1599		+ an array of the corresponding Δ4x values
    +1600		+ the corresponding (co)variance matrix
    +1601		
    +1602		**Parameters**
    +1603
    +1604		+ `sample_groups`: a dictionary of the form:
    +1605		```py
    +1606		{'group1': ['sample_1', 'sample_2'],
    +1607		 'group2': ['sample_3', 'sample_4', 'sample_5']}
    +1608		```
    +1609		'''
    +1610		
    +1611		samples = [s for k in sorted(sample_groups.keys()) for s in sorted(sample_groups[k])]
    +1612		groups = sorted(sample_groups.keys())
    +1613		group_total_weights = {k: sum([self.samples[s]['N'] for s in sample_groups[k]]) for k in groups}
    +1614		D4x_old = np.array([[self.samples[x][f'D{self._4x}']] for x in samples])
    +1615		CM_old = np.array([[self.sample_D4x_covar(x,y) for x in samples] for y in samples])
    +1616		W = np.array([
    +1617			[self.samples[i]['N']/group_total_weights[j] if i in sample_groups[j] else 0 for i in samples]
    +1618			for j in groups])
    +1619		D4x_new = W @ D4x_old
    +1620		CM_new = W @ CM_old @ W.T
    +1621
    +1622		return groups, D4x_new[:,0], CM_new
     
    @@ -8681,224 +8759,238 @@

    API Documentation

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

    API Documentation

    -
    1795	def standardization_error(self, session, d4x, D4x, t = 0):
    -1796		'''
    -1797		Compute standardization error for a given session and
    -1798		(δ47, Δ47) composition.
    -1799		'''
    -1800		a = self.sessions[session]['a']
    -1801		b = self.sessions[session]['b']
    -1802		c = self.sessions[session]['c']
    -1803		a2 = self.sessions[session]['a2']
    -1804		b2 = self.sessions[session]['b2']
    -1805		c2 = self.sessions[session]['c2']
    -1806		CM = self.sessions[session]['CM']
    -1807
    -1808		x, y = D4x, d4x
    -1809		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
    -1810# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
    -1811		dxdy = -(b+b2*t) / (a+a2*t)
    -1812		dxdz = 1. / (a+a2*t)
    -1813		dxda = -x / (a+a2*t)
    -1814		dxdb = -y / (a+a2*t)
    -1815		dxdc = -1. / (a+a2*t)
    -1816		dxda2 = -x * a2 / (a+a2*t)
    -1817		dxdb2 = -y * t / (a+a2*t)
    -1818		dxdc2 = -t / (a+a2*t)
    -1819		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
    -1820		sx = (V @ CM @ V.T) ** .5
    -1821		return sx
    +            
    1859	def standardization_error(self, session, d4x, D4x, t = 0):
    +1860		'''
    +1861		Compute standardization error for a given session and
    +1862		(δ47, Δ47) composition.
    +1863		'''
    +1864		a = self.sessions[session]['a']
    +1865		b = self.sessions[session]['b']
    +1866		c = self.sessions[session]['c']
    +1867		a2 = self.sessions[session]['a2']
    +1868		b2 = self.sessions[session]['b2']
    +1869		c2 = self.sessions[session]['c2']
    +1870		CM = self.sessions[session]['CM']
    +1871
    +1872		x, y = D4x, d4x
    +1873		z = a * x + b * y + c + a2 * x * t + b2 * y * t + c2 * t
    +1874# 		x = (z - b*y - b2*y*t - c - c2*t) / (a+a2*t)
    +1875		dxdy = -(b+b2*t) / (a+a2*t)
    +1876		dxdz = 1. / (a+a2*t)
    +1877		dxda = -x / (a+a2*t)
    +1878		dxdb = -y / (a+a2*t)
    +1879		dxdc = -1. / (a+a2*t)
    +1880		dxda2 = -x * a2 / (a+a2*t)
    +1881		dxdb2 = -y * t / (a+a2*t)
    +1882		dxdc2 = -t / (a+a2*t)
    +1883		V = np.array([dxda, dxdb, dxdc, dxda2, dxdb2, dxdc2])
    +1884		sx = (V @ CM @ V.T) ** .5
    +1885		return sx
     
    @@ -8972,45 +9064,45 @@

    API Documentation

    -
    1824	@make_verbal
    -1825	def summary(self,
    -1826		dir = 'output',
    -1827		filename = None,
    -1828		save_to_file = True,
    -1829		print_out = True,
    -1830		):
    -1831		'''
    -1832		Print out an/or save to disk a summary of the standardization results.
    -1833
    -1834		**Parameters**
    -1835
    -1836		+ `dir`: the directory in which to save the table
    -1837		+ `filename`: the name to the csv file to write to
    -1838		+ `save_to_file`: whether to save the table to disk
    -1839		+ `print_out`: whether to print out the table
    -1840		'''
    -1841
    -1842		out = []
    -1843		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
    -1844		out += [['N analyses (anchors + unknowns)', f"{len(self)} ({len([r for r in self if r['Sample'] in self.anchors])} + {len([r for r in self if r['Sample'] in self.unknowns])})"]]
    -1845		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
    -1846		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
    -1847		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
    -1848		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
    -1849		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
    -1850		out += [['Model degrees of freedom', f"{self.Nf}"]]
    -1851		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
    -1852		out += [['Standardization method', self.standardization_method]]
    -1853
    -1854		if save_to_file:
    -1855			if not os.path.exists(dir):
    -1856				os.makedirs(dir)
    -1857			if filename is None:
    -1858				filename = f'D{self._4x}_summary.csv'
    -1859			with open(f'{dir}/{filename}', 'w') as fid:
    -1860				fid.write(make_csv(out))
    -1861		if print_out:
    -1862			self.msg('\n' + pretty_table(out, header = 0))
    +            
    1888	@make_verbal
    +1889	def summary(self,
    +1890		dir = 'output',
    +1891		filename = None,
    +1892		save_to_file = True,
    +1893		print_out = True,
    +1894		):
    +1895		'''
    +1896		Print out an/or save to disk a summary of the standardization results.
    +1897
    +1898		**Parameters**
    +1899
    +1900		+ `dir`: the directory in which to save the table
    +1901		+ `filename`: the name to the csv file to write to
    +1902		+ `save_to_file`: whether to save the table to disk
    +1903		+ `print_out`: whether to print out the table
    +1904		'''
    +1905
    +1906		out = []
    +1907		out += [['N samples (anchors + unknowns)', f"{len(self.samples)} ({len(self.anchors)} + {len(self.unknowns)})"]]
    +1908		out += [['N analyses (anchors + unknowns)', f"{len(self)} ({len([r for r in self if r['Sample'] in self.anchors])} + {len([r for r in self if r['Sample'] in self.unknowns])})"]]
    +1909		out += [['Repeatability of δ13C_VPDB', f"{1000 * self.repeatability['r_d13C_VPDB']:.1f} ppm"]]
    +1910		out += [['Repeatability of δ18O_VSMOW', f"{1000 * self.repeatability['r_d18O_VSMOW']:.1f} ppm"]]
    +1911		out += [[f'Repeatability of Δ{self._4x} (anchors)', f"{1000 * self.repeatability[f'r_D{self._4x}a']:.1f} ppm"]]
    +1912		out += [[f'Repeatability of Δ{self._4x} (unknowns)', f"{1000 * self.repeatability[f'r_D{self._4x}u']:.1f} ppm"]]
    +1913		out += [[f'Repeatability of Δ{self._4x} (all)', f"{1000 * self.repeatability[f'r_D{self._4x}']:.1f} ppm"]]
    +1914		out += [['Model degrees of freedom', f"{self.Nf}"]]
    +1915		out += [['Student\'s 95% t-factor', f"{self.t95:.2f}"]]
    +1916		out += [['Standardization method', self.standardization_method]]
    +1917
    +1918		if save_to_file:
    +1919			if not os.path.exists(dir):
    +1920				os.makedirs(dir)
    +1921			if filename is None:
    +1922				filename = f'D{self._4x}_summary.csv'
    +1923			with open(f'{dir}/{filename}', 'w') as fid:
    +1924				fid.write(make_csv(out))
    +1925		if print_out:
    +1926			self.msg('\n' + pretty_table(out, header = 0))
     
    @@ -9040,81 +9132,81 @@

    API Documentation

    -
    1865	@make_verbal
    -1866	def table_of_sessions(self,
    -1867		dir = 'output',
    -1868		filename = None,
    -1869		save_to_file = True,
    -1870		print_out = True,
    -1871		output = None,
    -1872		):
    -1873		'''
    -1874		Print out an/or save to disk a table of sessions.
    -1875
    -1876		**Parameters**
    -1877
    -1878		+ `dir`: the directory in which to save the table
    -1879		+ `filename`: the name to the csv file to write to
    -1880		+ `save_to_file`: whether to save the table to disk
    -1881		+ `print_out`: whether to print out the table
    -1882		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    -1883		    if set to `'raw'`: return a list of list of strings
    -1884		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    -1885		'''
    -1886		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
    -1887		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
    -1888		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
    -1889
    -1890		out = [['Session','Na','Nu','d13Cwg_VPDB','d18Owg_VSMOW','r_d13C','r_d18O',f'r_D{self._4x}','a ± SE','1e3 x b ± SE','c ± SE']]
    -1891		if include_a2:
    -1892			out[-1] += ['a2 ± SE']
    -1893		if include_b2:
    -1894			out[-1] += ['b2 ± SE']
    -1895		if include_c2:
    -1896			out[-1] += ['c2 ± SE']
    -1897		for session in self.sessions:
    -1898			out += [[
    -1899				session,
    -1900				f"{self.sessions[session]['Na']}",
    -1901				f"{self.sessions[session]['Nu']}",
    -1902				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
    -1903				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
    -1904				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
    -1905				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
    -1906				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
    -1907				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
    -1908				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
    -1909				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
    -1910				]]
    -1911			if include_a2:
    -1912				if self.sessions[session]['scrambling_drift']:
    -1913					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
    -1914				else:
    -1915					out[-1] += ['']
    -1916			if include_b2:
    -1917				if self.sessions[session]['slope_drift']:
    -1918					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
    -1919				else:
    -1920					out[-1] += ['']
    -1921			if include_c2:
    -1922				if self.sessions[session]['wg_drift']:
    -1923					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
    -1924				else:
    -1925					out[-1] += ['']
    -1926
    -1927		if save_to_file:
    -1928			if not os.path.exists(dir):
    -1929				os.makedirs(dir)
    -1930			if filename is None:
    -1931				filename = f'D{self._4x}_sessions.csv'
    -1932			with open(f'{dir}/{filename}', 'w') as fid:
    -1933				fid.write(make_csv(out))
    -1934		if print_out:
    -1935			self.msg('\n' + pretty_table(out))
    -1936		if output == 'raw':
    -1937			return out
    -1938		elif output == 'pretty':
    -1939			return pretty_table(out)
    +            
    1929	@make_verbal
    +1930	def table_of_sessions(self,
    +1931		dir = 'output',
    +1932		filename = None,
    +1933		save_to_file = True,
    +1934		print_out = True,
    +1935		output = None,
    +1936		):
    +1937		'''
    +1938		Print out an/or save to disk a table of sessions.
    +1939
    +1940		**Parameters**
    +1941
    +1942		+ `dir`: the directory in which to save the table
    +1943		+ `filename`: the name to the csv file to write to
    +1944		+ `save_to_file`: whether to save the table to disk
    +1945		+ `print_out`: whether to print out the table
    +1946		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    +1947		    if set to `'raw'`: return a list of list of strings
    +1948		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    +1949		'''
    +1950		include_a2 = any([self.sessions[session]['scrambling_drift'] for session in self.sessions])
    +1951		include_b2 = any([self.sessions[session]['slope_drift'] for session in self.sessions])
    +1952		include_c2 = any([self.sessions[session]['wg_drift'] for session in self.sessions])
    +1953
    +1954		out = [['Session','Na','Nu','d13Cwg_VPDB','d18Owg_VSMOW','r_d13C','r_d18O',f'r_D{self._4x}','a ± SE','1e3 x b ± SE','c ± SE']]
    +1955		if include_a2:
    +1956			out[-1] += ['a2 ± SE']
    +1957		if include_b2:
    +1958			out[-1] += ['b2 ± SE']
    +1959		if include_c2:
    +1960			out[-1] += ['c2 ± SE']
    +1961		for session in self.sessions:
    +1962			out += [[
    +1963				session,
    +1964				f"{self.sessions[session]['Na']}",
    +1965				f"{self.sessions[session]['Nu']}",
    +1966				f"{self.sessions[session]['d13Cwg_VPDB']:.3f}",
    +1967				f"{self.sessions[session]['d18Owg_VSMOW']:.3f}",
    +1968				f"{self.sessions[session]['r_d13C_VPDB']:.4f}",
    +1969				f"{self.sessions[session]['r_d18O_VSMOW']:.4f}",
    +1970				f"{self.sessions[session][f'r_D{self._4x}']:.4f}",
    +1971				f"{self.sessions[session]['a']:.3f} ± {self.sessions[session]['SE_a']:.3f}",
    +1972				f"{1e3*self.sessions[session]['b']:.3f} ± {1e3*self.sessions[session]['SE_b']:.3f}",
    +1973				f"{self.sessions[session]['c']:.3f} ± {self.sessions[session]['SE_c']:.3f}",
    +1974				]]
    +1975			if include_a2:
    +1976				if self.sessions[session]['scrambling_drift']:
    +1977					out[-1] += [f"{self.sessions[session]['a2']:.1e} ± {self.sessions[session]['SE_a2']:.1e}"]
    +1978				else:
    +1979					out[-1] += ['']
    +1980			if include_b2:
    +1981				if self.sessions[session]['slope_drift']:
    +1982					out[-1] += [f"{self.sessions[session]['b2']:.1e} ± {self.sessions[session]['SE_b2']:.1e}"]
    +1983				else:
    +1984					out[-1] += ['']
    +1985			if include_c2:
    +1986				if self.sessions[session]['wg_drift']:
    +1987					out[-1] += [f"{self.sessions[session]['c2']:.1e} ± {self.sessions[session]['SE_c2']:.1e}"]
    +1988				else:
    +1989					out[-1] += ['']
    +1990
    +1991		if save_to_file:
    +1992			if not os.path.exists(dir):
    +1993				os.makedirs(dir)
    +1994			if filename is None:
    +1995				filename = f'D{self._4x}_sessions.csv'
    +1996			with open(f'{dir}/{filename}', 'w') as fid:
    +1997				fid.write(make_csv(out))
    +1998		if print_out:
    +1999			self.msg('\n' + pretty_table(out))
    +2000		if output == 'raw':
    +2001			return out
    +2002		elif output == 'pretty':
    +2003			return pretty_table(out)
     
    @@ -9147,63 +9239,63 @@

    API Documentation

    -
    1942	@make_verbal
    -1943	def table_of_analyses(
    -1944		self,
    -1945		dir = 'output',
    -1946		filename = None,
    -1947		save_to_file = True,
    -1948		print_out = True,
    -1949		output = None,
    -1950		):
    -1951		'''
    -1952		Print out an/or save to disk a table of analyses.
    -1953
    -1954		**Parameters**
    -1955
    -1956		+ `dir`: the directory in which to save the table
    -1957		+ `filename`: the name to the csv file to write to
    -1958		+ `save_to_file`: whether to save the table to disk
    -1959		+ `print_out`: whether to print out the table
    -1960		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    -1961		    if set to `'raw'`: return a list of list of strings
    -1962		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    -1963		'''
    -1964
    -1965		out = [['UID','Session','Sample']]
    -1966		extra_fields = [f for f in [('SampleMass','.2f'),('ColdFingerPressure','.1f'),('AcidReactionYield','.3f')] if f[0] in {k for r in self for k in r}]
    -1967		for f in extra_fields:
    -1968			out[-1] += [f[0]]
    -1969		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
    -1970		for r in self:
    -1971			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
    -1972			for f in extra_fields:
    -1973				out[-1] += [f"{r[f[0]]:{f[1]}}"]
    -1974			out[-1] += [
    -1975				f"{r['d13Cwg_VPDB']:.3f}",
    -1976				f"{r['d18Owg_VSMOW']:.3f}",
    -1977				f"{r['d45']:.6f}",
    -1978				f"{r['d46']:.6f}",
    -1979				f"{r['d47']:.6f}",
    -1980				f"{r['d48']:.6f}",
    -1981				f"{r['d49']:.6f}",
    -1982				f"{r['d13C_VPDB']:.6f}",
    -1983				f"{r['d18O_VSMOW']:.6f}",
    -1984				f"{r['D47raw']:.6f}",
    -1985				f"{r['D48raw']:.6f}",
    -1986				f"{r['D49raw']:.6f}",
    -1987				f"{r[f'D{self._4x}']:.6f}"
    -1988				]
    -1989		if save_to_file:
    -1990			if not os.path.exists(dir):
    -1991				os.makedirs(dir)
    -1992			if filename is None:
    -1993				filename = f'D{self._4x}_analyses.csv'
    -1994			with open(f'{dir}/{filename}', 'w') as fid:
    -1995				fid.write(make_csv(out))
    -1996		if print_out:
    -1997			self.msg('\n' + pretty_table(out))
    -1998		return out
    +            
    2006	@make_verbal
    +2007	def table_of_analyses(
    +2008		self,
    +2009		dir = 'output',
    +2010		filename = None,
    +2011		save_to_file = True,
    +2012		print_out = True,
    +2013		output = None,
    +2014		):
    +2015		'''
    +2016		Print out an/or save to disk a table of analyses.
    +2017
    +2018		**Parameters**
    +2019
    +2020		+ `dir`: the directory in which to save the table
    +2021		+ `filename`: the name to the csv file to write to
    +2022		+ `save_to_file`: whether to save the table to disk
    +2023		+ `print_out`: whether to print out the table
    +2024		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    +2025		    if set to `'raw'`: return a list of list of strings
    +2026		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    +2027		'''
    +2028
    +2029		out = [['UID','Session','Sample']]
    +2030		extra_fields = [f for f in [('SampleMass','.2f'),('ColdFingerPressure','.1f'),('AcidReactionYield','.3f')] if f[0] in {k for r in self for k in r}]
    +2031		for f in extra_fields:
    +2032			out[-1] += [f[0]]
    +2033		out[-1] += ['d13Cwg_VPDB','d18Owg_VSMOW','d45','d46','d47','d48','d49','d13C_VPDB','d18O_VSMOW','D47raw','D48raw','D49raw',f'D{self._4x}']
    +2034		for r in self:
    +2035			out += [[f"{r['UID']}",f"{r['Session']}",f"{r['Sample']}"]]
    +2036			for f in extra_fields:
    +2037				out[-1] += [f"{r[f[0]]:{f[1]}}"]
    +2038			out[-1] += [
    +2039				f"{r['d13Cwg_VPDB']:.3f}",
    +2040				f"{r['d18Owg_VSMOW']:.3f}",
    +2041				f"{r['d45']:.6f}",
    +2042				f"{r['d46']:.6f}",
    +2043				f"{r['d47']:.6f}",
    +2044				f"{r['d48']:.6f}",
    +2045				f"{r['d49']:.6f}",
    +2046				f"{r['d13C_VPDB']:.6f}",
    +2047				f"{r['d18O_VSMOW']:.6f}",
    +2048				f"{r['D47raw']:.6f}",
    +2049				f"{r['D48raw']:.6f}",
    +2050				f"{r['D49raw']:.6f}",
    +2051				f"{r[f'D{self._4x}']:.6f}"
    +2052				]
    +2053		if save_to_file:
    +2054			if not os.path.exists(dir):
    +2055				os.makedirs(dir)
    +2056			if filename is None:
    +2057				filename = f'D{self._4x}_analyses.csv'
    +2058			with open(f'{dir}/{filename}', 'w') as fid:
    +2059				fid.write(make_csv(out))
    +2060		if print_out:
    +2061			self.msg('\n' + pretty_table(out))
    +2062		return out
     
    @@ -9236,56 +9328,56 @@

    API Documentation

    -
    2000	@make_verbal
    -2001	def covar_table(
    -2002		self,
    -2003		correl = False,
    -2004		dir = 'output',
    -2005		filename = None,
    -2006		save_to_file = True,
    -2007		print_out = True,
    -2008		output = None,
    -2009		):
    -2010		'''
    -2011		Print out, save to disk and/or return the variance-covariance matrix of D4x
    -2012		for all unknown samples.
    -2013
    -2014		**Parameters**
    -2015
    -2016		+ `dir`: the directory in which to save the csv
    -2017		+ `filename`: the name of the csv file to write to
    -2018		+ `save_to_file`: whether to save the csv
    -2019		+ `print_out`: whether to print out the matrix
    -2020		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
    -2021		    if set to `'raw'`: return a list of list of strings
    -2022		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    -2023		'''
    -2024		samples = sorted([u for u in self.unknowns])
    -2025		out = [[''] + samples]
    -2026		for s1 in samples:
    -2027			out.append([s1])
    -2028			for s2 in samples:
    -2029				if correl:
    -2030					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
    -2031				else:
    -2032					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
    -2033
    -2034		if save_to_file:
    -2035			if not os.path.exists(dir):
    -2036				os.makedirs(dir)
    -2037			if filename is None:
    -2038				if correl:
    -2039					filename = f'D{self._4x}_correl.csv'
    -2040				else:
    -2041					filename = f'D{self._4x}_covar.csv'
    -2042			with open(f'{dir}/{filename}', 'w') as fid:
    -2043				fid.write(make_csv(out))
    -2044		if print_out:
    -2045			self.msg('\n'+pretty_table(out))
    -2046		if output == 'raw':
    -2047			return out
    -2048		elif output == 'pretty':
    -2049			return pretty_table(out)
    +            
    2064	@make_verbal
    +2065	def covar_table(
    +2066		self,
    +2067		correl = False,
    +2068		dir = 'output',
    +2069		filename = None,
    +2070		save_to_file = True,
    +2071		print_out = True,
    +2072		output = None,
    +2073		):
    +2074		'''
    +2075		Print out, save to disk and/or return the variance-covariance matrix of D4x
    +2076		for all unknown samples.
    +2077
    +2078		**Parameters**
    +2079
    +2080		+ `dir`: the directory in which to save the csv
    +2081		+ `filename`: the name of the csv file to write to
    +2082		+ `save_to_file`: whether to save the csv
    +2083		+ `print_out`: whether to print out the matrix
    +2084		+ `output`: if set to `'pretty'`: return a pretty text matrix (see `pretty_table()`);
    +2085		    if set to `'raw'`: return a list of list of strings
    +2086		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    +2087		'''
    +2088		samples = sorted([u for u in self.unknowns])
    +2089		out = [[''] + samples]
    +2090		for s1 in samples:
    +2091			out.append([s1])
    +2092			for s2 in samples:
    +2093				if correl:
    +2094					out[-1].append(f'{self.sample_D4x_correl(s1, s2):.6f}')
    +2095				else:
    +2096					out[-1].append(f'{self.sample_D4x_covar(s1, s2):.8e}')
    +2097
    +2098		if save_to_file:
    +2099			if not os.path.exists(dir):
    +2100				os.makedirs(dir)
    +2101			if filename is None:
    +2102				if correl:
    +2103					filename = f'D{self._4x}_correl.csv'
    +2104				else:
    +2105					filename = f'D{self._4x}_covar.csv'
    +2106			with open(f'{dir}/{filename}', 'w') as fid:
    +2107				fid.write(make_csv(out))
    +2108		if print_out:
    +2109			self.msg('\n'+pretty_table(out))
    +2110		if output == 'raw':
    +2111			return out
    +2112		elif output == 'pretty':
    +2113			return pretty_table(out)
     
    @@ -9319,64 +9411,64 @@

    API Documentation

    -
    2051	@make_verbal
    -2052	def table_of_samples(
    -2053		self,
    -2054		dir = 'output',
    -2055		filename = None,
    -2056		save_to_file = True,
    -2057		print_out = True,
    -2058		output = None,
    -2059		):
    -2060		'''
    -2061		Print out, save to disk and/or return a table of samples.
    -2062
    -2063		**Parameters**
    -2064
    -2065		+ `dir`: the directory in which to save the csv
    -2066		+ `filename`: the name of the csv file to write to
    -2067		+ `save_to_file`: whether to save the csv
    -2068		+ `print_out`: whether to print out the table
    -2069		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    -2070		    if set to `'raw'`: return a list of list of strings
    -2071		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    -2072		'''
    -2073
    -2074		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
    -2075		for sample in self.anchors:
    -2076			out += [[
    -2077				f"{sample}",
    -2078				f"{self.samples[sample]['N']}",
    -2079				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    -2080				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    -2081				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
    -2082				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
    -2083				]]
    -2084		for sample in self.unknowns:
    -2085			out += [[
    -2086				f"{sample}",
    -2087				f"{self.samples[sample]['N']}",
    -2088				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    -2089				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    -2090				f"{self.samples[sample][f'D{self._4x}']:.4f}",
    -2091				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
    -2092				f{self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
    -2093				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
    -2094				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
    -2095				]]
    -2096		if save_to_file:
    -2097			if not os.path.exists(dir):
    -2098				os.makedirs(dir)
    -2099			if filename is None:
    -2100				filename = f'D{self._4x}_samples.csv'
    -2101			with open(f'{dir}/{filename}', 'w') as fid:
    -2102				fid.write(make_csv(out))
    -2103		if print_out:
    -2104			self.msg('\n'+pretty_table(out))
    -2105		if output == 'raw':
    -2106			return out
    -2107		elif output == 'pretty':
    -2108			return pretty_table(out)
    +            
    2115	@make_verbal
    +2116	def table_of_samples(
    +2117		self,
    +2118		dir = 'output',
    +2119		filename = None,
    +2120		save_to_file = True,
    +2121		print_out = True,
    +2122		output = None,
    +2123		):
    +2124		'''
    +2125		Print out, save to disk and/or return a table of samples.
    +2126
    +2127		**Parameters**
    +2128
    +2129		+ `dir`: the directory in which to save the csv
    +2130		+ `filename`: the name of the csv file to write to
    +2131		+ `save_to_file`: whether to save the csv
    +2132		+ `print_out`: whether to print out the table
    +2133		+ `output`: if set to `'pretty'`: return a pretty text table (see `pretty_table()`);
    +2134		    if set to `'raw'`: return a list of list of strings
    +2135		    (e.g., `[['header1', 'header2'], ['0.1', '0.2']]`)
    +2136		'''
    +2137
    +2138		out = [['Sample','N','d13C_VPDB','d18O_VSMOW',f'D{self._4x}','SE','95% CL','SD','p_Levene']]
    +2139		for sample in self.anchors:
    +2140			out += [[
    +2141				f"{sample}",
    +2142				f"{self.samples[sample]['N']}",
    +2143				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    +2144				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    +2145				f"{self.samples[sample][f'D{self._4x}']:.4f}",'','',
    +2146				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '', ''
    +2147				]]
    +2148		for sample in self.unknowns:
    +2149			out += [[
    +2150				f"{sample}",
    +2151				f"{self.samples[sample]['N']}",
    +2152				f"{self.samples[sample]['d13C_VPDB']:.2f}",
    +2153				f"{self.samples[sample]['d18O_VSMOW']:.2f}",
    +2154				f"{self.samples[sample][f'D{self._4x}']:.4f}",
    +2155				f"{self.samples[sample][f'SE_D{self._4x}']:.4f}",
    +2156				f{self.samples[sample][f'SE_D{self._4x}'] * self.t95:.4f}",
    +2157				f"{self.samples[sample][f'SD_D{self._4x}']:.4f}" if self.samples[sample]['N'] > 1 else '',
    +2158				f"{self.samples[sample]['p_Levene']:.3f}" if self.samples[sample]['N'] > 2 else ''
    +2159				]]
    +2160		if save_to_file:
    +2161			if not os.path.exists(dir):
    +2162				os.makedirs(dir)
    +2163			if filename is None:
    +2164				filename = f'D{self._4x}_samples.csv'
    +2165			with open(f'{dir}/{filename}', 'w') as fid:
    +2166				fid.write(make_csv(out))
    +2167		if print_out:
    +2168			self.msg('\n'+pretty_table(out))
    +2169		if output == 'raw':
    +2170			return out
    +2171		elif output == 'pretty':
    +2172			return pretty_table(out)
     
    @@ -9408,22 +9500,22 @@

    API Documentation

    -
    2111	def plot_sessions(self, dir = 'output', figsize = (8,8)):
    -2112		'''
    -2113		Generate session plots and save them to disk.
    -2114
    -2115		**Parameters**
    -2116
    -2117		+ `dir`: the directory in which to save the plots
    -2118		+ `figsize`: the width and height (in inches) of each plot
    -2119		'''
    -2120		if not os.path.exists(dir):
    -2121			os.makedirs(dir)
    -2122
    -2123		for session in self.sessions:
    -2124			sp = self.plot_single_session(session, xylimits = 'constant')
    -2125			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
    -2126			ppl.close(sp.fig)
    +            
    2175	def plot_sessions(self, dir = 'output', figsize = (8,8)):
    +2176		'''
    +2177		Generate session plots and save them to disk.
    +2178
    +2179		**Parameters**
    +2180
    +2181		+ `dir`: the directory in which to save the plots
    +2182		+ `figsize`: the width and height (in inches) of each plot
    +2183		'''
    +2184		if not os.path.exists(dir):
    +2185			os.makedirs(dir)
    +2186
    +2187		for session in self.sessions:
    +2188			sp = self.plot_single_session(session, xylimits = 'constant')
    +2189			ppl.savefig(f'{dir}/D{self._4x}_plot_{session}.pdf')
    +2190			ppl.close(sp.fig)
     
    @@ -9451,82 +9543,82 @@

    API Documentation

    -
    2129	@make_verbal
    -2130	def consolidate_samples(self):
    -2131		'''
    -2132		Compile various statistics for each sample.
    -2133
    -2134		For each anchor sample:
    -2135
    -2136		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
    -2137		+ `SE_D47` or `SE_D48`: set to zero by definition
    -2138
    -2139		For each unknown sample:
    -2140
    -2141		+ `D47` or `D48`: the standardized Δ4x value for this unknown
    -2142		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
    -2143
    -2144		For each anchor and unknown:
    -2145
    -2146		+ `N`: the total number of analyses of this sample
    -2147		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
    -2148		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
    -2149		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
    -2150		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
    -2151		variance, indicating whether the Δ4x repeatability this sample differs significantly from
    -2152		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
    -2153		'''
    -2154		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
    -2155		for sample in self.samples:
    -2156			self.samples[sample]['N'] = len(self.samples[sample]['data'])
    -2157			if self.samples[sample]['N'] > 1:
    -2158				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
    -2159
    -2160			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
    -2161			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
    -2162
    -2163			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
    -2164			if len(D4x_pop) > 2:
    -2165				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
    -2166
    -2167		if self.standardization_method == 'pooled':
    -2168			for sample in self.anchors:
    -2169				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    -2170				self.samples[sample][f'SE_D{self._4x}'] = 0.
    -2171			for sample in self.unknowns:
    -2172				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
    -2173				try:
    -2174					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
    -2175				except ValueError:
    -2176					# when `sample` is constrained by self.standardize(constraints = {...}),
    -2177					# it is no longer listed in self.standardization.var_names.
    -2178					# Temporary fix: define SE as zero for now
    -2179					self.samples[sample][f'SE_D4{self._4x}'] = 0.
    -2180
    -2181		elif self.standardization_method == 'indep_sessions':
    -2182			for sample in self.anchors:
    -2183				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    -2184				self.samples[sample][f'SE_D{self._4x}'] = 0.
    -2185			for sample in self.unknowns:
    -2186				self.msg(f'Consolidating sample {sample}')
    -2187				self.unknowns[sample][f'session_D{self._4x}'] = {}
    -2188				session_avg = []
    -2189				for session in self.sessions:
    -2190					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
    -2191					if sdata:
    -2192						self.msg(f'{sample} found in session {session}')
    -2193						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
    -2194						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
    -2195						# !! TODO: sigma_s below does not account for temporal changes in standardization error
    -2196						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
    -2197						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
    -2198						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
    -2199						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
    -2200				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
    -2201				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
    -2202				wsum = sum([weights[s] for s in weights])
    -2203				for s in weights:
    -2204					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
    +            
    2193	@make_verbal
    +2194	def consolidate_samples(self):
    +2195		'''
    +2196		Compile various statistics for each sample.
    +2197
    +2198		For each anchor sample:
    +2199
    +2200		+ `D47` or `D48`: the nominal Δ4x value for this anchor, specified by `self.Nominal_D4x`
    +2201		+ `SE_D47` or `SE_D48`: set to zero by definition
    +2202
    +2203		For each unknown sample:
    +2204
    +2205		+ `D47` or `D48`: the standardized Δ4x value for this unknown
    +2206		+ `SE_D47` or `SE_D48`: the standard error of Δ4x for this unknown
    +2207
    +2208		For each anchor and unknown:
    +2209
    +2210		+ `N`: the total number of analyses of this sample
    +2211		+ `SD_D47` or `SD_D48`: the “sample” (in the statistical sense) standard deviation for this sample
    +2212		+ `d13C_VPDB`: the average δ13C_VPDB value for this sample
    +2213		+ `d18O_VSMOW`: the average δ18O_VSMOW value for this sample (as CO2)
    +2214		+ `p_Levene`: the p-value from a [Levene test](https://en.wikipedia.org/wiki/Levene%27s_test) of equal
    +2215		variance, indicating whether the Δ4x repeatability this sample differs significantly from
    +2216		that observed for the reference sample specified by `self.LEVENE_REF_SAMPLE`.
    +2217		'''
    +2218		D4x_ref_pop = [r[f'D{self._4x}'] for r in self.samples[self.LEVENE_REF_SAMPLE]['data']]
    +2219		for sample in self.samples:
    +2220			self.samples[sample]['N'] = len(self.samples[sample]['data'])
    +2221			if self.samples[sample]['N'] > 1:
    +2222				self.samples[sample][f'SD_D{self._4x}'] = stdev([r[f'D{self._4x}'] for r in self.samples[sample]['data']])
    +2223
    +2224			self.samples[sample]['d13C_VPDB'] = np.mean([r['d13C_VPDB'] for r in self.samples[sample]['data']])
    +2225			self.samples[sample]['d18O_VSMOW'] = np.mean([r['d18O_VSMOW'] for r in self.samples[sample]['data']])
    +2226
    +2227			D4x_pop = [r[f'D{self._4x}'] for r in self.samples[sample]['data']]
    +2228			if len(D4x_pop) > 2:
    +2229				self.samples[sample]['p_Levene'] = levene(D4x_ref_pop, D4x_pop, center = 'median')[1]
    +2230
    +2231		if self.standardization_method == 'pooled':
    +2232			for sample in self.anchors:
    +2233				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    +2234				self.samples[sample][f'SE_D{self._4x}'] = 0.
    +2235			for sample in self.unknowns:
    +2236				self.samples[sample][f'D{self._4x}'] = self.standardization.params.valuesdict()[f'D{self._4x}_{pf(sample)}']
    +2237				try:
    +2238					self.samples[sample][f'SE_D{self._4x}'] = self.sample_D4x_covar(sample)**.5
    +2239				except ValueError:
    +2240					# when `sample` is constrained by self.standardize(constraints = {...}),
    +2241					# it is no longer listed in self.standardization.var_names.
    +2242					# Temporary fix: define SE as zero for now
    +2243					self.samples[sample][f'SE_D4{self._4x}'] = 0.
    +2244
    +2245		elif self.standardization_method == 'indep_sessions':
    +2246			for sample in self.anchors:
    +2247				self.samples[sample][f'D{self._4x}'] = self.Nominal_D4x[sample]
    +2248				self.samples[sample][f'SE_D{self._4x}'] = 0.
    +2249			for sample in self.unknowns:
    +2250				self.msg(f'Consolidating sample {sample}')
    +2251				self.unknowns[sample][f'session_D{self._4x}'] = {}
    +2252				session_avg = []
    +2253				for session in self.sessions:
    +2254					sdata = [r for r in self.sessions[session]['data'] if r['Sample'] == sample]
    +2255					if sdata:
    +2256						self.msg(f'{sample} found in session {session}')
    +2257						avg_D4x = np.mean([r[f'D{self._4x}'] for r in sdata])
    +2258						avg_d4x = np.mean([r[f'd{self._4x}'] for r in sdata])
    +2259						# !! TODO: sigma_s below does not account for temporal changes in standardization error
    +2260						sigma_s = self.standardization_error(session, avg_d4x, avg_D4x)
    +2261						sigma_u = sdata[0][f'wD{self._4x}raw'] / self.sessions[session]['a'] / len(sdata)**.5
    +2262						session_avg.append([avg_D4x, (sigma_u**2 + sigma_s**2)**.5])
    +2263						self.unknowns[sample][f'session_D{self._4x}'][session] = session_avg[-1]
    +2264				self.samples[sample][f'D{self._4x}'], self.samples[sample][f'SE_D{self._4x}'] = w_avg(*zip(*session_avg))
    +2265				weights = {s: self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 for s in self.unknowns[sample][f'session_D{self._4x}']}
    +2266				wsum = sum([weights[s] for s in weights])
    +2267				for s in weights:
    +2268					self.unknowns[sample][f'session_D{self._4x}'][s] += [self.unknowns[sample][f'session_D{self._4x}'][s][1]**-2 / wsum]
     
    @@ -9572,127 +9664,127 @@

    API Documentation

    -
    2207	def consolidate_sessions(self):
    -2208		'''
    -2209		Compute various statistics for each session.
    -2210
    -2211		+ `Na`: Number of anchor analyses in the session
    -2212		+ `Nu`: Number of unknown analyses in the session
    -2213		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
    -2214		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
    -2215		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
    -2216		+ `a`: scrambling factor
    -2217		+ `b`: compositional slope
    -2218		+ `c`: WG offset
    -2219		+ `SE_a`: Model stadard erorr of `a`
    -2220		+ `SE_b`: Model stadard erorr of `b`
    -2221		+ `SE_c`: Model stadard erorr of `c`
    -2222		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
    -2223		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
    -2224		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
    -2225		+ `a2`: scrambling factor drift
    -2226		+ `b2`: compositional slope drift
    -2227		+ `c2`: WG offset drift
    -2228		+ `Np`: Number of standardization parameters to fit
    -2229		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
    -2230		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
    -2231		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
    -2232		'''
    -2233		for session in self.sessions:
    -2234			if 'd13Cwg_VPDB' not in self.sessions[session]:
    -2235				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
    -2236			if 'd18Owg_VSMOW' not in self.sessions[session]:
    -2237				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
    -2238			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
    -2239			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
    -2240
    -2241			self.msg(f'Computing repeatabilities for session {session}')
    -2242			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
    -2243			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
    -2244			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
    -2245
    -2246		if self.standardization_method == 'pooled':
    -2247			for session in self.sessions:
    -2248
    -2249				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
    -2250				i = self.standardization.var_names.index(f'a_{pf(session)}')
    -2251				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
    -2252
    -2253				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
    -2254				i = self.standardization.var_names.index(f'b_{pf(session)}')
    -2255				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
    -2256
    -2257				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
    -2258				i = self.standardization.var_names.index(f'c_{pf(session)}')
    -2259				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
    -2260
    -2261				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
    -2262				if self.sessions[session]['scrambling_drift']:
    -2263					i = self.standardization.var_names.index(f'a2_{pf(session)}')
    -2264					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
    -2265				else:
    -2266					self.sessions[session]['SE_a2'] = 0.
    -2267
    -2268				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
    -2269				if self.sessions[session]['slope_drift']:
    -2270					i = self.standardization.var_names.index(f'b2_{pf(session)}')
    -2271					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
    -2272				else:
    -2273					self.sessions[session]['SE_b2'] = 0.
    +            
    2271	def consolidate_sessions(self):
    +2272		'''
    +2273		Compute various statistics for each session.
     2274
    -2275				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
    -2276				if self.sessions[session]['wg_drift']:
    -2277					i = self.standardization.var_names.index(f'c2_{pf(session)}')
    -2278					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
    -2279				else:
    -2280					self.sessions[session]['SE_c2'] = 0.
    -2281
    -2282				i = self.standardization.var_names.index(f'a_{pf(session)}')
    -2283				j = self.standardization.var_names.index(f'b_{pf(session)}')
    -2284				k = self.standardization.var_names.index(f'c_{pf(session)}')
    -2285				CM = np.zeros((6,6))
    -2286				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
    -2287				try:
    -2288					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
    -2289					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
    -2290					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
    -2291					try:
    -2292						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    -2293						CM[3,4] = self.standardization.covar[i2,j2]
    -2294						CM[4,3] = self.standardization.covar[j2,i2]
    -2295					except ValueError:
    -2296						pass
    -2297					try:
    -2298						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    -2299						CM[3,5] = self.standardization.covar[i2,k2]
    -2300						CM[5,3] = self.standardization.covar[k2,i2]
    -2301					except ValueError:
    -2302						pass
    -2303				except ValueError:
    -2304					pass
    -2305				try:
    -2306					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    -2307					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
    -2308					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
    -2309					try:
    -2310						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    -2311						CM[4,5] = self.standardization.covar[j2,k2]
    -2312						CM[5,4] = self.standardization.covar[k2,j2]
    -2313					except ValueError:
    -2314						pass
    -2315				except ValueError:
    -2316					pass
    -2317				try:
    -2318					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    -2319					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
    -2320					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
    -2321				except ValueError:
    -2322					pass
    -2323
    -2324				self.sessions[session]['CM'] = CM
    -2325
    -2326		elif self.standardization_method == 'indep_sessions':
    -2327			pass # Not implemented yet
    +2275		+ `Na`: Number of anchor analyses in the session
    +2276		+ `Nu`: Number of unknown analyses in the session
    +2277		+ `r_d13C_VPDB`: δ13C_VPDB repeatability of analyses within the session
    +2278		+ `r_d18O_VSMOW`: δ18O_VSMOW repeatability of analyses within the session
    +2279		+ `r_D47` or `r_D48`: Δ4x repeatability of analyses within the session
    +2280		+ `a`: scrambling factor
    +2281		+ `b`: compositional slope
    +2282		+ `c`: WG offset
    +2283		+ `SE_a`: Model stadard erorr of `a`
    +2284		+ `SE_b`: Model stadard erorr of `b`
    +2285		+ `SE_c`: Model stadard erorr of `c`
    +2286		+ `scrambling_drift` (boolean): whether to allow a temporal drift in the scrambling factor (`a`)
    +2287		+ `slope_drift` (boolean): whether to allow a temporal drift in the compositional slope (`b`)
    +2288		+ `wg_drift` (boolean): whether to allow a temporal drift in the WG offset (`c`)
    +2289		+ `a2`: scrambling factor drift
    +2290		+ `b2`: compositional slope drift
    +2291		+ `c2`: WG offset drift
    +2292		+ `Np`: Number of standardization parameters to fit
    +2293		+ `CM`: model covariance matrix for (`a`, `b`, `c`, `a2`, `b2`, `c2`)
    +2294		+ `d13Cwg_VPDB`: δ13C_VPDB of WG
    +2295		+ `d18Owg_VSMOW`: δ18O_VSMOW of WG
    +2296		'''
    +2297		for session in self.sessions:
    +2298			if 'd13Cwg_VPDB' not in self.sessions[session]:
    +2299				self.sessions[session]['d13Cwg_VPDB'] = self.sessions[session]['data'][0]['d13Cwg_VPDB']
    +2300			if 'd18Owg_VSMOW' not in self.sessions[session]:
    +2301				self.sessions[session]['d18Owg_VSMOW'] = self.sessions[session]['data'][0]['d18Owg_VSMOW']
    +2302			self.sessions[session]['Na'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.anchors])
    +2303			self.sessions[session]['Nu'] = len([r for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns])
    +2304
    +2305			self.msg(f'Computing repeatabilities for session {session}')
    +2306			self.sessions[session]['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors', sessions = [session])
    +2307			self.sessions[session]['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors', sessions = [session])
    +2308			self.sessions[session][f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', sessions = [session])
    +2309
    +2310		if self.standardization_method == 'pooled':
    +2311			for session in self.sessions:
    +2312
    +2313				self.sessions[session]['a'] = self.standardization.params.valuesdict()[f'a_{pf(session)}']
    +2314				i = self.standardization.var_names.index(f'a_{pf(session)}')
    +2315				self.sessions[session]['SE_a'] = self.standardization.covar[i,i]**.5
    +2316
    +2317				self.sessions[session]['b'] = self.standardization.params.valuesdict()[f'b_{pf(session)}']
    +2318				i = self.standardization.var_names.index(f'b_{pf(session)}')
    +2319				self.sessions[session]['SE_b'] = self.standardization.covar[i,i]**.5
    +2320
    +2321				self.sessions[session]['c'] = self.standardization.params.valuesdict()[f'c_{pf(session)}']
    +2322				i = self.standardization.var_names.index(f'c_{pf(session)}')
    +2323				self.sessions[session]['SE_c'] = self.standardization.covar[i,i]**.5
    +2324
    +2325				self.sessions[session]['a2'] = self.standardization.params.valuesdict()[f'a2_{pf(session)}']
    +2326				if self.sessions[session]['scrambling_drift']:
    +2327					i = self.standardization.var_names.index(f'a2_{pf(session)}')
    +2328					self.sessions[session]['SE_a2'] = self.standardization.covar[i,i]**.5
    +2329				else:
    +2330					self.sessions[session]['SE_a2'] = 0.
    +2331
    +2332				self.sessions[session]['b2'] = self.standardization.params.valuesdict()[f'b2_{pf(session)}']
    +2333				if self.sessions[session]['slope_drift']:
    +2334					i = self.standardization.var_names.index(f'b2_{pf(session)}')
    +2335					self.sessions[session]['SE_b2'] = self.standardization.covar[i,i]**.5
    +2336				else:
    +2337					self.sessions[session]['SE_b2'] = 0.
    +2338
    +2339				self.sessions[session]['c2'] = self.standardization.params.valuesdict()[f'c2_{pf(session)}']
    +2340				if self.sessions[session]['wg_drift']:
    +2341					i = self.standardization.var_names.index(f'c2_{pf(session)}')
    +2342					self.sessions[session]['SE_c2'] = self.standardization.covar[i,i]**.5
    +2343				else:
    +2344					self.sessions[session]['SE_c2'] = 0.
    +2345
    +2346				i = self.standardization.var_names.index(f'a_{pf(session)}')
    +2347				j = self.standardization.var_names.index(f'b_{pf(session)}')
    +2348				k = self.standardization.var_names.index(f'c_{pf(session)}')
    +2349				CM = np.zeros((6,6))
    +2350				CM[:3,:3] = self.standardization.covar[[i,j,k],:][:,[i,j,k]]
    +2351				try:
    +2352					i2 = self.standardization.var_names.index(f'a2_{pf(session)}')
    +2353					CM[3,[0,1,2,3]] = self.standardization.covar[i2,[i,j,k,i2]]
    +2354					CM[[0,1,2,3],3] = self.standardization.covar[[i,j,k,i2],i2]
    +2355					try:
    +2356						j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    +2357						CM[3,4] = self.standardization.covar[i2,j2]
    +2358						CM[4,3] = self.standardization.covar[j2,i2]
    +2359					except ValueError:
    +2360						pass
    +2361					try:
    +2362						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    +2363						CM[3,5] = self.standardization.covar[i2,k2]
    +2364						CM[5,3] = self.standardization.covar[k2,i2]
    +2365					except ValueError:
    +2366						pass
    +2367				except ValueError:
    +2368					pass
    +2369				try:
    +2370					j2 = self.standardization.var_names.index(f'b2_{pf(session)}')
    +2371					CM[4,[0,1,2,4]] = self.standardization.covar[j2,[i,j,k,j2]]
    +2372					CM[[0,1,2,4],4] = self.standardization.covar[[i,j,k,j2],j2]
    +2373					try:
    +2374						k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    +2375						CM[4,5] = self.standardization.covar[j2,k2]
    +2376						CM[5,4] = self.standardization.covar[k2,j2]
    +2377					except ValueError:
    +2378						pass
    +2379				except ValueError:
    +2380					pass
    +2381				try:
    +2382					k2 = self.standardization.var_names.index(f'c2_{pf(session)}')
    +2383					CM[5,[0,1,2,5]] = self.standardization.covar[k2,[i,j,k,k2]]
    +2384					CM[[0,1,2,5],5] = self.standardization.covar[[i,j,k,k2],k2]
    +2385				except ValueError:
    +2386					pass
    +2387
    +2388				self.sessions[session]['CM'] = CM
    +2389
    +2390		elif self.standardization_method == 'indep_sessions':
    +2391			pass # Not implemented yet
     
    @@ -9737,19 +9829,19 @@

    API Documentation

    -
    2330	@make_verbal
    -2331	def repeatabilities(self):
    -2332		'''
    -2333		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    -2334		(for all samples, for anchors, and for unknowns).
    -2335		'''
    -2336		self.msg('Computing reproducibilities for all sessions')
    -2337
    -2338		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    -2339		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    -2340		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    -2341		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    -2342		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
    +            
    2394	@make_verbal
    +2395	def repeatabilities(self):
    +2396		'''
    +2397		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    +2398		(for all samples, for anchors, and for unknowns).
    +2399		'''
    +2400		self.msg('Computing reproducibilities for all sessions')
    +2401
    +2402		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    +2403		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    +2404		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    +2405		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    +2406		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
     
    @@ -9771,23 +9863,23 @@

    API Documentation

    -
    2345	@make_verbal
    -2346	def consolidate(self, tables = True, plots = True):
    -2347		'''
    -2348		Collect information about samples, sessions and repeatabilities.
    -2349		'''
    -2350		self.consolidate_samples()
    -2351		self.consolidate_sessions()
    -2352		self.repeatabilities()
    -2353
    -2354		if tables:
    -2355			self.summary()
    -2356			self.table_of_sessions()
    -2357			self.table_of_analyses()
    -2358			self.table_of_samples()
    -2359
    -2360		if plots:
    -2361			self.plot_sessions()
    +            
    2409	@make_verbal
    +2410	def consolidate(self, tables = True, plots = True):
    +2411		'''
    +2412		Collect information about samples, sessions and repeatabilities.
    +2413		'''
    +2414		self.consolidate_samples()
    +2415		self.consolidate_sessions()
    +2416		self.repeatabilities()
    +2417
    +2418		if tables:
    +2419			self.summary()
    +2420			self.table_of_sessions()
    +2421			self.table_of_analyses()
    +2422			self.table_of_samples()
    +2423
    +2424		if plots:
    +2425			self.plot_sessions()
     
    @@ -9808,40 +9900,40 @@

    API Documentation

    -
    2364	@make_verbal
    -2365	def rmswd(self,
    -2366		samples = 'all samples',
    -2367		sessions = 'all sessions',
    -2368		):
    -2369		'''
    -2370		Compute the χ2, root mean squared weighted deviation
    -2371		(i.e. reduced χ2), and corresponding degrees of freedom of the
    -2372		Δ4x values for samples in `samples` and sessions in `sessions`.
    -2373		
    -2374		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
    -2375		'''
    -2376		if samples == 'all samples':
    -2377			mysamples = [k for k in self.samples]
    -2378		elif samples == 'anchors':
    -2379			mysamples = [k for k in self.anchors]
    -2380		elif samples == 'unknowns':
    -2381			mysamples = [k for k in self.unknowns]
    -2382		else:
    -2383			mysamples = samples
    -2384
    -2385		if sessions == 'all sessions':
    -2386			sessions = [k for k in self.sessions]
    -2387
    -2388		chisq, Nf = 0, 0
    -2389		for sample in mysamples :
    -2390			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    -2391			if len(G) > 1 :
    -2392				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
    -2393				Nf += (len(G) - 1)
    -2394				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
    -2395		r = (chisq / Nf)**.5 if Nf > 0 else 0
    -2396		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
    -2397		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
    +            
    2428	@make_verbal
    +2429	def rmswd(self,
    +2430		samples = 'all samples',
    +2431		sessions = 'all sessions',
    +2432		):
    +2433		'''
    +2434		Compute the χ2, root mean squared weighted deviation
    +2435		(i.e. reduced χ2), and corresponding degrees of freedom of the
    +2436		Δ4x values for samples in `samples` and sessions in `sessions`.
    +2437		
    +2438		Only used in `D4xdata.standardize()` with `method='indep_sessions'`.
    +2439		'''
    +2440		if samples == 'all samples':
    +2441			mysamples = [k for k in self.samples]
    +2442		elif samples == 'anchors':
    +2443			mysamples = [k for k in self.anchors]
    +2444		elif samples == 'unknowns':
    +2445			mysamples = [k for k in self.unknowns]
    +2446		else:
    +2447			mysamples = samples
    +2448
    +2449		if sessions == 'all sessions':
    +2450			sessions = [k for k in self.sessions]
    +2451
    +2452		chisq, Nf = 0, 0
    +2453		for sample in mysamples :
    +2454			G = [ r for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    +2455			if len(G) > 1 :
    +2456				X, sX = w_avg([r[f'D{self._4x}'] for r in G], [r[f'wD{self._4x}'] for r in G])
    +2457				Nf += (len(G) - 1)
    +2458				chisq += np.sum([ ((r[f'D{self._4x}']-X)/r[f'wD{self._4x}'])**2 for r in G])
    +2459		r = (chisq / Nf)**.5 if Nf > 0 else 0
    +2460		self.msg(f'RMSWD of r["D{self._4x}"] is {r:.6f} for {samples}.')
    +2461		return {'rmswd': r, 'chisq': chisq, 'Nf': Nf}
     
    @@ -9866,52 +9958,52 @@

    API Documentation

    -
    2400	@make_verbal
    -2401	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
    -2402		'''
    -2403		Compute the repeatability of `[r[key] for r in self]`
    -2404		'''
    -2405		# NB: it's debatable whether rD47 should be computed
    -2406		# with Nf = len(self)-len(self.samples) instead of
    -2407		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
    -2408
    -2409		if samples == 'all samples':
    -2410			mysamples = [k for k in self.samples]
    -2411		elif samples == 'anchors':
    -2412			mysamples = [k for k in self.anchors]
    -2413		elif samples == 'unknowns':
    -2414			mysamples = [k for k in self.unknowns]
    -2415		else:
    -2416			mysamples = samples
    -2417
    -2418		if sessions == 'all sessions':
    -2419			sessions = [k for k in self.sessions]
    -2420
    -2421		if key in ['D47', 'D48']:
    -2422			chisq, Nf = 0, 0
    -2423			for sample in mysamples :
    -2424				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    -2425				if len(X) > 1 :
    -2426					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
    -2427					if sample in self.unknowns:
    -2428						Nf += len(X) - 1
    -2429					else:
    -2430						Nf += len(X)
    -2431			if samples in ['anchors', 'all samples']:
    -2432				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
    -2433			r = (chisq / Nf)**.5 if Nf > 0 else 0
    -2434
    -2435		else: # if key not in ['D47', 'D48']
    -2436			chisq, Nf = 0, 0
    -2437			for sample in mysamples :
    -2438				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    -2439				if len(X) > 1 :
    -2440					Nf += len(X) - 1
    -2441					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
    -2442			r = (chisq / Nf)**.5 if Nf > 0 else 0
    -2443
    -2444		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
    -2445		return r
    +            
    2464	@make_verbal
    +2465	def compute_r(self, key, samples = 'all samples', sessions = 'all sessions'):
    +2466		'''
    +2467		Compute the repeatability of `[r[key] for r in self]`
    +2468		'''
    +2469		# NB: it's debatable whether rD47 should be computed
    +2470		# with Nf = len(self)-len(self.samples) instead of
    +2471		# Nf = len(self) - len(self.unknwons) - 3*len(self.sessions)
    +2472
    +2473		if samples == 'all samples':
    +2474			mysamples = [k for k in self.samples]
    +2475		elif samples == 'anchors':
    +2476			mysamples = [k for k in self.anchors]
    +2477		elif samples == 'unknowns':
    +2478			mysamples = [k for k in self.unknowns]
    +2479		else:
    +2480			mysamples = samples
    +2481
    +2482		if sessions == 'all sessions':
    +2483			sessions = [k for k in self.sessions]
    +2484
    +2485		if key in ['D47', 'D48']:
    +2486			chisq, Nf = 0, 0
    +2487			for sample in mysamples :
    +2488				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    +2489				if len(X) > 1 :
    +2490					chisq += np.sum([ (x-self.samples[sample][key])**2 for x in X ])
    +2491					if sample in self.unknowns:
    +2492						Nf += len(X) - 1
    +2493					else:
    +2494						Nf += len(X)
    +2495			if samples in ['anchors', 'all samples']:
    +2496				Nf -= sum([self.sessions[s]['Np'] for s in sessions])
    +2497			r = (chisq / Nf)**.5 if Nf > 0 else 0
    +2498
    +2499		else: # if key not in ['D47', 'D48']
    +2500			chisq, Nf = 0, 0
    +2501			for sample in mysamples :
    +2502				X = [ r[key] for r in self if r['Sample'] == sample and r['Session'] in sessions ]
    +2503				if len(X) > 1 :
    +2504					Nf += len(X) - 1
    +2505					chisq += np.sum([ (x-np.mean(X))**2 for x in X ])
    +2506			r = (chisq / Nf)**.5 if Nf > 0 else 0
    +2507
    +2508		self.msg(f'Repeatability of r["{key}"] is {1000*r:.1f} ppm for {samples}.')
    +2509		return r
     
    @@ -9931,46 +10023,46 @@

    API Documentation

    -
    2447	def sample_average(self, samples, weights = 'equal', normalize = True):
    -2448		'''
    -2449		Weighted average Δ4x value of a group of samples, accounting for covariance.
    -2450
    -2451		Returns the weighed average Δ4x value and associated SE
    -2452		of a group of samples. Weights are equal by default. If `normalize` is
    -2453		true, `weights` will be rescaled so that their sum equals 1.
    -2454
    -2455		**Examples**
    -2456
    -2457		```python
    -2458		self.sample_average(['X','Y'], [1, 2])
    -2459		```
    -2460
    -2461		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
    -2462		where Δ4x(X) and Δ4x(Y) are the average Δ4x
    -2463		values of samples X and Y, respectively.
    -2464
    -2465		```python
    -2466		self.sample_average(['X','Y'], [1, -1], normalize = False)
    -2467		```
    -2468
    -2469		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
    -2470		'''
    -2471		if weights == 'equal':
    -2472			weights = [1/len(samples)] * len(samples)
    -2473
    -2474		if normalize:
    -2475			s = sum(weights)
    -2476			if s:
    -2477				weights = [w/s for w in weights]
    -2478
    -2479		try:
    -2480# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
    -2481# 			C = self.standardization.covar[indices,:][:,indices]
    -2482			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
    -2483			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
    -2484			return correlated_sum(X, C, weights)
    -2485		except ValueError:
    -2486			return (0., 0.)
    +            
    2511	def sample_average(self, samples, weights = 'equal', normalize = True):
    +2512		'''
    +2513		Weighted average Δ4x value of a group of samples, accounting for covariance.
    +2514
    +2515		Returns the weighed average Δ4x value and associated SE
    +2516		of a group of samples. Weights are equal by default. If `normalize` is
    +2517		true, `weights` will be rescaled so that their sum equals 1.
    +2518
    +2519		**Examples**
    +2520
    +2521		```python
    +2522		self.sample_average(['X','Y'], [1, 2])
    +2523		```
    +2524
    +2525		returns the value and SE of [Δ4x(X) + 2 Δ4x(Y)]/3,
    +2526		where Δ4x(X) and Δ4x(Y) are the average Δ4x
    +2527		values of samples X and Y, respectively.
    +2528
    +2529		```python
    +2530		self.sample_average(['X','Y'], [1, -1], normalize = False)
    +2531		```
    +2532
    +2533		returns the value and SE of the difference Δ4x(X) - Δ4x(Y).
    +2534		'''
    +2535		if weights == 'equal':
    +2536			weights = [1/len(samples)] * len(samples)
    +2537
    +2538		if normalize:
    +2539			s = sum(weights)
    +2540			if s:
    +2541				weights = [w/s for w in weights]
    +2542
    +2543		try:
    +2544# 			indices = [self.standardization.var_names.index(f'D47_{pf(sample)}') for sample in samples]
    +2545# 			C = self.standardization.covar[indices,:][:,indices]
    +2546			C = np.array([[self.sample_D4x_covar(x, y) for x in samples] for y in samples])
    +2547			X = [self.samples[sample][f'D{self._4x}'] for sample in samples]
    +2548			return correlated_sum(X, C, weights)
    +2549		except ValueError:
    +2550			return (0., 0.)
     
    @@ -10012,44 +10104,44 @@

    API Documentation

    -
    2489	def sample_D4x_covar(self, sample1, sample2 = None):
    -2490		'''
    -2491		Covariance between Δ4x values of samples
    -2492
    -2493		Returns the error covariance between the average Δ4x values of two
    -2494		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
    -2495		returns the Δ4x variance for that sample.
    -2496		'''
    -2497		if sample2 is None:
    -2498			sample2 = sample1
    -2499		if self.standardization_method == 'pooled':
    -2500			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
    -2501			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
    -2502			return self.standardization.covar[i, j]
    -2503		elif self.standardization_method == 'indep_sessions':
    -2504			if sample1 == sample2:
    -2505				return self.samples[sample1][f'SE_D{self._4x}']**2
    -2506			else:
    -2507				c = 0
    -2508				for session in self.sessions:
    -2509					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
    -2510					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
    -2511					if sdata1 and sdata2:
    -2512						a = self.sessions[session]['a']
    -2513						# !! TODO: CM below does not account for temporal changes in standardization parameters
    -2514						CM = self.sessions[session]['CM'][:3,:3]
    -2515						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
    -2516						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
    -2517						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
    -2518						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
    -2519						c += (
    -2520							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
    -2521							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
    -2522							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
    -2523							@ CM
    -2524							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
    -2525							) / a**2
    -2526				return float(c)
    +            
    2553	def sample_D4x_covar(self, sample1, sample2 = None):
    +2554		'''
    +2555		Covariance between Δ4x values of samples
    +2556
    +2557		Returns the error covariance between the average Δ4x values of two
    +2558		samples. If if only `sample_1` is specified, or if `sample_1 == sample_2`),
    +2559		returns the Δ4x variance for that sample.
    +2560		'''
    +2561		if sample2 is None:
    +2562			sample2 = sample1
    +2563		if self.standardization_method == 'pooled':
    +2564			i = self.standardization.var_names.index(f'D{self._4x}_{pf(sample1)}')
    +2565			j = self.standardization.var_names.index(f'D{self._4x}_{pf(sample2)}')
    +2566			return self.standardization.covar[i, j]
    +2567		elif self.standardization_method == 'indep_sessions':
    +2568			if sample1 == sample2:
    +2569				return self.samples[sample1][f'SE_D{self._4x}']**2
    +2570			else:
    +2571				c = 0
    +2572				for session in self.sessions:
    +2573					sdata1 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample1]
    +2574					sdata2 = [r for r in self.sessions[session]['data'] if r['Sample'] == sample2]
    +2575					if sdata1 and sdata2:
    +2576						a = self.sessions[session]['a']
    +2577						# !! TODO: CM below does not account for temporal changes in standardization parameters
    +2578						CM = self.sessions[session]['CM'][:3,:3]
    +2579						avg_D4x_1 = np.mean([r[f'D{self._4x}'] for r in sdata1])
    +2580						avg_d4x_1 = np.mean([r[f'd{self._4x}'] for r in sdata1])
    +2581						avg_D4x_2 = np.mean([r[f'D{self._4x}'] for r in sdata2])
    +2582						avg_d4x_2 = np.mean([r[f'd{self._4x}'] for r in sdata2])
    +2583						c += (
    +2584							self.unknowns[sample1][f'session_D{self._4x}'][session][2]
    +2585							* self.unknowns[sample2][f'session_D{self._4x}'][session][2]
    +2586							* np.array([[avg_D4x_1, avg_d4x_1, 1]])
    +2587							@ CM
    +2588							@ np.array([[avg_D4x_2, avg_d4x_2, 1]]).T
    +2589							) / a**2
    +2590				return float(c)
     
    @@ -10073,19 +10165,19 @@

    API Documentation

    -
    2528	def sample_D4x_correl(self, sample1, sample2 = None):
    -2529		'''
    -2530		Correlation between Δ4x errors of samples
    -2531
    -2532		Returns the error correlation between the average Δ4x values of two samples.
    -2533		'''
    -2534		if sample2 is None or sample2 == sample1:
    -2535			return 1.
    -2536		return (
    -2537			self.sample_D4x_covar(sample1, sample2)
    -2538			/ self.unknowns[sample1][f'SE_D{self._4x}']
    -2539			/ self.unknowns[sample2][f'SE_D{self._4x}']
    -2540			)
    +            
    2592	def sample_D4x_correl(self, sample1, sample2 = None):
    +2593		'''
    +2594		Correlation between Δ4x errors of samples
    +2595
    +2596		Returns the error correlation between the average Δ4x values of two samples.
    +2597		'''
    +2598		if sample2 is None or sample2 == sample1:
    +2599			return 1.
    +2600		return (
    +2601			self.sample_D4x_covar(sample1, sample2)
    +2602			/ self.unknowns[sample1][f'SE_D{self._4x}']
    +2603			/ self.unknowns[sample2][f'SE_D{self._4x}']
    +2604			)
     
    @@ -10107,104 +10199,104 @@

    API Documentation

    -
    2542	def plot_single_session(self,
    -2543		session,
    -2544		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
    -2545		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
    -2546		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
    -2547		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
    -2548		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
    -2549		xylimits = 'free', # | 'constant'
    -2550		x_label = None,
    -2551		y_label = None,
    -2552		error_contour_interval = 'auto',
    -2553		fig = 'new',
    -2554		):
    -2555		'''
    -2556		Generate plot for a single session
    -2557		'''
    -2558		if x_label is None:
    -2559			x_label = f'δ$_{{{self._4x}}}$ (‰)'
    -2560		if y_label is None:
    -2561			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
    -2562
    -2563		out = _SessionPlot()
    -2564		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
    -2565		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
    -2566		
    -2567		if fig == 'new':
    -2568			out.fig = ppl.figure(figsize = (6,6))
    -2569			ppl.subplots_adjust(.1,.1,.9,.9)
    -2570
    -2571		out.anchor_analyses, = ppl.plot(
    -2572			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    -2573			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    -2574			**kw_plot_anchors)
    -2575		out.unknown_analyses, = ppl.plot(
    -2576			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    -2577			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    -2578			**kw_plot_unknowns)
    -2579		out.anchor_avg = ppl.plot(
    -2580			np.array([ np.array([
    -2581				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    -2582				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    -2583				]) for sample in anchors]).T,
    -2584			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
    -2585			**kw_plot_anchor_avg)
    -2586		out.unknown_avg = ppl.plot(
    -2587			np.array([ np.array([
    -2588				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    -2589				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    -2590				]) for sample in unknowns]).T,
    -2591			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
    -2592			**kw_plot_unknown_avg)
    -2593		if xylimits == 'constant':
    -2594			x = [r[f'd{self._4x}'] for r in self]
    -2595			y = [r[f'D{self._4x}'] for r in self]
    -2596			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
    -2597			w, h = x2-x1, y2-y1
    -2598			x1 -= w/20
    -2599			x2 += w/20
    -2600			y1 -= h/20
    -2601			y2 += h/20
    -2602			ppl.axis([x1, x2, y1, y2])
    -2603		elif xylimits == 'free':
    -2604			x1, x2, y1, y2 = ppl.axis()
    -2605		else:
    -2606			x1, x2, y1, y2 = ppl.axis(xylimits)
    -2607				
    -2608		if error_contour_interval != 'none':
    -2609			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
    -2610			XI,YI = np.meshgrid(xi, yi)
    -2611			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
    -2612			if error_contour_interval == 'auto':
    -2613				rng = np.max(SI) - np.min(SI)
    -2614				if rng <= 0.01:
    -2615					cinterval = 0.001
    -2616				elif rng <= 0.03:
    -2617					cinterval = 0.004
    -2618				elif rng <= 0.1:
    -2619					cinterval = 0.01
    -2620				elif rng <= 0.3:
    -2621					cinterval = 0.03
    -2622				elif rng <= 1.:
    -2623					cinterval = 0.1
    -2624				else:
    -2625					cinterval = 0.5
    -2626			else:
    -2627				cinterval = error_contour_interval
    -2628
    -2629			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
    -2630			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
    -2631			out.clabel = ppl.clabel(out.contour)
    -2632
    -2633		ppl.xlabel(x_label)
    -2634		ppl.ylabel(y_label)
    -2635		ppl.title(session, weight = 'bold')
    -2636		ppl.grid(alpha = .2)
    -2637		out.ax = ppl.gca()		
    -2638
    -2639		return out
    +            
    2606	def plot_single_session(self,
    +2607		session,
    +2608		kw_plot_anchors = dict(ls='None', marker='x', mec=(.75, 0, 0), mew = .75, ms = 4),
    +2609		kw_plot_unknowns = dict(ls='None', marker='x', mec=(0, 0, .75), mew = .75, ms = 4),
    +2610		kw_plot_anchor_avg = dict(ls='-', marker='None', color=(.75, 0, 0), lw = .75),
    +2611		kw_plot_unknown_avg = dict(ls='-', marker='None', color=(0, 0, .75), lw = .75),
    +2612		kw_contour_error = dict(colors = [[0, 0, 0]], alpha = .5, linewidths = 0.75),
    +2613		xylimits = 'free', # | 'constant'
    +2614		x_label = None,
    +2615		y_label = None,
    +2616		error_contour_interval = 'auto',
    +2617		fig = 'new',
    +2618		):
    +2619		'''
    +2620		Generate plot for a single session
    +2621		'''
    +2622		if x_label is None:
    +2623			x_label = f'δ$_{{{self._4x}}}$ (‰)'
    +2624		if y_label is None:
    +2625			y_label = f'Δ$_{{{self._4x}}}$ (‰)'
    +2626
    +2627		out = _SessionPlot()
    +2628		anchors = [a for a in self.anchors if [r for r in self.sessions[session]['data'] if r['Sample'] == a]]
    +2629		unknowns = [u for u in self.unknowns if [r for r in self.sessions[session]['data'] if r['Sample'] == u]]
    +2630		
    +2631		if fig == 'new':
    +2632			out.fig = ppl.figure(figsize = (6,6))
    +2633			ppl.subplots_adjust(.1,.1,.9,.9)
    +2634
    +2635		out.anchor_analyses, = ppl.plot(
    +2636			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    +2637			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.anchors],
    +2638			**kw_plot_anchors)
    +2639		out.unknown_analyses, = ppl.plot(
    +2640			[r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    +2641			[r[f'D{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] in self.unknowns],
    +2642			**kw_plot_unknowns)
    +2643		out.anchor_avg = ppl.plot(
    +2644			np.array([ np.array([
    +2645				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    +2646				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    +2647				]) for sample in anchors]).T,
    +2648			np.array([ np.array([0, 0]) + self.Nominal_D4x[sample] for sample in anchors]).T,
    +2649			**kw_plot_anchor_avg)
    +2650		out.unknown_avg = ppl.plot(
    +2651			np.array([ np.array([
    +2652				np.min([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) - 1,
    +2653				np.max([r[f'd{self._4x}'] for r in self.sessions[session]['data'] if r['Sample'] == sample]) + 1
    +2654				]) for sample in unknowns]).T,
    +2655			np.array([ np.array([0, 0]) + self.unknowns[sample][f'D{self._4x}'] for sample in unknowns]).T,
    +2656			**kw_plot_unknown_avg)
    +2657		if xylimits == 'constant':
    +2658			x = [r[f'd{self._4x}'] for r in self]
    +2659			y = [r[f'D{self._4x}'] for r in self]
    +2660			x1, x2, y1, y2 = np.min(x), np.max(x), np.min(y), np.max(y)
    +2661			w, h = x2-x1, y2-y1
    +2662			x1 -= w/20
    +2663			x2 += w/20
    +2664			y1 -= h/20
    +2665			y2 += h/20
    +2666			ppl.axis([x1, x2, y1, y2])
    +2667		elif xylimits == 'free':
    +2668			x1, x2, y1, y2 = ppl.axis()
    +2669		else:
    +2670			x1, x2, y1, y2 = ppl.axis(xylimits)
    +2671				
    +2672		if error_contour_interval != 'none':
    +2673			xi, yi = np.linspace(x1, x2), np.linspace(y1, y2)
    +2674			XI,YI = np.meshgrid(xi, yi)
    +2675			SI = np.array([[self.standardization_error(session, x, y) for x in xi] for y in yi])
    +2676			if error_contour_interval == 'auto':
    +2677				rng = np.max(SI) - np.min(SI)
    +2678				if rng <= 0.01:
    +2679					cinterval = 0.001
    +2680				elif rng <= 0.03:
    +2681					cinterval = 0.004
    +2682				elif rng <= 0.1:
    +2683					cinterval = 0.01
    +2684				elif rng <= 0.3:
    +2685					cinterval = 0.03
    +2686				elif rng <= 1.:
    +2687					cinterval = 0.1
    +2688				else:
    +2689					cinterval = 0.5
    +2690			else:
    +2691				cinterval = error_contour_interval
    +2692
    +2693			cval = np.arange(np.ceil(SI.min() / .001) * .001, np.ceil(SI.max() / .001 + 1) * .001, cinterval)
    +2694			out.contour = ppl.contour(XI, YI, SI, cval, **kw_contour_error)
    +2695			out.clabel = ppl.clabel(out.contour)
    +2696
    +2697		ppl.xlabel(x_label)
    +2698		ppl.ylabel(y_label)
    +2699		ppl.title(session, weight = 'bold')
    +2700		ppl.grid(alpha = .2)
    +2701		out.ax = ppl.gca()		
    +2702
    +2703		return out
     
    @@ -10224,193 +10316,193 @@

    API Documentation

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

    API Documentation

    -
    2830	def simulate(self, *args, **kwargs):
    -2831		'''
    -2832		Legacy function with warning message pointing to `virtual_data()`
    -2833		'''
    -2834		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
    +            
    2894	def simulate(self, *args, **kwargs):
    +2895		'''
    +2896		Legacy function with warning message pointing to `virtual_data()`
    +2897		'''
    +2898		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
     
    @@ -10464,81 +10556,81 @@

    API Documentation

    -
    2836	def plot_distribution_of_analyses(
    -2837		self,
    -2838		dir = 'output',
    -2839		filename = None,
    -2840		vs_time = False,
    -2841		figsize = (6,4),
    -2842		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
    -2843		output = None,
    -2844		):
    -2845		'''
    -2846		Plot temporal distribution of all analyses in the data set.
    -2847		
    -2848		**Parameters**
    -2849
    -2850		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    -2851		'''
    -2852
    -2853		asamples = [s for s in self.anchors]
    -2854		usamples = [s for s in self.unknowns]
    -2855		if output is None or output == 'fig':
    -2856			fig = ppl.figure(figsize = figsize)
    -2857			ppl.subplots_adjust(*subplots_adjust)
    -2858		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    -2859		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    -2860		Xmax += (Xmax-Xmin)/40
    -2861		Xmin -= (Xmax-Xmin)/41
    -2862		for k, s in enumerate(asamples + usamples):
    -2863			if vs_time:
    -2864				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    -2865			else:
    -2866				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    -2867			Y = [-k for x in X]
    -2868			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
    -2869			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    -2870			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
    -2871		ppl.axis([Xmin, Xmax, -k-1, 1])
    -2872		ppl.xlabel('\ntime')
    -2873		ppl.gca().annotate('',
    -2874			xy = (0.6, -0.02),
    -2875			xycoords = 'axes fraction',
    -2876			xytext = (.4, -0.02), 
    -2877            arrowprops = dict(arrowstyle = "->", color = 'k'),
    -2878            )
    -2879			
    -2880
    -2881		x2 = -1
    -2882		for session in self.sessions:
    -2883			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    -2884			if vs_time:
    -2885				ppl.axvline(x1, color = 'k', lw = .75)
    -2886			if x2 > -1:
    -2887				if not vs_time:
    -2888					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
    -2889			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    -2890# 			from xlrd import xldate_as_datetime
    -2891# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    -2892			if vs_time:
    -2893				ppl.axvline(x2, color = 'k', lw = .75)
    -2894				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
    -2895			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
    -2896
    -2897		ppl.xticks([])
    -2898		ppl.yticks([])
    -2899
    -2900		if output is None:
    -2901			if not os.path.exists(dir):
    -2902				os.makedirs(dir)
    -2903			if filename == None:
    -2904				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    -2905			ppl.savefig(f'{dir}/{filename}')
    -2906			ppl.close(fig)
    -2907		elif output == 'ax':
    -2908			return ppl.gca()
    -2909		elif output == 'fig':
    -2910			return fig
    +            
    2900	def plot_distribution_of_analyses(
    +2901		self,
    +2902		dir = 'output',
    +2903		filename = None,
    +2904		vs_time = False,
    +2905		figsize = (6,4),
    +2906		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
    +2907		output = None,
    +2908		):
    +2909		'''
    +2910		Plot temporal distribution of all analyses in the data set.
    +2911		
    +2912		**Parameters**
    +2913
    +2914		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    +2915		'''
    +2916
    +2917		asamples = [s for s in self.anchors]
    +2918		usamples = [s for s in self.unknowns]
    +2919		if output is None or output == 'fig':
    +2920			fig = ppl.figure(figsize = figsize)
    +2921			ppl.subplots_adjust(*subplots_adjust)
    +2922		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    +2923		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    +2924		Xmax += (Xmax-Xmin)/40
    +2925		Xmin -= (Xmax-Xmin)/41
    +2926		for k, s in enumerate(asamples + usamples):
    +2927			if vs_time:
    +2928				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    +2929			else:
    +2930				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    +2931			Y = [-k for x in X]
    +2932			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
    +2933			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    +2934			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
    +2935		ppl.axis([Xmin, Xmax, -k-1, 1])
    +2936		ppl.xlabel('\ntime')
    +2937		ppl.gca().annotate('',
    +2938			xy = (0.6, -0.02),
    +2939			xycoords = 'axes fraction',
    +2940			xytext = (.4, -0.02), 
    +2941            arrowprops = dict(arrowstyle = "->", color = 'k'),
    +2942            )
    +2943			
    +2944
    +2945		x2 = -1
    +2946		for session in self.sessions:
    +2947			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    +2948			if vs_time:
    +2949				ppl.axvline(x1, color = 'k', lw = .75)
    +2950			if x2 > -1:
    +2951				if not vs_time:
    +2952					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
    +2953			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    +2954# 			from xlrd import xldate_as_datetime
    +2955# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    +2956			if vs_time:
    +2957				ppl.axvline(x2, color = 'k', lw = .75)
    +2958				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
    +2959			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
    +2960
    +2961		ppl.xticks([])
    +2962		ppl.yticks([])
    +2963
    +2964		if output is None:
    +2965			if not os.path.exists(dir):
    +2966				os.makedirs(dir)
    +2967			if filename == None:
    +2968				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    +2969			ppl.savefig(f'{dir}/{filename}')
    +2970			ppl.close(fig)
    +2971		elif output == 'ax':
    +2972			return ppl.gca()
    +2973		elif output == 'fig':
    +2974			return fig
     
    @@ -10584,94 +10676,94 @@
    Inherited Members
    -
    2913class D47data(D4xdata):
    -2914	'''
    -2915	Store and process data for a large set of Δ47 analyses,
    -2916	usually comprising more than one analytical session.
    -2917	'''
    -2918
    -2919	Nominal_D4x = {
    -2920		'ETH-1':   0.2052,
    -2921		'ETH-2':   0.2085,
    -2922		'ETH-3':   0.6132,
    -2923		'ETH-4':   0.4511,
    -2924		'IAEA-C1': 0.3018,
    -2925		'IAEA-C2': 0.6409,
    -2926		'MERCK':   0.5135,
    -2927		} # I-CDES (Bernasconi et al., 2021)
    -2928	'''
    -2929	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
    -2930	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
    -2931	reference frame.
    -2932
    -2933	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
    -2934	```py
    -2935	{
    -2936		'ETH-1'   : 0.2052,
    -2937		'ETH-2'   : 0.2085,
    -2938		'ETH-3'   : 0.6132,
    -2939		'ETH-4'   : 0.4511,
    -2940		'IAEA-C1' : 0.3018,
    -2941		'IAEA-C2' : 0.6409,
    -2942		'MERCK'   : 0.5135,
    -2943	}
    -2944	```
    -2945	'''
    -2946
    -2947
    -2948	@property
    -2949	def Nominal_D47(self):
    -2950		return self.Nominal_D4x
    -2951	
    -2952
    -2953	@Nominal_D47.setter
    -2954	def Nominal_D47(self, new):
    -2955		self.Nominal_D4x = dict(**new)
    -2956		self.refresh()
    -2957
    -2958
    -2959	def __init__(self, l = [], **kwargs):
    -2960		'''
    -2961		**Parameters:** same as `D4xdata.__init__()`
    -2962		'''
    -2963		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    -2964
    -2965
    -2966	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    -2967		'''
    -2968		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    -2969		value for that temperature, and add treat these samples as additional anchors.
    -2970
    -2971		**Parameters**
    -2972
    -2973		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    -2974		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    -2975		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    -2976		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    -2977		if `new`: keep pre-existing anchors but update them in case of conflict
    -2978		between old and new Δ47 values;
    -2979		if `old`: keep pre-existing anchors but preserve their original Δ47
    -2980		values in case of conflict.
    -2981		'''
    -2982		f = {
    -2983			'petersen': fCO2eqD47_Petersen,
    -2984			'wang': fCO2eqD47_Wang,
    -2985			}[fCo2eqD47]
    -2986		foo = {}
    -2987		for r in self:
    -2988			if 'Teq' in r:
    -2989				if r['Sample'] in foo:
    -2990					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    -2991				else:
    -2992					foo[r['Sample']] = f(r['Teq'])
    -2993			else:
    -2994					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    -2995
    -2996		if priority == 'replace':
    -2997			self.Nominal_D47 = {}
    -2998		for s in foo:
    -2999			if priority != 'old' or s not in self.Nominal_D47:
    -3000				self.Nominal_D47[s] = foo[s]
    +            
    2977class D47data(D4xdata):
    +2978	'''
    +2979	Store and process data for a large set of Δ47 analyses,
    +2980	usually comprising more than one analytical session.
    +2981	'''
    +2982
    +2983	Nominal_D4x = {
    +2984		'ETH-1':   0.2052,
    +2985		'ETH-2':   0.2085,
    +2986		'ETH-3':   0.6132,
    +2987		'ETH-4':   0.4511,
    +2988		'IAEA-C1': 0.3018,
    +2989		'IAEA-C2': 0.6409,
    +2990		'MERCK':   0.5135,
    +2991		} # I-CDES (Bernasconi et al., 2021)
    +2992	'''
    +2993	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
    +2994	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
    +2995	reference frame.
    +2996
    +2997	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
    +2998	```py
    +2999	{
    +3000		'ETH-1'   : 0.2052,
    +3001		'ETH-2'   : 0.2085,
    +3002		'ETH-3'   : 0.6132,
    +3003		'ETH-4'   : 0.4511,
    +3004		'IAEA-C1' : 0.3018,
    +3005		'IAEA-C2' : 0.6409,
    +3006		'MERCK'   : 0.5135,
    +3007	}
    +3008	```
    +3009	'''
    +3010
    +3011
    +3012	@property
    +3013	def Nominal_D47(self):
    +3014		return self.Nominal_D4x
    +3015	
    +3016
    +3017	@Nominal_D47.setter
    +3018	def Nominal_D47(self, new):
    +3019		self.Nominal_D4x = dict(**new)
    +3020		self.refresh()
    +3021
    +3022
    +3023	def __init__(self, l = [], **kwargs):
    +3024		'''
    +3025		**Parameters:** same as `D4xdata.__init__()`
    +3026		'''
    +3027		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    +3028
    +3029
    +3030	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    +3031		'''
    +3032		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    +3033		value for that temperature, and add treat these samples as additional anchors.
    +3034
    +3035		**Parameters**
    +3036
    +3037		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    +3038		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    +3039		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    +3040		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    +3041		if `new`: keep pre-existing anchors but update them in case of conflict
    +3042		between old and new Δ47 values;
    +3043		if `old`: keep pre-existing anchors but preserve their original Δ47
    +3044		values in case of conflict.
    +3045		'''
    +3046		f = {
    +3047			'petersen': fCO2eqD47_Petersen,
    +3048			'wang': fCO2eqD47_Wang,
    +3049			}[fCo2eqD47]
    +3050		foo = {}
    +3051		for r in self:
    +3052			if 'Teq' in r:
    +3053				if r['Sample'] in foo:
    +3054					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    +3055				else:
    +3056					foo[r['Sample']] = f(r['Teq'])
    +3057			else:
    +3058					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    +3059
    +3060		if priority == 'replace':
    +3061			self.Nominal_D47 = {}
    +3062		for s in foo:
    +3063			if priority != 'old' or s not in self.Nominal_D47:
    +3064				self.Nominal_D47[s] = foo[s]
     
    @@ -10690,11 +10782,11 @@
    Inherited Members
    -
    2959	def __init__(self, l = [], **kwargs):
    -2960		'''
    -2961		**Parameters:** same as `D4xdata.__init__()`
    -2962		'''
    -2963		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    +            
    3023	def __init__(self, l = [], **kwargs):
    +3024		'''
    +3025		**Parameters:** same as `D4xdata.__init__()`
    +3026		'''
    +3027		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
     
    @@ -10746,41 +10838,41 @@
    Inherited Members
    -
    2966	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    -2967		'''
    -2968		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    -2969		value for that temperature, and add treat these samples as additional anchors.
    -2970
    -2971		**Parameters**
    -2972
    -2973		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    -2974		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    -2975		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    -2976		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    -2977		if `new`: keep pre-existing anchors but update them in case of conflict
    -2978		between old and new Δ47 values;
    -2979		if `old`: keep pre-existing anchors but preserve their original Δ47
    -2980		values in case of conflict.
    -2981		'''
    -2982		f = {
    -2983			'petersen': fCO2eqD47_Petersen,
    -2984			'wang': fCO2eqD47_Wang,
    -2985			}[fCo2eqD47]
    -2986		foo = {}
    -2987		for r in self:
    -2988			if 'Teq' in r:
    -2989				if r['Sample'] in foo:
    -2990					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    -2991				else:
    -2992					foo[r['Sample']] = f(r['Teq'])
    -2993			else:
    -2994					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    -2995
    -2996		if priority == 'replace':
    -2997			self.Nominal_D47 = {}
    -2998		for s in foo:
    -2999			if priority != 'old' or s not in self.Nominal_D47:
    -3000				self.Nominal_D47[s] = foo[s]
    +            
    3030	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    +3031		'''
    +3032		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    +3033		value for that temperature, and add treat these samples as additional anchors.
    +3034
    +3035		**Parameters**
    +3036
    +3037		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    +3038		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    +3039		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    +3040		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    +3041		if `new`: keep pre-existing anchors but update them in case of conflict
    +3042		between old and new Δ47 values;
    +3043		if `old`: keep pre-existing anchors but preserve their original Δ47
    +3044		values in case of conflict.
    +3045		'''
    +3046		f = {
    +3047			'petersen': fCO2eqD47_Petersen,
    +3048			'wang': fCO2eqD47_Wang,
    +3049			}[fCo2eqD47]
    +3050		foo = {}
    +3051		for r in self:
    +3052			if 'Teq' in r:
    +3053				if r['Sample'] in foo:
    +3054					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    +3055				else:
    +3056					foo[r['Sample']] = f(r['Teq'])
    +3057			else:
    +3058					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    +3059
    +3060		if priority == 'replace':
    +3061			self.Nominal_D47 = {}
    +3062		for s in foo:
    +3063			if priority != 'old' or s not in self.Nominal_D47:
    +3064				self.Nominal_D47[s] = foo[s]
     
    @@ -10892,55 +10984,55 @@
    Inherited Members
    -
    3005class D48data(D4xdata):
    -3006	'''
    -3007	Store and process data for a large set of Δ48 analyses,
    -3008	usually comprising more than one analytical session.
    -3009	'''
    -3010
    -3011	Nominal_D4x = {
    -3012		'ETH-1':  0.138,
    -3013		'ETH-2':  0.138,
    -3014		'ETH-3':  0.270,
    -3015		'ETH-4':  0.223,
    -3016		'GU-1':  -0.419,
    -3017		} # (Fiebig et al., 2019, 2021)
    -3018	'''
    -3019	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
    -3020	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
    -3021	reference frame.
    -3022
    -3023	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
    -3024	Fiebig et al. (in press)):
    -3025
    -3026	```py
    -3027	{
    -3028		'ETH-1' :  0.138,
    -3029		'ETH-2' :  0.138,
    -3030		'ETH-3' :  0.270,
    -3031		'ETH-4' :  0.223,
    -3032		'GU-1'  : -0.419,
    -3033	}
    -3034	```
    -3035	'''
    -3036
    -3037
    -3038	@property
    -3039	def Nominal_D48(self):
    -3040		return self.Nominal_D4x
    -3041
    -3042	
    -3043	@Nominal_D48.setter
    -3044	def Nominal_D48(self, new):
    -3045		self.Nominal_D4x = dict(**new)
    -3046		self.refresh()
    -3047
    -3048
    -3049	def __init__(self, l = [], **kwargs):
    -3050		'''
    -3051		**Parameters:** same as `D4xdata.__init__()`
    -3052		'''
    -3053		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
    +            
    3069class D48data(D4xdata):
    +3070	'''
    +3071	Store and process data for a large set of Δ48 analyses,
    +3072	usually comprising more than one analytical session.
    +3073	'''
    +3074
    +3075	Nominal_D4x = {
    +3076		'ETH-1':  0.138,
    +3077		'ETH-2':  0.138,
    +3078		'ETH-3':  0.270,
    +3079		'ETH-4':  0.223,
    +3080		'GU-1':  -0.419,
    +3081		} # (Fiebig et al., 2019, 2021)
    +3082	'''
    +3083	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
    +3084	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
    +3085	reference frame.
    +3086
    +3087	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
    +3088	Fiebig et al. (in press)):
    +3089
    +3090	```py
    +3091	{
    +3092		'ETH-1' :  0.138,
    +3093		'ETH-2' :  0.138,
    +3094		'ETH-3' :  0.270,
    +3095		'ETH-4' :  0.223,
    +3096		'GU-1'  : -0.419,
    +3097	}
    +3098	```
    +3099	'''
    +3100
    +3101
    +3102	@property
    +3103	def Nominal_D48(self):
    +3104		return self.Nominal_D4x
    +3105
    +3106	
    +3107	@Nominal_D48.setter
    +3108	def Nominal_D48(self, new):
    +3109		self.Nominal_D4x = dict(**new)
    +3110		self.refresh()
    +3111
    +3112
    +3113	def __init__(self, l = [], **kwargs):
    +3114		'''
    +3115		**Parameters:** same as `D4xdata.__init__()`
    +3116		'''
    +3117		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
     
    @@ -10959,11 +11051,11 @@
    Inherited Members
    -
    3049	def __init__(self, l = [], **kwargs):
    -3050		'''
    -3051		**Parameters:** same as `D4xdata.__init__()`
    -3052		'''
    -3053		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
    +            
    3113	def __init__(self, l = [], **kwargs):
    +3114		'''
    +3115		**Parameters:** same as `D4xdata.__init__()`
    +3116		'''
    +3117		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
     
    diff --git a/tests/test_covar_for_constained_params.py b/tests/test_covar_for_constained_params.py new file mode 100755 index 0000000..d31182d --- /dev/null +++ b/tests/test_covar_for_constained_params.py @@ -0,0 +1,36 @@ +#! /usr/bin/env python3 + +from lmfit import report_fit +from D47crunch import * + +mydata1 = virtual_data( + session = 'mysession1', + samples = [ + dict(Sample = 'ETH-1', N = 4), + dict(Sample = 'ETH-2', N = 4), + dict(Sample = 'ETH-3', N = 4), + dict(Sample = 'FOO', N = 4, D47 = 0.6, D48 = 0.1, d13C_VPDB = -4.0, d18O_VPDB = -12.0), + dict(Sample = 'BAR', N = 4, D47 = 0.5, D48 = 0.1, d13C_VPDB = -14.0, d18O_VPDB = -22.0), + ]) + +mydata2 = virtual_data( + session = 'mysession2', + samples = [ + dict(Sample = 'ETH-1', N = 4), + dict(Sample = 'ETH-2', N = 4), + dict(Sample = 'ETH-3', N = 4), + dict(Sample = 'FOO', N = 4, D47 = 0.6, D48 = 0.1, d13C_VPDB = -4.0, d18O_VPDB = -12.0), + dict(Sample = 'BAR', N = 4, D47 = 0.5, D48 = 0.1, d13C_VPDB = -14.0, d18O_VPDB = -22.0), + ]) + +mydata = D47data(mydata1+mydata2, verbose = True) + +mydata.refresh() +mydata.wg() +mydata.crunch() +report_fit(mydata.standardize( + constraints = {'D47_FOO': 'D47_BAR + 0.1'} + )) +mydata.table_of_sessions() +mydata.table_of_samples() +mydata.plot_sessions() From cc0be71e84f96f1be672756391253f6d22c328c2 Mon Sep 17 00:00:00 2001 From: mdaeron Date: Thu, 11 May 2023 23:09:22 +0200 Subject: [PATCH 13/16] Prepare for v2.0.5 --- D47crunch/__init__.py | 2 +- changelog.md | 4 ++-- docs/index.html | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 556ddb4..80ac114 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -21,7 +21,7 @@ __copyright__ = 'Copyright (c) 2023 Mathieu Daëron' __license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause' __date__ = '2023-05-11' -__version__ = '2.0.5.dev0' +__version__ = '2.0.5' import os import numpy as np diff --git a/changelog.md b/changelog.md index 30201dd..9600c7c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,7 +1,7 @@ # Changelog -## v2.0.5.dev0 -*Unreleased* +## v2.0.5 +*Released on 2023-05-11* ### Changes * Under the hood: constrained parameters in pooled standardization now get fully propagated variance and covariance, allowing for truly arbitrary constraints without having book-keeping problems further down the line. diff --git a/docs/index.html b/docs/index.html index cca813c..b571380 100644 --- a/docs/index.html +++ b/docs/index.html @@ -819,7 +819,7 @@

    API Documentation

    21__copyright__ = 'Copyright (c) 2023 Mathieu Daëron' 22__license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause' 23__date__ = '2023-05-11' - 24__version__ = '2.0.5.dev0' + 24__version__ = '2.0.5' 25 26import os 27import numpy as np From 16094c1c50160b4d0ca2755c70d851f766d5ea86 Mon Sep 17 00:00:00 2001 From: mdaeron Date: Thu, 11 May 2023 23:26:40 +0200 Subject: [PATCH 14/16] Bump version --- D47crunch/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 80ac114..e9765cb 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -21,7 +21,7 @@ __copyright__ = 'Copyright (c) 2023 Mathieu Daëron' __license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause' __date__ = '2023-05-11' -__version__ = '2.0.5' +__version__ = '2.0.6.dev0' import os import numpy as np From 2fcc5ded0d5adc070f4297411be16dd34bc1cadd Mon Sep 17 00:00:00 2001 From: mdaeron Date: Sat, 13 May 2023 13:24:45 +0200 Subject: [PATCH 15/16] Bugfix --- D47crunch/__init__.py | 5 - docs/index.html | 12577 ++++++++++++++++++++-------------------- 2 files changed, 6286 insertions(+), 6296 deletions(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index e9765cb..86a2b11 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -855,16 +855,11 @@ def _fullcovar(minresult, epsilon = 0.01, named = False): def f(values): interp = asteval.Interpreter() - print(minresult.var_names, values) for n,v in zip(minresult.var_names, values): interp(f'{n} = {v}') - print(f'{n} = {v}') for q in minresult.params: - print(q, minresult.params[q].expr) if minresult.params[q].expr: interp(f'{q} = {minresult.params[q].expr}') - print(f'{q} = {minresult.params[q].expr}') - print() return np.array([interp.symtable[q] for q in minresult.params]) # construct Jacobian diff --git a/docs/index.html b/docs/index.html index b571380..1026265 100644 --- a/docs/index.html +++ b/docs/index.html @@ -819,7 +819,7 @@

    API Documentation

    21__copyright__ = 'Copyright (c) 2023 Mathieu Daëron' 22__license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause' 23__date__ = '2023-05-11' - 24__version__ = '2.0.5' + 24__version__ = '2.0.6.dev0' 25 26import os 27import numpy as np @@ -1653,2273 +1653,2268 @@

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

    -
    1065	def msg(self, txt):
    -1066		'''
    -1067		Log a message to `self.logfile`, and print it out if `verbose = True`
    -1068		'''
    -1069		self.log(txt)
    -1070		if self.verbose:
    -1071			print(f'{f"[{self.prefix}]":<16} {txt}')
    +            
    1060	def msg(self, txt):
    +1061		'''
    +1062		Log a message to `self.logfile`, and print it out if `verbose = True`
    +1063		'''
    +1064		self.log(txt)
    +1065		if self.verbose:
    +1066			print(f'{f"[{self.prefix}]":<16} {txt}')
     
    @@ -7749,12 +7744,12 @@

    API Documentation

    -
    1074	def vmsg(self, txt):
    -1075		'''
    -1076		Log a message to `self.logfile` and print it out
    -1077		'''
    -1078		self.log(txt)
    -1079		print(txt)
    +            
    1069	def vmsg(self, txt):
    +1070		'''
    +1071		Log a message to `self.logfile` and print it out
    +1072		'''
    +1073		self.log(txt)
    +1074		print(txt)
     
    @@ -7774,14 +7769,14 @@

    API Documentation

    -
    1082	def log(self, *txts):
    -1083		'''
    -1084		Log a message to `self.logfile`
    -1085		'''
    -1086		if self.logfile:
    -1087			with open(self.logfile, 'a') as fid:
    -1088				for txt in txts:
    -1089					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
    +            
    1077	def log(self, *txts):
    +1078		'''
    +1079		Log a message to `self.logfile`
    +1080		'''
    +1081		if self.logfile:
    +1082			with open(self.logfile, 'a') as fid:
    +1083				for txt in txts:
    +1084					fid.write(f'\n{dt.now().strftime("%Y-%m-%d %H:%M:%S")} {f"[{self.prefix}]":<16} {txt}')
     
    @@ -7801,13 +7796,13 @@

    API Documentation

    -
    1092	def refresh(self, session = 'mySession'):
    -1093		'''
    -1094		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    -1095		'''
    -1096		self.fill_in_missing_info(session = session)
    -1097		self.refresh_sessions()
    -1098		self.refresh_samples()
    +            
    1087	def refresh(self, session = 'mySession'):
    +1088		'''
    +1089		Update `self.sessions`, `self.samples`, `self.anchors`, and `self.unknowns`.
    +1090		'''
    +1091		self.fill_in_missing_info(session = session)
    +1092		self.refresh_sessions()
    +1093		self.refresh_samples()
     
    @@ -7827,21 +7822,21 @@

    API Documentation

    -
    1101	def refresh_sessions(self):
    -1102		'''
    -1103		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    -1104		to `False` for all sessions.
    -1105		'''
    -1106		self.sessions = {
    -1107			s: {'data': [r for r in self if r['Session'] == s]}
    -1108			for s in sorted({r['Session'] for r in self})
    -1109			}
    -1110		for s in self.sessions:
    -1111			self.sessions[s]['scrambling_drift'] = False
    -1112			self.sessions[s]['slope_drift'] = False
    -1113			self.sessions[s]['wg_drift'] = False
    -1114			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    -1115			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
    +            
    1096	def refresh_sessions(self):
    +1097		'''
    +1098		Update `self.sessions` and set `scrambling_drift`, `slope_drift`, and `wg_drift`
    +1099		to `False` for all sessions.
    +1100		'''
    +1101		self.sessions = {
    +1102			s: {'data': [r for r in self if r['Session'] == s]}
    +1103			for s in sorted({r['Session'] for r in self})
    +1104			}
    +1105		for s in self.sessions:
    +1106			self.sessions[s]['scrambling_drift'] = False
    +1107			self.sessions[s]['slope_drift'] = False
    +1108			self.sessions[s]['wg_drift'] = False
    +1109			self.sessions[s]['d13C_standardization_method'] = self.d13C_STANDARDIZATION_METHOD
    +1110			self.sessions[s]['d18O_standardization_method'] = self.d18O_STANDARDIZATION_METHOD
     
    @@ -7862,16 +7857,16 @@

    API Documentation

    -
    1118	def refresh_samples(self):
    -1119		'''
    -1120		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    -1121		'''
    -1122		self.samples = {
    -1123			s: {'data': [r for r in self if r['Sample'] == s]}
    -1124			for s in sorted({r['Sample'] for r in self})
    -1125			}
    -1126		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    -1127		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
    +            
    1113	def refresh_samples(self):
    +1114		'''
    +1115		Define `self.samples`, `self.anchors`, and `self.unknowns`.
    +1116		'''
    +1117		self.samples = {
    +1118			s: {'data': [r for r in self if r['Sample'] == s]}
    +1119			for s in sorted({r['Sample'] for r in self})
    +1120			}
    +1121		self.anchors = {s: self.samples[s] for s in self.samples if s in self.Nominal_D4x}
    +1122		self.unknowns = {s: self.samples[s] for s in self.samples if s not in self.Nominal_D4x}
     
    @@ -7891,32 +7886,32 @@

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

    -
    1319	@make_verbal
    -1320	def crunch(self, verbose = ''):
    -1321		'''
    -1322		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    -1323		'''
    -1324		for r in self:
    -1325			self.compute_bulk_and_clumping_deltas(r)
    -1326		self.standardize_d13C()
    -1327		self.standardize_d18O()
    -1328		self.msg(f"Crunched {len(self)} analyses.")
    +            
    1314	@make_verbal
    +1315	def crunch(self, verbose = ''):
    +1316		'''
    +1317		Compute bulk composition and raw clumped isotope anomalies for all analyses.
    +1318		'''
    +1319		for r in self:
    +1320			self.compute_bulk_and_clumping_deltas(r)
    +1321		self.standardize_d13C()
    +1322		self.standardize_d18O()
    +1323		self.msg(f"Crunched {len(self)} analyses.")
     
    @@ -8235,20 +8230,20 @@

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

    -
    1578	def report(self):
    -1579		'''
    -1580		Prints a report on the standardization fit.
    -1581		Only applicable after `D4xdata.standardize(method='pooled')`.
    -1582		'''
    -1583		report_fit(self.standardization)
    +            
    1573	def report(self):
    +1574		'''
    +1575		Prints a report on the standardization fit.
    +1576		Only applicable after `D4xdata.standardize(method='pooled')`.
    +1577		'''
    +1578		report_fit(self.standardization)
     
    @@ -8676,43 +8671,43 @@

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

    -
    2394	@make_verbal
    -2395	def repeatabilities(self):
    -2396		'''
    -2397		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    -2398		(for all samples, for anchors, and for unknowns).
    -2399		'''
    -2400		self.msg('Computing reproducibilities for all sessions')
    -2401
    -2402		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    -2403		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    -2404		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    -2405		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    -2406		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
    +            
    2389	@make_verbal
    +2390	def repeatabilities(self):
    +2391		'''
    +2392		Compute analytical repeatabilities for δ13C_VPDB, δ18O_VSMOW, Δ4x
    +2393		(for all samples, for anchors, and for unknowns).
    +2394		'''
    +2395		self.msg('Computing reproducibilities for all sessions')
    +2396
    +2397		self.repeatability['r_d13C_VPDB'] = self.compute_r('d13C_VPDB', samples = 'anchors')
    +2398		self.repeatability['r_d18O_VSMOW'] = self.compute_r('d18O_VSMOW', samples = 'anchors')
    +2399		self.repeatability[f'r_D{self._4x}a'] = self.compute_r(f'D{self._4x}', samples = 'anchors')
    +2400		self.repeatability[f'r_D{self._4x}u'] = self.compute_r(f'D{self._4x}', samples = 'unknowns')
    +2401		self.repeatability[f'r_D{self._4x}'] = self.compute_r(f'D{self._4x}', samples = 'all samples')
     
    @@ -9863,23 +9858,23 @@

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

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

    API Documentation

    -
    2894	def simulate(self, *args, **kwargs):
    -2895		'''
    -2896		Legacy function with warning message pointing to `virtual_data()`
    -2897		'''
    -2898		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
    +            
    2889	def simulate(self, *args, **kwargs):
    +2890		'''
    +2891		Legacy function with warning message pointing to `virtual_data()`
    +2892		'''
    +2893		raise DeprecationWarning('D4xdata.simulate is deprecated and has been replaced by virtual_data()')
     
    @@ -10556,81 +10551,81 @@

    API Documentation

    -
    2900	def plot_distribution_of_analyses(
    -2901		self,
    -2902		dir = 'output',
    -2903		filename = None,
    -2904		vs_time = False,
    -2905		figsize = (6,4),
    -2906		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
    -2907		output = None,
    -2908		):
    -2909		'''
    -2910		Plot temporal distribution of all analyses in the data set.
    -2911		
    -2912		**Parameters**
    -2913
    -2914		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    -2915		'''
    -2916
    -2917		asamples = [s for s in self.anchors]
    -2918		usamples = [s for s in self.unknowns]
    -2919		if output is None or output == 'fig':
    -2920			fig = ppl.figure(figsize = figsize)
    -2921			ppl.subplots_adjust(*subplots_adjust)
    -2922		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    -2923		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    -2924		Xmax += (Xmax-Xmin)/40
    -2925		Xmin -= (Xmax-Xmin)/41
    -2926		for k, s in enumerate(asamples + usamples):
    -2927			if vs_time:
    -2928				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    -2929			else:
    -2930				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    -2931			Y = [-k for x in X]
    -2932			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
    -2933			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    -2934			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
    -2935		ppl.axis([Xmin, Xmax, -k-1, 1])
    -2936		ppl.xlabel('\ntime')
    -2937		ppl.gca().annotate('',
    -2938			xy = (0.6, -0.02),
    -2939			xycoords = 'axes fraction',
    -2940			xytext = (.4, -0.02), 
    -2941            arrowprops = dict(arrowstyle = "->", color = 'k'),
    -2942            )
    -2943			
    -2944
    -2945		x2 = -1
    -2946		for session in self.sessions:
    -2947			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    -2948			if vs_time:
    -2949				ppl.axvline(x1, color = 'k', lw = .75)
    -2950			if x2 > -1:
    -2951				if not vs_time:
    -2952					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
    -2953			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    -2954# 			from xlrd import xldate_as_datetime
    -2955# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    -2956			if vs_time:
    -2957				ppl.axvline(x2, color = 'k', lw = .75)
    -2958				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
    -2959			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
    -2960
    -2961		ppl.xticks([])
    -2962		ppl.yticks([])
    -2963
    -2964		if output is None:
    -2965			if not os.path.exists(dir):
    -2966				os.makedirs(dir)
    -2967			if filename == None:
    -2968				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    -2969			ppl.savefig(f'{dir}/{filename}')
    -2970			ppl.close(fig)
    -2971		elif output == 'ax':
    -2972			return ppl.gca()
    -2973		elif output == 'fig':
    -2974			return fig
    +            
    2895	def plot_distribution_of_analyses(
    +2896		self,
    +2897		dir = 'output',
    +2898		filename = None,
    +2899		vs_time = False,
    +2900		figsize = (6,4),
    +2901		subplots_adjust = (0.02, 0.13, 0.85, 0.8),
    +2902		output = None,
    +2903		):
    +2904		'''
    +2905		Plot temporal distribution of all analyses in the data set.
    +2906		
    +2907		**Parameters**
    +2908
    +2909		+ `vs_time`: if `True`, plot as a function of `TimeTag` rather than sequentially.
    +2910		'''
    +2911
    +2912		asamples = [s for s in self.anchors]
    +2913		usamples = [s for s in self.unknowns]
    +2914		if output is None or output == 'fig':
    +2915			fig = ppl.figure(figsize = figsize)
    +2916			ppl.subplots_adjust(*subplots_adjust)
    +2917		Xmin = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    +2918		Xmax = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self)])
    +2919		Xmax += (Xmax-Xmin)/40
    +2920		Xmin -= (Xmax-Xmin)/41
    +2921		for k, s in enumerate(asamples + usamples):
    +2922			if vs_time:
    +2923				X = [r['TimeTag'] for r in self if r['Sample'] == s]
    +2924			else:
    +2925				X = [x for x,r in enumerate(self) if r['Sample'] == s]
    +2926			Y = [-k for x in X]
    +2927			ppl.plot(X, Y, 'o', mec = None, mew = 0, mfc = 'b' if s in usamples else 'r', ms = 3, alpha = .75)
    +2928			ppl.axhline(-k, color = 'b' if s in usamples else 'r', lw = .5, alpha = .25)
    +2929			ppl.text(Xmax, -k, f'   {s}', va = 'center', ha = 'left', size = 7, color = 'b' if s in usamples else 'r')
    +2930		ppl.axis([Xmin, Xmax, -k-1, 1])
    +2931		ppl.xlabel('\ntime')
    +2932		ppl.gca().annotate('',
    +2933			xy = (0.6, -0.02),
    +2934			xycoords = 'axes fraction',
    +2935			xytext = (.4, -0.02), 
    +2936            arrowprops = dict(arrowstyle = "->", color = 'k'),
    +2937            )
    +2938			
    +2939
    +2940		x2 = -1
    +2941		for session in self.sessions:
    +2942			x1 = min([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    +2943			if vs_time:
    +2944				ppl.axvline(x1, color = 'k', lw = .75)
    +2945			if x2 > -1:
    +2946				if not vs_time:
    +2947					ppl.axvline((x1+x2)/2, color = 'k', lw = .75, alpha = .5)
    +2948			x2 = max([r['TimeTag'] if vs_time else j for j,r in enumerate(self) if r['Session'] == session])
    +2949# 			from xlrd import xldate_as_datetime
    +2950# 			print(session, xldate_as_datetime(x1, 0), xldate_as_datetime(x2, 0))
    +2951			if vs_time:
    +2952				ppl.axvline(x2, color = 'k', lw = .75)
    +2953				ppl.axvspan(x1,x2,color = 'k', zorder = -100, alpha = .15)
    +2954			ppl.text((x1+x2)/2, 1, f' {session}', ha = 'left', va = 'bottom', rotation = 45, size = 8)
    +2955
    +2956		ppl.xticks([])
    +2957		ppl.yticks([])
    +2958
    +2959		if output is None:
    +2960			if not os.path.exists(dir):
    +2961				os.makedirs(dir)
    +2962			if filename == None:
    +2963				filename = f'D{self._4x}_distribution_of_analyses.pdf'
    +2964			ppl.savefig(f'{dir}/{filename}')
    +2965			ppl.close(fig)
    +2966		elif output == 'ax':
    +2967			return ppl.gca()
    +2968		elif output == 'fig':
    +2969			return fig
     
    @@ -10676,94 +10671,94 @@
    Inherited Members
    -
    2977class D47data(D4xdata):
    -2978	'''
    -2979	Store and process data for a large set of Δ47 analyses,
    -2980	usually comprising more than one analytical session.
    -2981	'''
    -2982
    -2983	Nominal_D4x = {
    -2984		'ETH-1':   0.2052,
    -2985		'ETH-2':   0.2085,
    -2986		'ETH-3':   0.6132,
    -2987		'ETH-4':   0.4511,
    -2988		'IAEA-C1': 0.3018,
    -2989		'IAEA-C2': 0.6409,
    -2990		'MERCK':   0.5135,
    -2991		} # I-CDES (Bernasconi et al., 2021)
    -2992	'''
    -2993	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
    -2994	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
    -2995	reference frame.
    -2996
    -2997	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
    -2998	```py
    -2999	{
    -3000		'ETH-1'   : 0.2052,
    -3001		'ETH-2'   : 0.2085,
    -3002		'ETH-3'   : 0.6132,
    -3003		'ETH-4'   : 0.4511,
    -3004		'IAEA-C1' : 0.3018,
    -3005		'IAEA-C2' : 0.6409,
    -3006		'MERCK'   : 0.5135,
    -3007	}
    -3008	```
    -3009	'''
    -3010
    +            
    2972class D47data(D4xdata):
    +2973	'''
    +2974	Store and process data for a large set of Δ47 analyses,
    +2975	usually comprising more than one analytical session.
    +2976	'''
    +2977
    +2978	Nominal_D4x = {
    +2979		'ETH-1':   0.2052,
    +2980		'ETH-2':   0.2085,
    +2981		'ETH-3':   0.6132,
    +2982		'ETH-4':   0.4511,
    +2983		'IAEA-C1': 0.3018,
    +2984		'IAEA-C2': 0.6409,
    +2985		'MERCK':   0.5135,
    +2986		} # I-CDES (Bernasconi et al., 2021)
    +2987	'''
    +2988	Nominal Δ47 values assigned to the Δ47 anchor samples, used by
    +2989	`D47data.standardize()` to normalize unknown samples to an absolute Δ47
    +2990	reference frame.
    +2991
    +2992	By default equal to (after [Bernasconi et al. (2021)](https://doi.org/10.1029/2020GC009588)):
    +2993	```py
    +2994	{
    +2995		'ETH-1'   : 0.2052,
    +2996		'ETH-2'   : 0.2085,
    +2997		'ETH-3'   : 0.6132,
    +2998		'ETH-4'   : 0.4511,
    +2999		'IAEA-C1' : 0.3018,
    +3000		'IAEA-C2' : 0.6409,
    +3001		'MERCK'   : 0.5135,
    +3002	}
    +3003	```
    +3004	'''
    +3005
    +3006
    +3007	@property
    +3008	def Nominal_D47(self):
    +3009		return self.Nominal_D4x
    +3010	
     3011
    -3012	@property
    -3013	def Nominal_D47(self):
    -3014		return self.Nominal_D4x
    -3015	
    +3012	@Nominal_D47.setter
    +3013	def Nominal_D47(self, new):
    +3014		self.Nominal_D4x = dict(**new)
    +3015		self.refresh()
     3016
    -3017	@Nominal_D47.setter
    -3018	def Nominal_D47(self, new):
    -3019		self.Nominal_D4x = dict(**new)
    -3020		self.refresh()
    -3021
    -3022
    -3023	def __init__(self, l = [], **kwargs):
    -3024		'''
    -3025		**Parameters:** same as `D4xdata.__init__()`
    -3026		'''
    -3027		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    -3028
    +3017
    +3018	def __init__(self, l = [], **kwargs):
    +3019		'''
    +3020		**Parameters:** same as `D4xdata.__init__()`
    +3021		'''
    +3022		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    +3023
    +3024
    +3025	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    +3026		'''
    +3027		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    +3028		value for that temperature, and add treat these samples as additional anchors.
     3029
    -3030	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    -3031		'''
    -3032		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    -3033		value for that temperature, and add treat these samples as additional anchors.
    -3034
    -3035		**Parameters**
    -3036
    -3037		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    -3038		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    -3039		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    -3040		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    -3041		if `new`: keep pre-existing anchors but update them in case of conflict
    -3042		between old and new Δ47 values;
    -3043		if `old`: keep pre-existing anchors but preserve their original Δ47
    -3044		values in case of conflict.
    -3045		'''
    -3046		f = {
    -3047			'petersen': fCO2eqD47_Petersen,
    -3048			'wang': fCO2eqD47_Wang,
    -3049			}[fCo2eqD47]
    -3050		foo = {}
    -3051		for r in self:
    -3052			if 'Teq' in r:
    -3053				if r['Sample'] in foo:
    -3054					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    -3055				else:
    -3056					foo[r['Sample']] = f(r['Teq'])
    -3057			else:
    -3058					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    -3059
    -3060		if priority == 'replace':
    -3061			self.Nominal_D47 = {}
    -3062		for s in foo:
    -3063			if priority != 'old' or s not in self.Nominal_D47:
    -3064				self.Nominal_D47[s] = foo[s]
    +3030		**Parameters**
    +3031
    +3032		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    +3033		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    +3034		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    +3035		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    +3036		if `new`: keep pre-existing anchors but update them in case of conflict
    +3037		between old and new Δ47 values;
    +3038		if `old`: keep pre-existing anchors but preserve their original Δ47
    +3039		values in case of conflict.
    +3040		'''
    +3041		f = {
    +3042			'petersen': fCO2eqD47_Petersen,
    +3043			'wang': fCO2eqD47_Wang,
    +3044			}[fCo2eqD47]
    +3045		foo = {}
    +3046		for r in self:
    +3047			if 'Teq' in r:
    +3048				if r['Sample'] in foo:
    +3049					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    +3050				else:
    +3051					foo[r['Sample']] = f(r['Teq'])
    +3052			else:
    +3053					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    +3054
    +3055		if priority == 'replace':
    +3056			self.Nominal_D47 = {}
    +3057		for s in foo:
    +3058			if priority != 'old' or s not in self.Nominal_D47:
    +3059				self.Nominal_D47[s] = foo[s]
     
    @@ -10782,11 +10777,11 @@
    Inherited Members
    -
    3023	def __init__(self, l = [], **kwargs):
    -3024		'''
    -3025		**Parameters:** same as `D4xdata.__init__()`
    -3026		'''
    -3027		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
    +            
    3018	def __init__(self, l = [], **kwargs):
    +3019		'''
    +3020		**Parameters:** same as `D4xdata.__init__()`
    +3021		'''
    +3022		D4xdata.__init__(self, l = l, mass = '47', **kwargs)
     
    @@ -10838,41 +10833,41 @@
    Inherited Members
    -
    3030	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    -3031		'''
    -3032		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    -3033		value for that temperature, and add treat these samples as additional anchors.
    -3034
    -3035		**Parameters**
    -3036
    -3037		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    -3038		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    -3039		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    -3040		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    -3041		if `new`: keep pre-existing anchors but update them in case of conflict
    -3042		between old and new Δ47 values;
    -3043		if `old`: keep pre-existing anchors but preserve their original Δ47
    -3044		values in case of conflict.
    -3045		'''
    -3046		f = {
    -3047			'petersen': fCO2eqD47_Petersen,
    -3048			'wang': fCO2eqD47_Wang,
    -3049			}[fCo2eqD47]
    -3050		foo = {}
    -3051		for r in self:
    -3052			if 'Teq' in r:
    -3053				if r['Sample'] in foo:
    -3054					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    -3055				else:
    -3056					foo[r['Sample']] = f(r['Teq'])
    -3057			else:
    -3058					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    -3059
    -3060		if priority == 'replace':
    -3061			self.Nominal_D47 = {}
    -3062		for s in foo:
    -3063			if priority != 'old' or s not in self.Nominal_D47:
    -3064				self.Nominal_D47[s] = foo[s]
    +            
    3025	def D47fromTeq(self, fCo2eqD47 = 'petersen', priority = 'new'):
    +3026		'''
    +3027		Find all samples for which `Teq` is specified, compute equilibrium Δ47
    +3028		value for that temperature, and add treat these samples as additional anchors.
    +3029
    +3030		**Parameters**
    +3031
    +3032		+ `fCo2eqD47`: Which CO2 equilibrium law to use
    +3033		(`petersen`: [Petersen et al. (2019)](https://doi.org/10.1029/2018GC008127);
    +3034		`wang`: [Wang et al. (2019)](https://doi.org/10.1016/j.gca.2004.05.039)).
    +3035		+ `priority`: if `replace`: forget old anchors and only use the new ones;
    +3036		if `new`: keep pre-existing anchors but update them in case of conflict
    +3037		between old and new Δ47 values;
    +3038		if `old`: keep pre-existing anchors but preserve their original Δ47
    +3039		values in case of conflict.
    +3040		'''
    +3041		f = {
    +3042			'petersen': fCO2eqD47_Petersen,
    +3043			'wang': fCO2eqD47_Wang,
    +3044			}[fCo2eqD47]
    +3045		foo = {}
    +3046		for r in self:
    +3047			if 'Teq' in r:
    +3048				if r['Sample'] in foo:
    +3049					assert foo[r['Sample']] == f(r['Teq']), f'Different values of `Teq` provided for sample `{r["Sample"]}`.'
    +3050				else:
    +3051					foo[r['Sample']] = f(r['Teq'])
    +3052			else:
    +3053					assert r['Sample'] not in foo, f'`Teq` is inconsistently specified for sample `{r["Sample"]}`.'
    +3054
    +3055		if priority == 'replace':
    +3056			self.Nominal_D47 = {}
    +3057		for s in foo:
    +3058			if priority != 'old' or s not in self.Nominal_D47:
    +3059				self.Nominal_D47[s] = foo[s]
     
    @@ -10984,55 +10979,55 @@
    Inherited Members
    -
    3069class D48data(D4xdata):
    -3070	'''
    -3071	Store and process data for a large set of Δ48 analyses,
    -3072	usually comprising more than one analytical session.
    -3073	'''
    -3074
    -3075	Nominal_D4x = {
    -3076		'ETH-1':  0.138,
    -3077		'ETH-2':  0.138,
    -3078		'ETH-3':  0.270,
    -3079		'ETH-4':  0.223,
    -3080		'GU-1':  -0.419,
    -3081		} # (Fiebig et al., 2019, 2021)
    -3082	'''
    -3083	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
    -3084	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
    -3085	reference frame.
    -3086
    -3087	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
    -3088	Fiebig et al. (in press)):
    -3089
    -3090	```py
    -3091	{
    -3092		'ETH-1' :  0.138,
    -3093		'ETH-2' :  0.138,
    -3094		'ETH-3' :  0.270,
    -3095		'ETH-4' :  0.223,
    -3096		'GU-1'  : -0.419,
    -3097	}
    -3098	```
    -3099	'''
    +            
    3064class D48data(D4xdata):
    +3065	'''
    +3066	Store and process data for a large set of Δ48 analyses,
    +3067	usually comprising more than one analytical session.
    +3068	'''
    +3069
    +3070	Nominal_D4x = {
    +3071		'ETH-1':  0.138,
    +3072		'ETH-2':  0.138,
    +3073		'ETH-3':  0.270,
    +3074		'ETH-4':  0.223,
    +3075		'GU-1':  -0.419,
    +3076		} # (Fiebig et al., 2019, 2021)
    +3077	'''
    +3078	Nominal Δ48 values assigned to the Δ48 anchor samples, used by
    +3079	`D48data.standardize()` to normalize unknown samples to an absolute Δ48
    +3080	reference frame.
    +3081
    +3082	By default equal to (after [Fiebig et al. (2019)](https://doi.org/10.1016/j.chemgeo.2019.05.019),
    +3083	Fiebig et al. (in press)):
    +3084
    +3085	```py
    +3086	{
    +3087		'ETH-1' :  0.138,
    +3088		'ETH-2' :  0.138,
    +3089		'ETH-3' :  0.270,
    +3090		'ETH-4' :  0.223,
    +3091		'GU-1'  : -0.419,
    +3092	}
    +3093	```
    +3094	'''
    +3095
    +3096
    +3097	@property
    +3098	def Nominal_D48(self):
    +3099		return self.Nominal_D4x
     3100
    -3101
    -3102	@property
    -3103	def Nominal_D48(self):
    -3104		return self.Nominal_D4x
    -3105
    -3106	
    -3107	@Nominal_D48.setter
    -3108	def Nominal_D48(self, new):
    -3109		self.Nominal_D4x = dict(**new)
    -3110		self.refresh()
    -3111
    -3112
    -3113	def __init__(self, l = [], **kwargs):
    -3114		'''
    -3115		**Parameters:** same as `D4xdata.__init__()`
    -3116		'''
    -3117		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
    +3101	
    +3102	@Nominal_D48.setter
    +3103	def Nominal_D48(self, new):
    +3104		self.Nominal_D4x = dict(**new)
    +3105		self.refresh()
    +3106
    +3107
    +3108	def __init__(self, l = [], **kwargs):
    +3109		'''
    +3110		**Parameters:** same as `D4xdata.__init__()`
    +3111		'''
    +3112		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
     
    @@ -11051,11 +11046,11 @@
    Inherited Members
    -
    3113	def __init__(self, l = [], **kwargs):
    -3114		'''
    -3115		**Parameters:** same as `D4xdata.__init__()`
    -3116		'''
    -3117		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
    +            
    3108	def __init__(self, l = [], **kwargs):
    +3109		'''
    +3110		**Parameters:** same as `D4xdata.__init__()`
    +3111		'''
    +3112		D4xdata.__init__(self, l = l, mass = '48', **kwargs)
     
    From 4f4cc51d6ac73eb2cb79e01581c8a2b8197134e2 Mon Sep 17 00:00:00 2001 From: mdaeron Date: Sat, 13 May 2023 13:29:27 +0200 Subject: [PATCH 16/16] Prepare v2.0.6 release --- D47crunch/__init__.py | 4 ++-- changelog.md | 6 ++++++ docs/index.html | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/D47crunch/__init__.py b/D47crunch/__init__.py index 86a2b11..03651fb 100755 --- a/D47crunch/__init__.py +++ b/D47crunch/__init__.py @@ -20,8 +20,8 @@ __contact__ = 'daeron@lsce.ipsl.fr' __copyright__ = 'Copyright (c) 2023 Mathieu Daëron' __license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause' -__date__ = '2023-05-11' -__version__ = '2.0.6.dev0' +__date__ = '2023-05-13' +__version__ = '2.0.6' import os import numpy as np diff --git a/changelog.md b/changelog.md index 9600c7c..a2f209d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,11 @@ # Changelog +## v2.0.6 +*Released on 2023-05-13* + +### Bugfix +* Eliminate some spurious debugging messages in `_fullcovar()` + ## v2.0.5 *Released on 2023-05-11* diff --git a/docs/index.html b/docs/index.html index 1026265..32a6933 100644 --- a/docs/index.html +++ b/docs/index.html @@ -818,8 +818,8 @@

    API Documentation

    20__contact__ = 'daeron@lsce.ipsl.fr' 21__copyright__ = 'Copyright (c) 2023 Mathieu Daëron' 22__license__ = 'Modified BSD License - https://opensource.org/licenses/BSD-3-Clause' - 23__date__ = '2023-05-11' - 24__version__ = '2.0.6.dev0' + 23__date__ = '2023-05-13' + 24__version__ = '2.0.6' 25 26import os 27import numpy as np