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