D95eq 1.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
D95eq/__init__.py ADDED
@@ -0,0 +1,1387 @@
1
+ """
2
+ Test for clumped isotope equilibrium and estimate carbonate formation temperatures from dual clumped isotope measurements
3
+
4
+ .. include:: ../../docpages/install.md
5
+ .. include:: ../../docpages/cli.md
6
+
7
+ * * *
8
+ """
9
+
10
+ from ._metadata import *
11
+ from ._tools import confidence_band
12
+
13
+ import sys
14
+ import numpy as _np
15
+ import ogls as _ogls
16
+ import uncertainties as _uc
17
+ import lmfit as _lmfit
18
+ import correldata as _cd
19
+ import typer as _typer
20
+
21
+ from uncertainties import unumpy as _unp
22
+ from matplotlib import pyplot as _ppl
23
+ from matplotlib.patches import Ellipse as _Ellipse
24
+ from matplotlib.patches import Polygon as _Polygon
25
+ from scipy.stats import chi2 as _chi2
26
+ from scipy.stats import norm as _norm
27
+ from scipy.linalg import eigh as _eigh
28
+ from scipy.linalg import cholesky as _cholesky
29
+ from scipy.optimize import fsolve as _fsolve
30
+ from numpy.typing import ArrayLike
31
+ from typing_extensions import Annotated as _Annotated
32
+ from typer import rich_utils as _rich_utils
33
+
34
+ from warnings import filterwarnings as _filterwarnings
35
+ _filterwarnings('ignore', category = FutureWarning, message = 'AffineScalarFunc')
36
+ _filterwarnings('ignore', category = RuntimeWarning, message = 'The iteration is not making good progress')
37
+
38
+
39
+ ### Mathematical functions ###
40
+
41
+
42
+ def ufloat_compatible_interp(
43
+ xi: (_cd.uarray | ArrayLike),
44
+ yi: (_cd.uarray | ArrayLike),
45
+ x: (float | _uc.UFloat | _cd.uarray | ArrayLike),
46
+ ):
47
+ """
48
+ Linear interpolation accepting UFloat values for all three input parameters.
49
+ Only handles one interpolated value. For interpolated arrays, use `uarray_compatible_interp()`
50
+
51
+ **Arguments**
52
+ * `xi`: x-values defining the interpolated function
53
+ * `yi`: y-values defining the interpolated function
54
+ * `x`: x-value of the interpolation point
55
+
56
+ Returns y-value of the interpolation point, either as a float or a UFloat.
57
+ """
58
+ xn = x.nominal_value if isinstance(x, _uc.UFloat) else float(x)
59
+ idx = _np.searchsorted(xi, xn)
60
+ idx = _np.clip(idx, 1, len(xi) - 1)
61
+
62
+ x0 = xi[idx-1]
63
+ x1 = xi[idx]
64
+ y0 = yi[idx-1]
65
+ y1 = yi[idx]
66
+
67
+ t = (x - x0) / (x1 - x0)
68
+ return y0 + t * (y1 - y0)
69
+
70
+
71
+ def uarray_compatible_interp(xi, yi):
72
+ """
73
+ Linear interpolation accepting UFloat values for all three input parameters.
74
+
75
+ **Arguments**
76
+ * `xi`: x-values defining the interpolated function
77
+ * `yi`: y-values defining the interpolated function
78
+
79
+ Returns an interpolation function which returns arrays or uarrays of y-values.
80
+ """
81
+ return _np.vectorize(
82
+ lambda x: ufloat_compatible_interp(xi, yi, x)
83
+ )
84
+
85
+
86
+ def transform_pdf_monotonic(f_inv, df_inv, mu_x, sigma_x, yi):
87
+ """
88
+ Compute probability distribution function of Y = f(X)
89
+ where X ~ Normal(mu_x, sigma_x) and f is monotonic,
90
+ based on the change-of-variables formula:
91
+
92
+ p[y=f(x)] = p[x=f_inv(y)] * d(f_inv)/dy
93
+
94
+ Additionally, if f_inv returns UFloats, the PDF is convolved with that local
95
+ source of uncertainty (assumed to be Gaussian) at each grid point.
96
+
97
+ As currently implemented, requires `yi` to be an equally spaced array-like.
98
+
99
+ **Arguments**
100
+ f_inv: inverse of f, may return UFloats
101
+ df_inv: derivative of f_inv, should return UFloats if f_inv does
102
+ mu_x: mean of X PDF
103
+ sigma_x: std dev of X PDF
104
+ yi: regularly spaced grid of y values at which to evaluate the PDF
105
+
106
+ **Returns:**
107
+ pdf: normalized PDF evaluated at yi
108
+ """
109
+
110
+ if not _np.allclose(_np.diff(yi), yi[1] - yi[0]):
111
+ raise ValueError("yi must be regularly spaced")
112
+
113
+ xi = f_inv(yi) # may be floats or ufloats, depending on f_inv
114
+
115
+ try:
116
+ xi_nom = xi.n
117
+ sigma_xi = xi.s
118
+ has_ufloats = True
119
+ except AttributeError:
120
+ xi_nom = xi
121
+ has_ufloats = False
122
+
123
+ # Jacobian weights (account for irregular xi spacing)
124
+ try:
125
+ df_inv_nom = df_inv(yi).n
126
+ except AttributeError:
127
+ df_inv_nom = df_inv(yi)
128
+
129
+ w_i = _norm.pdf(xi_nom, loc = mu_x, scale = sigma_x) * _np.abs(df_inv_nom)
130
+
131
+ if not has_ufloats:
132
+ return w_i / (_np.trapezoid(w_i, yi))
133
+
134
+ # Propagate sigma from x-space to y-space via Jacobian: sigma_y = sigma_x / abs( dx/dy )
135
+ sigma_yi = sigma_xi / _np.abs(df_inv_nom)
136
+
137
+ # Convolution of Gaussians: each grid point j contributes N(yi; yj, σ_yj²) scaled by w_j
138
+ gaussians = _norm.pdf(
139
+ yi[:, None],
140
+ loc = yi[None, :],
141
+ scale = sigma_yi[None, :]
142
+ ) # NOTE: nice syntax to reshape ndarrays, perhaps use this in D4x_calib_function?
143
+
144
+ pdf = (gaussians * w_i[None, :]).sum(axis = 1)
145
+
146
+ return pdf / (_np.trapezoid(pdf, yi))
147
+
148
+
149
+ #### Calibration variables and functions ####
150
+
151
+
152
+ _D47_approx_calib_coefs = [0.159502986, 38588.1545] # computed from code in comments below
153
+ # from D47calib import OGLS23 as _OGLS23
154
+ # from D47calib import D47calib as _D47calib
155
+ #
156
+ # _D47_approx = _D47calib(
157
+ # samples = _OGLS23.samples,
158
+ # T = _OGLS23.T,
159
+ # sT = _OGLS23.sT,
160
+ # D47 = _OGLS23.D47,
161
+ # sD47 = _OGLS23.sD47,
162
+ # degrees = [0,2],
163
+ # )
164
+ # _D47_approx_calib_coefs = [_D47_approx.bfp['a0'], _D47_approx.bfp['a2']]
165
+
166
+
167
+ def _compute_D48_calib_coefficients(reprocess = False):
168
+ """
169
+ Based on Fiebig et al. (2021, 2024)
170
+ """
171
+
172
+ # D64 predictions
173
+ a1 = 6.002
174
+ a2 = -1.299e4
175
+ a3 = 8.996e6
176
+ a4 = -7.423e8
177
+
178
+ if reprocess:
179
+
180
+ # M. Bernecker, pers. comm.
181
+ # after Fiebig et al. (2024) 10.1016/j.chemgeo.2024.122382
182
+ datastr = '''
183
+ Sample, D48, SE_D48, T, SE_T, correl_T
184
+ LGB-2, 0.2606, 0.0103, 7.9, 0.2, 1., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.
185
+ DHC2-8, 0.2335, 0.0066, 33.7, 0.2, 0., 1., 1., 0., 0., 0., 0., 0., 0., 0., 0.
186
+ DVH-2, 0.2484, 0.0105, 33.7, 0.2, 0., 1., 1., 0., 0., 0., 0., 0., 0., 0., 0.
187
+ CA120, 0.1715, 0.0154, 120.0, 2., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0., 0.
188
+ CA170, 0.1621, 0.0142, 170.0, 2., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0., 0.
189
+ CA200, 0.1561, 0.0134, 200.0, 2., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0., 0.
190
+ CA250A, 0.1449, 0.0146, 250.0, 2., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0., 0.
191
+ CA250B, 0.1301, 0.0134, 250.0, 2., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0., 0.
192
+ CM351, 0.1220, 0.0073, 726.85, 10., 0., 0., 0., 0., 0., 0., 0., 0., 1., 0., 0.
193
+ ETH-1-1100, 0.1161, 0.0091, 1100.0, 10., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1., 0.
194
+ ETH-2-1100, 0.1225, 0.0070, 1100.0, 10., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 1.
195
+ '''[1:-2]
196
+
197
+ data = _cd.read_str(datastr)
198
+ T, D48 = data['T'], data['D48']
199
+
200
+
201
+ D64_predicted = (
202
+ a1 / (273.15 + T)
203
+ + a2 / (273.15 + T)**2
204
+ + a3 / (273.15 + T)**3
205
+ + a4 / (273.15 + T)**4
206
+ )
207
+
208
+ # affine regression of the form D48 = b0 + b1 * D64_theory
209
+ R = _ogls.Polynomial(
210
+ X = D64_predicted.n,
211
+ sX = D64_predicted.covar,
212
+ Y = D48.n,
213
+ sY = D48.covar,
214
+ degrees = [0,1],
215
+ )
216
+
217
+ R.regress(overdispersion_scaling = True)
218
+ b0, b1 = _uc.correlated_values(R.bfp.values(), R.bfp_CM)
219
+ # print(_cd.data_string(dict(affine_coefs = _cd.uarray([b0, b1]))))
220
+
221
+ else:
222
+
223
+ # M. Bernecker, pers. comm.
224
+ # after Fiebig et al. (2024) 10.1016/j.chemgeo.2024.122382
225
+ # Caution: because Fiebig et al. ignored T uncertainties, these
226
+ # coefficeients have smaller uncertainties than those computed above.
227
+ b0, b1 = _uc.correlated_values(
228
+ [
229
+ 0.12135157920099604,
230
+ 1.0379702801201238,
231
+ ], [
232
+ [ 7.39697438e-06, -6.90467053e-05],
233
+ [-6.90467053e-05, 1.46002771e-03],
234
+ ],
235
+ )
236
+
237
+ a0 = b0
238
+ a1 *= b1
239
+ a2 *= b1
240
+ a3 *= b1
241
+ a4 *= b1
242
+
243
+ return _cd.uarray([a0, a1, a2, a3, a4])
244
+
245
+
246
+ def D4x_calib_function(
247
+ T: (float | _uc.UFloat | _cd.uarray | ArrayLike),
248
+ coefs: _cd.uarray,
249
+ return_without_uncertainties: bool = False,
250
+ ignore_calib_uncertainties: bool = False,
251
+ ) -> (float | _uc.UFloat | _cd.uarray | ArrayLike):
252
+ """
253
+ **Arguments**
254
+ * `T`: temperature(s) for which to compute Δ<sub>4x</sub>
255
+ * `return_without_uncertainties`: if `True`, returns Δ<sub>4x</sub> values without error propagation of any kind
256
+ * `ignore_calib_uncertainties`: whether to propagate calibration uncertainties
257
+
258
+ Returns equilibrium Δ<sub>4x</sub> value(s) corresponding to `T` value(s)
259
+ """
260
+ degs = _np.arange(coefs.size)
261
+
262
+ D4x = (
263
+ _np.expand_dims(_cd.nv(coefs) if ignore_calib_uncertainties else coefs, 1) # shape = (coefs.size, 1)
264
+ * _np.expand_dims((T+273.15)**-1, 0) # shape = (1, T.size)
265
+ ** _np.expand_dims(degs, 1) # shape = (coefs.size, 1)
266
+ ).sum(axis = 0 if isinstance(T, _np.ndarray) else None)
267
+
268
+ if D4x.ndim == 0:
269
+ return D4x.tolist().n if return_without_uncertainties else D4x.tolist()
270
+ return D4x.n if return_without_uncertainties else D4x
271
+
272
+
273
+ def D4x_calib_derivative(
274
+ T: (float | _uc.UFloat | _cd.uarray | ArrayLike),
275
+ coefs: _cd.uarray,
276
+ return_without_uncertainties: bool = False,
277
+ ignore_calib_uncertainties: bool = False,
278
+ ) -> (float | _uc.UFloat | _cd.uarray | ArrayLike):
279
+ """
280
+ **Arguments**
281
+ * `T`: temperature(s) for which to compute Δ<sub>4x</sub>
282
+ * `return_without_uncertainties`: if `True`, returns D4x values without error propagation of any kind.
283
+ * `ignore_calib_uncertainties`: whether to propagate calibration uncertainties.
284
+
285
+ Returns d(D4x)/dT corresponding to `T` value(s)
286
+ """
287
+ dcoefs = -_np.arange(len(coefs)) * coefs
288
+ dcoefs = _cd.uarray((dcoefs[0], *dcoefs))
289
+ return D4x_calib_function(
290
+ T,
291
+ dcoefs,
292
+ return_without_uncertainties = return_without_uncertainties,
293
+ ignore_calib_uncertainties = ignore_calib_uncertainties,
294
+ )
295
+
296
+
297
+ #### Plotting functions ####
298
+
299
+
300
+ def conf_ellipse(
301
+ X: (_cd.uarray | _np.ndarray | _uc.UFloat | float),
302
+ Y: (_cd.uarray | _np.ndarray | _uc.UFloat | float) = None,
303
+ p: float = 0.95,
304
+ CM: (_np.ndarray | None) = None,
305
+ Xse: (_np.ndarray | float | None) = None,
306
+ Yse: (_np.ndarray | float | None) = None,
307
+ ax: (_ppl.Axes | None) = None,
308
+ **kwargs,
309
+ ) -> tuple:
310
+ """
311
+ Plot the joint *p*-level confidence ellipses for the elements of (X, Y)
312
+
313
+ **Arguments**
314
+ * `X`: x values
315
+ * `Y`: y values
316
+ * `p`: confidence level
317
+ * `CM`: covariance matrix of (X, Y); not needed if X and Y are of type
318
+ [`uncertainties.UFloat`](https://pythonhosted.org/uncertainties/tech_guide.html).
319
+ or if (`Xse`, `Yse`) are specified.
320
+ * `Xse`, `Yse`: SE of X and Y; not needed if X and Y are of type
321
+ [`uncertainties.UFloat`](https://pythonhosted.org/uncertainties/tech_guide.html)
322
+ or if `CM` is specified.
323
+ * `ax`: which instance of `matplotlib.axes.Axes` to draw in; use current axes if `ax` = `None`.
324
+ * `kwargs`: passed to `matplotlib.patches.Ellipse()`
325
+
326
+ Returns a list of the `Ellipse` objects thus created.
327
+ """
328
+
329
+
330
+ r2 = _chi2.ppf(p, 2)
331
+ kwargs = dict(fc = 'None', ec = 'k', lw = 0.7) | kwargs
332
+
333
+ if ax is None:
334
+ ax = _ppl.gca()
335
+
336
+ out = []
337
+
338
+ for x, y in zip(
339
+ *_cd.as_pair_of_uarrays(X, Y, CM = CM, Xse = Xse, Yse = Yse)
340
+ ):
341
+ val, vec = _eigh(_uc.covariance_matrix((x, y)))
342
+ width, height = 2 * (val[:, None] * r2)**0.5
343
+ angle = _np.degrees(_np.arctan2(*vec[::-1, 0]))
344
+
345
+ out.append(
346
+ ax.add_patch(
347
+ _Ellipse(
348
+ xy = (x.n, y.n),
349
+ width = width.item(),
350
+ height = height.item(),
351
+ angle = angle,
352
+ **kwargs,
353
+ )
354
+ )
355
+ )
356
+
357
+ return (*out,)
358
+
359
+
360
+ ### D95eq Engine implementation ###
361
+
362
+ class _Interpolation():
363
+ pass
364
+
365
+ class Engine():
366
+ """
367
+ Underlying engine to compute and plot nearest equilibrium temperatures and projected
368
+ temperatures based on a consistent pair of Δ<sub>47</sub>, Δ<sub>48</sub> calibrations.
369
+ """
370
+
371
+ # D47_calib_coefs from OGLS23 (D47calib v1.3.1)
372
+ D47_calib_coefs = _cd.read_str('''
373
+ coefs, SE, correl,
374
+ 0.17437754366432887, 4.911105567257293e-3, 1. , -0.93797005, 0.8865771
375
+ -18.14215245127414, 5.632326472234856, -0.93797005, 1. , -0.98994249
376
+ 42.65722989162373e3, 1.27712751715908e3, 0.8865771 , -0.98994249, 1.
377
+ '''[1:-1])['coefs']
378
+ """
379
+ Default (OGLS23) Δ<sub>47</sub> calibration coefficients based on [Daëron & Vermeesch (2024)](https://doi.org/10.1016/j.chemgeo.2023.121881)
380
+ """
381
+
382
+ # D48_calib_coefs reprocessed from Fiebig et al. (2024):
383
+ #
384
+ # D48_calib_coefs = _compute_D48_calib_coefficients(reprocess = True)
385
+ # print(_cd.data_string(
386
+ # {'coefs': D48_calib_coefs},
387
+ # float_format = 'z.12g',
388
+ # correl_format = 'z.12f',
389
+ # ))
390
+
391
+ D48_calib_coefs = _cd.read_str('''
392
+ coefs, SE_coefs, correl_coefs, , , ,
393
+ 0.121349237888, 0.00390048540724, 1.000000000000, -0.664181963395, 0.664181963395, -0.664181963395, 0.664181963395
394
+ 6.22931985613, 0.32896761459, -0.664181963395, 1.000000000000, -1.000000000000, 1.000000000000, -1.000000000000
395
+ -13481.983494, 711.977559735, 0.664181963395, -1.000000000000, 1.000000000000, -1.000000000000, 1.000000000000
396
+ 9336714.66607, 493067.754224, -0.664181963395, 1.000000000000, -1.000000000000, 1.000000000000, -1.000000000000
397
+ -770413883.573, 40685214.9801, 0.664181963395, -1.000000000000, 1.000000000000, -1.000000000000, 1.000000000000
398
+ '''[1:-1])['coefs']
399
+ """
400
+ Default Δ<sub>48</sub> calibration coefficients based on [Fiebig et al. (2024)](https://doi.org/10.1016/j.chemgeo.2024.122382)
401
+ """
402
+
403
+ def __init__(
404
+ self,
405
+ D47_coefs: (_cd.uarray | ArrayLike | None) = None,
406
+ D48_coefs: (_cd.uarray | ArrayLike | None) = None,
407
+ Tmin_interp: float = -23.0,
408
+ Tmax_interp: float = 1277.0,
409
+ N_interp: float = 201,
410
+ ):
411
+ """
412
+ **Arguments**
413
+ * `D47_coefs`: `ndarray` or `uarray` of coefficients to use instead of default ones, ordered as (a0, a1, a2...)
414
+ * `D48_coefs`: `ndarray` or `uarray` of coefficients to use instead of default ones, ordered as (a0, a1, a2...)
415
+ * `Tmin_interp`: minimum temperature over which to interpolate for inverse function computations
416
+ * `Tmax_interp`: maximum temperature over which to interpolate for inverse function computations
417
+ * `N_interp`: number of points (equally-spaced in 1/T space) over which to interpolate for inverse function computations
418
+ """
419
+
420
+ self.D47_coefs = Engine.D47_calib_coefs if D47_coefs is None else D47_coefs
421
+ """The Δ<sub>47</sub> calibration coefficients used by this `Engine` instance"""
422
+
423
+ self.D48_coefs = Engine.D48_calib_coefs if D48_coefs is None else D48_coefs
424
+ """The Δ<sub>48</sub> calibration coefficients used by this `Engine` instance"""
425
+
426
+ self.interp = _Interpolation()
427
+ """
428
+ Holds equilibrium Δ<sub>47</sub> and Δ<sub>48</sub> values (ufloats) interpolated
429
+ along an array of T values (regularly spaced increments of 1/T<sup>2</sup>).
430
+
431
+ * `interp.T`: interpolation T values (floats) in regularly spaced increments of 1/T<sup>2</sup>
432
+ * `interp.D47`: Equilibrium Δ<sub>47</sub> values (ufloats) interpolated along `interp.T`
433
+ * `interp.D48`: Equilibrium Δ<sub>48</sub> values (ufloats) interpolated along `interp.T`
434
+ * `interp.D47_no_calib_errors`: Equilibrium Δ<sub>47</sub> values (ufloats) interpolated along `interp.T`,
435
+ ignoring calibration uncertainties
436
+ * `interp.D48_no_calib_errors`: Equilibrium Δ<sub>48</sub> values (ufloats) interpolated along `interp.T`,
437
+ ignoring calibration uncertainties
438
+ """
439
+
440
+ self.interp.T = _np.linspace(
441
+ (Tmax_interp+273.15)**-2,
442
+ (Tmin_interp+273.15)**-2,
443
+ N_interp,
444
+ )**-0.5 - 273.15
445
+
446
+ self.interp.D47 = self.D47_calib_function(
447
+ self.interp.T,
448
+ return_without_uncertainties = False,
449
+ ignore_calib_uncertainties = False,
450
+ )
451
+
452
+ self.interp.D47_no_calib_errors = self.D47_calib_function(
453
+ self.interp.T,
454
+ return_without_uncertainties = False,
455
+ ignore_calib_uncertainties = True,
456
+ )
457
+
458
+ self.interp.D48 = self.D48_calib_function(
459
+ self.interp.T,
460
+ return_without_uncertainties = False,
461
+ ignore_calib_uncertainties = False,
462
+ )
463
+
464
+ self.interp.D48_no_calib_errors = self.D48_calib_function(
465
+ self.interp.T,
466
+ return_without_uncertainties = False,
467
+ ignore_calib_uncertainties = True,
468
+ )
469
+
470
+ self.interp.D47u_as_function_of_D47n = uarray_compatible_interp(self.interp.D47.n, self.interp.D47)
471
+ self.interp.D48u_as_function_of_D47n = uarray_compatible_interp(self.interp.D47.n, self.interp.D48)
472
+
473
+ #inverse D47 calibration (ignoring calibration errors)
474
+ self.interp.Teq_as_function_of_D47n = uarray_compatible_interp(self.interp.D47.n, self.interp.T)
475
+ #inverse D47 calibration (including calibration errors)
476
+ self.interp.Teq_as_function_of_D47u = uarray_compatible_interp(self.interp.D47, self.interp.T)
477
+
478
+ def T_as_function_of_D47(
479
+ self,
480
+ D47: (_cd.uarray | ArrayLike),
481
+ ignore_calib_uncertainties: bool = False,
482
+ ):
483
+ """
484
+ Provided with one or more Δ<sub>47</sub> values (floats or ufloats), return ufloats for the
485
+ corresponding equilibrium T values (ufloats with or without Δ<sub>47</sub> calibration uncertainties).
486
+
487
+ **Arguments**
488
+ * `D47`: array of Δ<sub>47</sub> values
489
+ * `ignore_calib_uncertainties`: whether to propagate calibration uncertainties
490
+ """
491
+ if ignore_calib_uncertainties:
492
+ return _cd.uarray(self.interp.Teq_as_function_of_D47n(D47))
493
+ else:
494
+ return _cd.uarray(self.interp.Teq_as_function_of_D47u(D47))
495
+
496
+ def D47u_as_function_of_D47n(
497
+ self,
498
+ D47: ArrayLike
499
+ ):
500
+ """
501
+ Provided with one or more Δ<sub>47</sub> values (floats), return ufloats for the corresponding
502
+ equilibrium Δ<sub>47</sub> values (ufloats with Δ<sub>47</sub> calibration uncertainties).
503
+ """
504
+ return _cd.uarray(self.interp.D47u_as_function_of_D47n(D47))
505
+
506
+ def D48u_as_function_of_D47n(
507
+ self,
508
+ D47: ArrayLike
509
+ ):
510
+ """
511
+ Provided with one or more Δ<sub>47</sub> values (floats), return ufloats for the corresponding
512
+ equilibrium Δ<sub>48</sub> values (ufloats with Δ<sub>48</sub> calibration uncertainties).
513
+ """
514
+ return _cd.uarray(self.interp.D48u_as_function_of_D47n(D47))
515
+
516
+ def D47_calib_function(
517
+ self,
518
+ T: (float | _uc.UFloat | _cd.uarray),
519
+ return_without_uncertainties: bool = False,
520
+ ignore_calib_uncertainties: bool = False,
521
+ ):
522
+ return D4x_calib_function(
523
+ T = T,
524
+ coefs = self.D47_coefs,
525
+ return_without_uncertainties = return_without_uncertainties,
526
+ ignore_calib_uncertainties = ignore_calib_uncertainties,
527
+ )
528
+
529
+ def D48_calib_function(
530
+ self,
531
+ T: (float | _uc.UFloat | _cd.uarray),
532
+ return_without_uncertainties: bool = False,
533
+ ignore_calib_uncertainties: bool = False,
534
+ ):
535
+ return D4x_calib_function(
536
+ T = T,
537
+ coefs = self.D48_coefs,
538
+ return_without_uncertainties = return_without_uncertainties,
539
+ ignore_calib_uncertainties = ignore_calib_uncertainties,
540
+ )
541
+
542
+ D47_calib_function.__doc__ = D4x_calib_function.__doc__.replace('Δ<sub>4x</sub>', 'Δ<sub>47</sub>')
543
+ D48_calib_function.__doc__ = D4x_calib_function.__doc__.replace('Δ<sub>4x</sub>', 'Δ<sub>48</sub>')
544
+
545
+ def T_ellipse(
546
+ self,
547
+ T: (_np.ndarray | _cd.uarray),
548
+ p: float = 0.95,
549
+ CM: (_np.ndarray | None) = None,
550
+ Tse: (_np.ndarray | float | None) = None,
551
+ ax: (_ppl.Axes | None) = None,
552
+ **kwargs,
553
+ ) -> list:
554
+ """
555
+ Plot the joint `p`-level confidence ellipses in (Δ<sub>47</sub>, Δ<sub>48</sub>)
556
+ space, for temperatures equal to the elements of `T`, and return a list of the
557
+ `Ellipse` objects thus created.
558
+
559
+ **Arguments**
560
+ * `T`: `ndarray` or `uarray` of temperatures to plot
561
+ * `p`: confidence level
562
+ * `ax`: which instance of `matplotlib.axes.Axes` to draw in; use current axes if `ax` = `None`.
563
+ * `kwargs`: passed to `matplotlib.patches.Ellipse()`
564
+ """
565
+ _T = _cd.as_uarray(T, CM = CM, Xse = Tse)
566
+ return conf_ellipse(
567
+ self.D47_calib_function(_T),
568
+ self.D48_calib_function(_T),
569
+ p = p,
570
+ ax = ax,
571
+ **kwargs,
572
+ )
573
+
574
+ def plot_D95_confidence_band(
575
+ self,
576
+ p: float = 0.95,
577
+ Ti: (ArrayLike | None) = None,
578
+ ax: (_ppl.Axes | None) = None,
579
+ **kwargs,
580
+ ):
581
+ """
582
+ Plot, for a given p-value, the confidence band of the thermodynamic equilibrium curve
583
+ in (Δ<sub>47</sub>, Δ<sub>48</sub>) space.
584
+
585
+ **Arguments**
586
+ * `p`: confidence level
587
+ * `Ti`: array of temperatures over which to evaluate confidence band (default: use `interp.T` attribute instead)
588
+ * `ax`: `Axes` instance to plot to (default: use current Axes)
589
+ * `kwargs`: passed to `patches.Polygon()`
590
+
591
+ Returns the corresponding `Polygon` instance.
592
+ """
593
+ if ax is None:
594
+ ax = _ppl.gca()
595
+ if Ti is None:
596
+ Ti = self.interp.T
597
+ polygon = ax.add_patch(
598
+ _Polygon(
599
+ confidence_band(
600
+ Ti,
601
+ self.D47_calib_function,
602
+ self.D48_calib_function,
603
+ p,
604
+ ),
605
+ closed = True,
606
+ **kwargs,
607
+ )
608
+ )
609
+ return polygon
610
+
611
+
612
+ def plot_D95_equilibrium(
613
+ self,
614
+ Tmin: float = 0.,
615
+ Tmax: float = 1000.,
616
+ NT: int = 101,
617
+ Tmarkers: _np.typing.ArrayLike = [0, 25, 100, 250, 1000],
618
+ kwargs_Tmarkers: dict = {},
619
+ show_Tmarker_labels: bool = True,
620
+ kwargs_Tmarker_labels: dict = {},
621
+ show_Tmarker_ellipses: bool = False,
622
+ kwargs_Tmarker_ellipses: dict = {},
623
+ show_eqline: bool = True,
624
+ kwargs_eqline: dict = {},
625
+ show_confidence: bool = True,
626
+ confidence_pvalue: float = 0.95,
627
+ kwargs_confidence: dict = {},
628
+ ax: (_ppl.Axes | None) = None,
629
+ xlabel: str = '$Δ_{47}$ [‰]',
630
+ ylabel: str = '$Δ_{48}$ [‰]',
631
+ lw: float = 0.7,
632
+ ) -> (dict, dict):
633
+ """
634
+ Plot a thermodynamic equilibrium curve in (Δ<sub>47</sub>, Δ<sub>48</sub>) space
635
+ as a function of temperature.
636
+
637
+ **Arguments**
638
+ * `Tmin`: minimum T to plot
639
+ * `Tmax`: maximum T to plot
640
+ * `NT`: number of steps in equilibrium curve (interpolated at constant steps in 1/T<sup>2</sup> space)
641
+ * `Tmarkers`: T markers to add along the curve
642
+ * `kwargs_Tmarkers`: passed to `plot()` when plotting T markers
643
+ * `show_Tmarker_labels`: whether to add T labels to T markers
644
+ * `kwargs_Tmarker_labels`: passed to `text()` when plotting T markers
645
+ * `show_Tmarker_ellipses`: whether to add confidence ellipses to T markers
646
+ * `kwargs_Tmarker_ellipses`: passed to `T_ellipses()` when plotting T marker ellipses
647
+ * `show_eqline`: whether to plot the equilibrium curve itself
648
+ * `kwargs_eqline`: passed to `plot()` when plotting the equilibrium curve
649
+ * `show_confidence`: whether to plot the confidence band of the equilibrium curve
650
+ * `confidence_pvalue`: confidence level for the confidence band
651
+ * `kwargs_confidence`: passed to `plot_D95_confidence_band()` when plotting the confidence band
652
+ * `ax`: which instance of `matplotlib.axes.Axes` to draw in; use current axes if `ax` = `None`.
653
+ * `xlabel`: string to pass to `xlabel()`
654
+ * `ylabel`: string to pass to `ylabel()`
655
+ * `lw`: default line width for most plot elements
656
+
657
+ **Returns**
658
+ * `data`: a dict of the T, Δ<sub>47</sub> and Δ<sub>48</sub> values generated for this plot:
659
+ - `Te` : temperature interpolated along the equilibrium curve
660
+ - `D47e`: Δ<sub>47</sub> interpolated along the equilibrium curve
661
+ - `D48e`: Δ<sub>48</sub> interpolated along the equilibrium curve
662
+ - `Tm` : temperature of T markers
663
+ - `D47m`: Δ<sub>47</sub> of T markers
664
+ - `D48m`: Δ<sub>48</sub> of T markers
665
+
666
+ * `plot_elements`: a dict of the `Axes` elements generated for this plot:
667
+ - `eqline`: `Line2D` of the equilibrium curve
668
+ - `confidence`: `Polygon` object for the confidence band
669
+ - `Tm`: `Line2D` of the T markers
670
+ - `Tme`: list of `Ellipse` objects for the T marker ellipses
671
+ - `Tml`: list of `Text` objects for the T marker labels
672
+ """
673
+
674
+ default_kwargs_eqline = dict(
675
+ marker = 'None',
676
+ ls = '-',
677
+ color = 'k',
678
+ lw = lw,
679
+ )
680
+ default_kwargs_confidence = dict(
681
+ ec = (0,0,0,1),
682
+ fc = (0,0,0,0.15),
683
+ lw = 0.,
684
+ )
685
+ default_kwargs_Tmarkers = dict(
686
+ ls = 'None',
687
+ marker = 'o',
688
+ ms = 4,
689
+ mfc = 'w',
690
+ mec = 'k',
691
+ mew = lw,
692
+ )
693
+ default_kwargs_Tmarker_ellipses = dict(
694
+ fc = 'None',
695
+ ec = 'k',
696
+ lw = lw,
697
+ )
698
+ default_kwargs_Tmarker_labels = dict(
699
+ size = 8,
700
+ va = 'center',
701
+ ha = 'left',
702
+ linespacing = 3,
703
+ )
704
+
705
+ plot_elements = {}
706
+
707
+ Ti = _np.linspace(
708
+ (Tmin + 273.15)**-2,
709
+ (Tmax + 273.15)**-2,
710
+ NT
711
+ )**-0.5 - 273.15
712
+
713
+ Tmarkers = _np.array([_ for _ in Tmarkers if _ >= Ti.min() and _ <= Ti.max()])
714
+
715
+ if ax is None:
716
+ ax = _ppl.gca()
717
+ ax.set_xlabel(xlabel)
718
+ ax.set_ylabel(ylabel)
719
+
720
+ Xe = self.D47_calib_function(Ti)
721
+ Ye = self.D48_calib_function(Ti)
722
+
723
+ if show_eqline:
724
+ plot_elements['eqline'], = ax.plot(
725
+ _unp.nominal_values(Xe),
726
+ _unp.nominal_values(Ye),
727
+ **(default_kwargs_eqline | kwargs_eqline),
728
+ )
729
+
730
+ if show_confidence:
731
+ plot_elements['confidence'] = self.plot_D95_confidence_band(
732
+ p = confidence_pvalue,
733
+ ax = ax,
734
+ **(default_kwargs_confidence | kwargs_confidence),
735
+ )
736
+
737
+ Xm = self.D47_calib_function(Tmarkers)
738
+ Ym = self.D48_calib_function(Tmarkers)
739
+ if Tmarkers.size > 0:
740
+ plot_elements['Tm'] = ax.plot(
741
+ _unp.nominal_values(Xm),
742
+ _unp.nominal_values(Ym),
743
+ **(default_kwargs_Tmarkers | kwargs_Tmarkers),
744
+ )
745
+ if show_Tmarker_ellipses:
746
+ plot_elements['Tme'] = conf_ellipse(
747
+ Xm,
748
+ Ym,
749
+ ax = ax,
750
+ **(default_kwargs_Tmarker_ellipses | kwargs_Tmarker_ellipses),
751
+ )
752
+ if show_Tmarker_labels:
753
+ plot_elements['Tml'] = []
754
+ for x,y,t in zip(Xm, Ym, Tmarkers):
755
+ plot_elements['Tml'].append(
756
+ ax.text(
757
+ x.n,
758
+ y.n,
759
+ f'\n${t:.0f}\\,$°C',
760
+ **(default_kwargs_Tmarker_labels | kwargs_Tmarker_labels),
761
+ )
762
+ )
763
+
764
+ ax.autoscale_view()
765
+
766
+ data = dict(
767
+ Te = Ti,
768
+ D47e = Xe,
769
+ D48e = Ye,
770
+ Tm = Tmarkers,
771
+ D47m = Xm,
772
+ D48m = Ym,
773
+ )
774
+
775
+ return data, plot_elements
776
+
777
+ def _compute_p_and_D48eq_from_D47eq(
778
+ self,
779
+ D47,
780
+ D48,
781
+ D47eq,
782
+ ignore_calib_uncertainties = False,
783
+ ):
784
+ """
785
+ Used by the various `Engine.nearest_D47eq()` methods
786
+ """
787
+ N = D47.size
788
+
789
+ # Compute fit residuals for p values
790
+ if ignore_calib_uncertainties:
791
+ R = _cd.uarray(_np.concatenate((
792
+ D47 - self.D47u_as_function_of_D47n(D47eq.n).n,
793
+ D48 - self.D48u_as_function_of_D47n(D47eq.n).n,
794
+ )))
795
+ else:
796
+ R = _cd.uarray(_np.concatenate((
797
+ D47 - self.D47u_as_function_of_D47n(D47eq.n),
798
+ D48 - self.D48u_as_function_of_D47n(D47eq.n),
799
+ )))
800
+
801
+ # Compute p values
802
+ p = _np.zeros((N,))
803
+ for k in range(N):
804
+ r = R[k::N]
805
+ z2 = r.m
806
+ p[k] = 1-_chi2.cdf(z2, 1)
807
+
808
+ # Compute D48eq
809
+ D48eq = self.D48u_as_function_of_D47n(D47eq)
810
+
811
+ return p, D48eq
812
+
813
+ def nearest_D47eq(
814
+ self,
815
+ D47: _cd.uarray,
816
+ D48: _cd.uarray,
817
+ ignore_calib_uncertainties: bool = False,
818
+ ):
819
+ """
820
+ Computes a `correldata.uarray` of *equilibrium* Δ<sub>47</sub> values, each of which is
821
+ the closest (in the OGLS sense) to one (Δ<sub>47</sub>, Δ<sub>48</sub>) observation
822
+ considered independently of the others.
823
+
824
+ Also returns an array of corresponding p-values taking into account errors in Δ<sub>47</sub>
825
+ and Δ<sub>48</sub> (and any covariance between the two) as well as errors in the
826
+ Δ<sub>47</sub> and Δ<sub>48</sub> calibrations.
827
+
828
+ > [!NOTE]
829
+ > This is both the fastest and the strongly recommended version of this calculation.
830
+ > It is expected to yield an `uarray` with reasonably accurate covariance between the
831
+ > `D47eq` values, but also between `D47eq` and all other variables.
832
+ """
833
+
834
+ N = D47.size
835
+ N47 = self.D47_coefs.size
836
+ N48 = self.D48_coefs.size
837
+ D47eq = D47 * 0
838
+
839
+ # _np.set_printoptions(threshold = _np.inf)
840
+ # _np.set_printoptions(linewidth = _np.inf)
841
+
842
+ for i in range(N):
843
+ def fun(*args): # args = (D47, D48, *D47_calib_coefs, *D48_calib_coefs)
844
+
845
+ args = _np.array(args)
846
+ D47_n = args[0]
847
+ D48_n = args[1]
848
+ D47_calib_coefs_n = args[-N48-N47:-N48]
849
+ D48_calib_coefs_n = args[-N48:]
850
+
851
+ params = _lmfit.Parameters()
852
+ params.add('D47eq', value = D47_n)
853
+
854
+ D47_u = _cd.uarray([_uc.ufloat(D47_n, D47.s[i])])
855
+ D48_u = _cd.uarray([_uc.ufloat(D48_n, D48.s[i])])
856
+ D47_calib_coefs_u = _cd.uarray(_uc.correlated_values(D47_calib_coefs_n, self.D47_coefs.covar))
857
+ D48_calib_coefs_u = _cd.uarray(_uc.correlated_values(D48_calib_coefs_n, self.D48_coefs.covar))
858
+
859
+ D47i = D4x_calib_function(
860
+ self.interp.T,
861
+ D47_calib_coefs_u,
862
+ return_without_uncertainties = False,
863
+ ignore_calib_uncertainties = ignore_calib_uncertainties,
864
+ )
865
+
866
+ D48i = D4x_calib_function(
867
+ self.interp.T,
868
+ D48_calib_coefs_u,
869
+ return_without_uncertainties = False,
870
+ ignore_calib_uncertainties = ignore_calib_uncertainties,
871
+ )
872
+
873
+ D47_interp = uarray_compatible_interp(D47i.n, D47i)
874
+ D48_interp = uarray_compatible_interp(D47i.n, D48i)
875
+
876
+ def cost_fun(p):
877
+ R = _cd.uarray(_np.concatenate((
878
+ D47_u - D47_interp(p['D47eq'].value),
879
+ D48_u - D48_interp(p['D47eq'].value),
880
+ )))
881
+
882
+ invS = _np.linalg.inv(R.covar)
883
+ L = _cholesky(invS)
884
+
885
+ return L @ R.n
886
+
887
+ minresult = _lmfit.minimize(
888
+ cost_fun,
889
+ params,
890
+ method = 'least_squares',
891
+ scale_covar = False,
892
+ jac = '3-point',
893
+ )
894
+ # slower but yields very similar results:
895
+ # minresult = _lmfit.minimize(cost_fun, params, method = 'powell', scale_covar = False)
896
+
897
+ return minresult.params['D47eq'].value
898
+
899
+ wrapped_fun = _uc.wrap(fun)
900
+ D47eq[i] = wrapped_fun(D47[i], D48[i], *self.D47_coefs, *self.D48_coefs)
901
+
902
+ p, D48eq = self._compute_p_and_D48eq_from_D47eq(D47, D48, D47eq, ignore_calib_uncertainties = ignore_calib_uncertainties)
903
+
904
+ return D47eq, D48eq, p
905
+
906
+ def joint_nearest_D47eq(
907
+ self,
908
+ D47: _cd.uarray,
909
+ D48: _cd.uarray,
910
+ ignore_calib_uncertainties: bool = False,
911
+ ):
912
+ """
913
+ Returns a `correldata.uarray` of equilibrium Δ<sub>47</sub> values which are *jointly* closest (in the OGLS sense)
914
+ to a sequence of (Δ<sub>47</sub>, Δ<sub>48</sub>) pairs. Also returns an array of
915
+ corresponding p-values taking into account errors in Δ<sub>47</sub> and Δ<sub>48</sub>
916
+ (and any covariance between the two) as well as errors in the Δ<sub>47</sub> and
917
+ Δ<sub>48</sub> calibrations.
918
+
919
+ > [!CAUTION]
920
+ > Caution: the use of this function is **not generally recommended** except for
921
+ > experimentation purposes, because it is conceptually and numerically risky to *jointly*
922
+ > fit the sequence of `Teq` values, as opposed to fitting each of them individually,
923
+ > as done by the recommended function `nearest_D47eq()`.
924
+
925
+ This is the most complete but slowest and not recommended version of this calculation.
926
+ It is expected to yield an `uarray` with reasonably accurate covariance between the
927
+ `D47eq` values, but also between `D47eq` and all other variables.
928
+
929
+ A faster but incomplete and potentially less accurate version of this calculation is
930
+ provided by `lazy_joint_nearest_D47eq()`.
931
+ """
932
+
933
+ N = D47.size
934
+ N47 = self.D47_coefs.size
935
+ N48 = self.D48_coefs.size
936
+
937
+ def fun(j, *args):
938
+
939
+ args = _np.array(args)
940
+ D47_n = args[:N]
941
+ D48_n = args[N:2*N]
942
+ D47_calib_coefs_n = args[-N48-N47:-N48]
943
+ D48_calib_coefs_n = args[-N48:]
944
+
945
+ params = _lmfit.Parameters()
946
+ for k in range(N):
947
+ params.add(f'D47eq{k}', value = D47_n[k])
948
+
949
+ D47_u = _cd.uarray(_uc.correlated_values(D47_n, D47.covar))
950
+ D48_u = _cd.uarray(_uc.correlated_values(D48_n, D48.covar))
951
+ D47_calib_coefs_u = _cd.uarray(_uc.correlated_values(D47_calib_coefs_n, self.D47_coefs.covar))
952
+ D48_calib_coefs_u = _cd.uarray(_uc.correlated_values(D48_calib_coefs_n, self.D48_coefs.covar))
953
+
954
+ D47i = D4x_calib_function(
955
+ self.interp.T,
956
+ D47_calib_coefs_u,
957
+ return_without_uncertainties = False,
958
+ ignore_calib_uncertainties = ignore_calib_uncertainties,
959
+ )
960
+
961
+ D48i = D4x_calib_function(
962
+ self.interp.T,
963
+ D48_calib_coefs_u,
964
+ return_without_uncertainties = False,
965
+ ignore_calib_uncertainties = ignore_calib_uncertainties,
966
+ )
967
+
968
+ D47_interp = uarray_compatible_interp(D47i.n, D47i)
969
+ D48_interp = uarray_compatible_interp(D47i.n, D48i)
970
+
971
+ def cost_fun(p):
972
+ _D47eq = _np.array([p[f'D47eq{k}'] for k in range(N)])
973
+ R = _cd.uarray(_np.concatenate((
974
+ D47_u - D47_interp(_D47eq),
975
+ D48_u - D48_interp(_D47eq),
976
+ )))
977
+
978
+ invS = _np.linalg.inv(R.covar)
979
+ L = _cholesky(invS)
980
+
981
+ # print(((L @ R.n)**2).sum())
982
+ return L @ R.n
983
+
984
+ minresult = _lmfit.minimize(
985
+ cost_fun,
986
+ params,
987
+ method = 'least_squares',
988
+ scale_covar = False,
989
+ jac = '3-point',
990
+ )
991
+ # slower but yields very similar results:
992
+ # minresult = _lmfit.minimize(cost_fun, params, method = 'powell', scale_covar = False)
993
+
994
+ return minresult.params[f'D47eq{j}'].value
995
+
996
+ wrapped_fun = _uc.wrap(fun)
997
+
998
+ D47eq = _cd.uarray([wrapped_fun(j, *D47, *D48, *self.D47_coefs, *self.D48_coefs) for j in range(N)])
999
+ p, D48eq = self._compute_p_and_D48eq_from_D47eq(D47, D48, D47eq, ignore_calib_uncertainties = ignore_calib_uncertainties)
1000
+
1001
+ return D47eq, D48eq, p
1002
+
1003
+ def lazy_joint_nearest_D47eq(
1004
+ self,
1005
+ D47: _cd.uarray,
1006
+ D48: _cd.uarray,
1007
+ ignore_calib_uncertainties: bool = False,
1008
+ ):
1009
+ """
1010
+ Returns a `correldata.uarray` of equilibrium Δ<sub>47</sub> values which are *jointly* closest (in the OGLS sense)
1011
+ to a sequence of (Δ<sub>47</sub>, Δ<sub>48</sub>) pairs. Also returns an array of
1012
+ corresponding p-values taking into account errors in Δ<sub>47</sub> and Δ<sub>48</sub>
1013
+ (and any covariance between the two) as well as errors in the Δ<sub>47</sub> and
1014
+ Δ<sub>48</sub> calibrations.
1015
+
1016
+ > [!CAUTION]
1017
+ > Caution: the use of this function is **not generally recommended** except for
1018
+ > experimentation purposes, because it is conceptually and numerically risky to *jointly*
1019
+ > fit the sequence of `Teq` values, as opposed to fitting each of them individually,
1020
+ > as done by the recommended function `nearest_D47eq()`.
1021
+
1022
+ This is a faster but incomplete version of this calculation. It is expected to yield an
1023
+ `uarray` with roughly accurate covariance between the `Teq` values, but without computing
1024
+ the covariance with any other variables.
1025
+
1026
+ A slower but complete and more accurate version of this calculation is provided by
1027
+ `joint_nearest_D47eq()`.
1028
+ """
1029
+
1030
+ N = D47.size
1031
+
1032
+ params = _lmfit.Parameters()
1033
+ for k in range(N):
1034
+ params.add(f'D47eq{k}', value = D47[k].n)
1035
+
1036
+ def cost_fun(p, ignore_calib_uncertainties = ignore_calib_uncertainties):
1037
+ _D47eq = _np.array([p[f'D47eq{k}'] for k in range(N)])
1038
+
1039
+ if ignore_calib_uncertainties:
1040
+ R = _cd.uarray(_np.concatenate((
1041
+ D47 - self.D47u_as_function_of_D47n(_D47eq).n,
1042
+ D48 - self.D48u_as_function_of_D47n(_D47eq).n,
1043
+ )))
1044
+ else:
1045
+ R = _cd.uarray(_np.concatenate((
1046
+ D47 - self.D47u_as_function_of_D47n(_D47eq),
1047
+ D48 - self.D48u_as_function_of_D47n(_D47eq),
1048
+ )))
1049
+
1050
+ invS = _np.linalg.inv(R.covar)
1051
+ L = _cholesky(invS)
1052
+
1053
+ # print(((L @ R.n)**2).sum())
1054
+ return L @ R.n
1055
+
1056
+ minresult = _lmfit.minimize(
1057
+ cost_fun,
1058
+ params,
1059
+ method = 'least_squares',
1060
+ scale_covar = False,
1061
+ jac = '3-point',
1062
+ )
1063
+
1064
+ D47eq = _cd.uarray([minresult.uvars[f'D47eq{k}'] for k in range(N)])
1065
+
1066
+ p, D48eq = self._compute_p_and_D48eq_from_D47eq(D47, D48, D47eq, ignore_calib_uncertainties = ignore_calib_uncertainties)
1067
+
1068
+ return D47eq, D48eq, p
1069
+
1070
+ def projected_D47eq(
1071
+ self,
1072
+ D47: _cd.uarray,
1073
+ D48: _cd.uarray,
1074
+ kinetic_slope: (float | _uc.UFloat),
1075
+ ):
1076
+ """
1077
+ Projects one or more (Δ<sub>47</sub>, Δ<sub>48</sub>) observations onto the equlibrium curve
1078
+ following a kinetic fractionation vector with a given slope (∂Δ<sub>48</sub>/∂Δ<sub>47</sub>).
1079
+
1080
+ **Arguments**
1081
+ * `D47`: observed Δ<sub>47</sub> value(s)
1082
+ * `D48`: observed Δ<sub>48</sub> value(s)
1083
+ * `kinetic_slope`: kinetic fractionation slopw, with or without uncertainty
1084
+
1085
+ Returns a tuple of uarrays corresponding to the projected Δ<sub>47</sub> and Δ<sub>48</sub> values.
1086
+
1087
+ > [!NOTE]
1088
+ > This is not a least-squares minimization problem but a direct calculation, and should thus
1089
+ > be much faster than the various `CorelData.nearestD47eq()` methods.
1090
+ """
1091
+
1092
+ D47 = _cd.uarray(D47)
1093
+ D48 = _cd.uarray(D48)
1094
+ N = D47.size
1095
+ N47c = self.D47_coefs.size
1096
+ N48c = self.D48_coefs.size
1097
+ D47p = D47 * 0
1098
+
1099
+ for i in range(N):
1100
+
1101
+ # function to solve
1102
+ def fun(x, *args): # args = (D47, D48, kinetic_slope, *self.D47_coefs, *self.D48_coefs)
1103
+
1104
+ args = _np.array(args)
1105
+ D47_n = args[0]
1106
+ D48_n = args[1]
1107
+ kslope_n = args[2]
1108
+ D47_calib_coefs_n = args[-N48c-N47c:-N48c]
1109
+ D48_calib_coefs_n = args[-N48c:]
1110
+
1111
+ D47i = D4x_calib_function(
1112
+ self.interp.T,
1113
+ D47_calib_coefs_n,
1114
+ return_without_uncertainties = False,
1115
+ )
1116
+
1117
+ D48i = D4x_calib_function(
1118
+ self.interp.T,
1119
+ D48_calib_coefs_n,
1120
+ return_without_uncertainties = False,
1121
+ )
1122
+
1123
+ D48_interp = uarray_compatible_interp(D47i, D48i)
1124
+
1125
+ return D48_n - D48_interp(x) - kslope_n * (D47_n - x)
1126
+
1127
+ def g(*args):
1128
+ return _fsolve(fun, [100.], args = args)[0]
1129
+
1130
+ wg = _uc.wrap(g)
1131
+
1132
+ D47p[i] = wg(
1133
+ D47[i],
1134
+ D48[i],
1135
+ kinetic_slope,
1136
+ *self.D47_coefs,
1137
+ *self.D48_coefs,
1138
+ )
1139
+
1140
+ _, D48p = self._compute_p_and_D48eq_from_D47eq(D47, D48, D47p, ignore_calib_uncertainties = False)
1141
+
1142
+ return D47p, D48p
1143
+
1144
+ def Teq_pdf(
1145
+ self,
1146
+ D47: _uc.ufloat,
1147
+ Tmin: (float | None) = None,
1148
+ Tmax: (float | None) = None,
1149
+ Tinc: float = 0.2,
1150
+ default_D47_sigmas: float = 4.0,
1151
+ ignore_calib_uncertainties: bool = False,
1152
+ run_qmc: bool = False,
1153
+ N_qmc: int = 1024,
1154
+ ):
1155
+ """
1156
+ Compute the unit-normalized probability distribution function (PDF) of the
1157
+ equilibrium temperature (`Teq`) for a given (`UFloat`) value of Δ<sub>47</sub>.
1158
+
1159
+ **Arguments**
1160
+ * `D47`: Δ<sub>47</sub> value (with uncertainty)
1161
+ * `Tmin`: minimum temperature over which to compute the PDF; if not specified,
1162
+ use temperature corresponding to `D47.n + `default_D47_sigmas` * D47.s`
1163
+ * `Tmax`: maximum temperature over which to compute the PDF; if not specified,
1164
+ use temperature corresponding to `D47.n - `default_D47_sigmas` * D47.s`
1165
+ * `Tinc`: temperature increment over which to compute the PDF
1166
+ * `default_D47_sigmas`: see `Tmin` and `Tmin` above
1167
+ * `ignore_calib_uncertainties`: whether to propagate calibration uncertainties
1168
+ * `run_qmc`: whether to also run a Quasi Monte carlo simulation to estimate the PDF
1169
+ * `N_qmc`: number of iterations in the above Quasi Monte Carlo simulation
1170
+
1171
+ **Returns**
1172
+ * `Ti`: Evenly-spaced array of temperature values over which the PDF is computed
1173
+ * `pdf`: PDF evaluated over `Ti`
1174
+ * `Tqmc` (only returned if `run_qmc = True`): array of `N_qmc` temperature values
1175
+ computed in the Quasi Monte Carlo simulation
1176
+ """
1177
+
1178
+ if Tmin is None:
1179
+ Tmin = _np.floor(self.T_as_function_of_D47(
1180
+ D47.n + default_D47_sigmas * D47.s,
1181
+ ignore_calib_uncertainties = ignore_calib_uncertainties,
1182
+ ).n)
1183
+
1184
+ if Tmax is None:
1185
+ Tmax = _np.ceil(self.T_as_function_of_D47(
1186
+ D47.n - default_D47_sigmas * D47.s,
1187
+ ignore_calib_uncertainties = ignore_calib_uncertainties,
1188
+ ).n)
1189
+
1190
+ assert Tmin < Tmax, "Tmax must be strictly greater than Tmin"
1191
+ assert Tinc > 0, "Tinc must be strictly greater than zero"
1192
+
1193
+ # compute interpolated Ti values
1194
+ Ti = _np.arange(Tmin, Tmax+Tinc, Tinc)
1195
+
1196
+ pdf = transform_pdf_monotonic(
1197
+ f_inv = lambda T: D4x_calib_function(
1198
+ T,
1199
+ self.D47_coefs,
1200
+ return_without_uncertainties = ignore_calib_uncertainties,
1201
+ ignore_calib_uncertainties = ignore_calib_uncertainties,
1202
+ ),
1203
+ df_inv = lambda T: D4x_calib_derivative(
1204
+ T,
1205
+ self.D47_coefs,
1206
+ return_without_uncertainties = ignore_calib_uncertainties,
1207
+ ignore_calib_uncertainties = ignore_calib_uncertainties,
1208
+ ),
1209
+ mu_x = D47.n,
1210
+ sigma_x = D47.s,
1211
+ yi = Ti,
1212
+ )
1213
+
1214
+ if run_qmc:
1215
+
1216
+ from scipy.stats import qmc
1217
+ from tqdm.rich import tqdm
1218
+
1219
+ #parameters to jiggle
1220
+ input_params = _cd.uarray([D47, *self.D47_coefs])
1221
+
1222
+ # QMC sampler for the correlation matrix of these parameters
1223
+ qmc_dist = qmc.MultivariateNormalQMC(
1224
+ mean = input_params.n*0,
1225
+ cov = input_params.cor,
1226
+ )
1227
+
1228
+ # QMC samples
1229
+ qmc_draws = input_params.n + qmc_dist.random(N_qmc) * input_params.s
1230
+
1231
+ # initialize T_qmc
1232
+ Tqmc = _cd.uarray(_np.zeros((N_qmc,)))
1233
+
1234
+ for k in tqdm(range(N_qmc)):
1235
+ # jiggled D47 and D47coefs
1236
+ _D47 = qmc_draws[k,0]
1237
+ if ignore_calib_uncertainties:
1238
+ _coefs = self.D47_coefs
1239
+ else:
1240
+ _coefs = _cd.uarray(_uc.correlated_values(qmc_draws[k,1:], self.D47_coefs.covar))
1241
+
1242
+ # jiggled D47
1243
+ _D47i = D4x_calib_function(self.interp.T, _coefs)
1244
+ _f = uarray_compatible_interp(_D47i.n, self.interp.T)
1245
+ Tqmc[k] = _f(_D47)
1246
+
1247
+ return Ti, pdf, Tqmc
1248
+
1249
+ return Ti, pdf
1250
+
1251
+
1252
+ ### Utilities and CLI ###
1253
+
1254
+
1255
+ def save_Teq_report(
1256
+ X,
1257
+ Y,
1258
+ T,
1259
+ p,
1260
+ filename,
1261
+ Xname = 'D47',
1262
+ Yname = 'D48',
1263
+ Tname = 'T95',
1264
+ labelname = 'Sample',
1265
+ fmt_Xnv = '.4f',
1266
+ fmt_Xse = '.4f',
1267
+ fmt_Ynv = '.4f',
1268
+ fmt_Yse = '.4f',
1269
+ fmt_Tnv = '.1f',
1270
+ fmt_Tse = '.1f',
1271
+ fmt_cm = '.6f',
1272
+ fmt_pv = '.2e',
1273
+ labels = None,
1274
+ sep = ',',
1275
+ p_cutoff = 0.05,
1276
+ ):
1277
+ """
1278
+ Save a temperature report to a csv file.
1279
+ Includes observed `D47`, `D48`, p-equilibrium values, and nearest `Teq` with sensible precision defaults.
1280
+ Alternatively, users may find [`correldata.CorrelData.str()`](https://mdaeron.github.io/correldata/#CorrelData.str)
1281
+ to be more versatile.
1282
+ """
1283
+ N = T.size
1284
+ if labels is None:
1285
+ labels = [str(k+1) for k in range(N)]
1286
+
1287
+ with open(filename, 'w') as fid:
1288
+ fid.write(f'{labelname}{sep}{Xname}{sep}SE{sep}correl{sep*N}{Yname}{sep}SE{sep}correl{sep*N}p-value{sep}{Tname}{sep}SE{sep}correl')
1289
+ Xnv = _unp.nominal_values(X)
1290
+ Xse = _unp.std_devs(X)
1291
+ Xcm = _np.array(_uc.correlation_matrix(X))
1292
+ Ynv = _unp.nominal_values(Y)
1293
+ Yse = _unp.std_devs(Y)
1294
+ Ycm = _np.array(_uc.correlation_matrix(Y))
1295
+ Tnv = _unp.nominal_values(T)
1296
+ Tse = _unp.std_devs(T)
1297
+ Tcm = _np.array(_uc.correlation_matrix(T))
1298
+ for k in range(X.size):
1299
+ fid.write(f'\n{labels[k]}{sep}{Xnv[k]:{fmt_Xnv}}{sep}{Xse[k]:{fmt_Xse}}{sep}')
1300
+ fid.write(sep.join([f'{Xcm[j,k]:{fmt_cm}}' for j in range(N)]))
1301
+ fid.write(f'{sep}{Ynv[k]:{fmt_Ynv}}{sep}{Yse[k]:{fmt_Yse}}{sep}')
1302
+ fid.write(sep.join([f'{Ycm[j,k]:{fmt_cm}}' for j in range(N)]))
1303
+ fid.write(f'{sep}{p[k]:{fmt_pv}}')
1304
+ if p[k] >= p_cutoff:
1305
+ fid.write(f'{sep}{Tnv[k]:{fmt_Tnv}}{sep}{Tse[k]:{fmt_Tse}}{sep}')
1306
+ fid.write(sep.join([f'{Tcm[j,k]:{fmt_cm}}' for j in range(N)]))
1307
+
1308
+ _rich_utils.STYLE_HELPTEXT = ''
1309
+
1310
+ __app = _typer.Typer(
1311
+ add_completion = False,
1312
+ context_settings={'help_option_names': ['-h', '--help']},
1313
+ rich_markup_mode = 'rich',
1314
+ )
1315
+
1316
+ @__app.command()
1317
+ def _cli(
1318
+ input: _Annotated[str, _typer.Option('--input', '-i', help = "Input file to read from (otherwise read from stdin).")] = None,
1319
+ output: _Annotated[str, _typer.Option('--output', '-o', help = "Output file to write to (otherwise write to stdout).")] = None,
1320
+ kslope: _Annotated[str, _typer.Option('--kslope', '-k', help = "Kinetic fractionation slope, using format [bold]'n(s)'[/bold] (with quotes), where [bold]n[/bold] is the slope and [bold]s[/bold] its standard error.")] = None,
1321
+ hpoutput: _Annotated[bool, _typer.Option('--high-precision-output', '-p', help = "Generate higher precision output.")] = False,
1322
+ show_mixed_correl: _Annotated[bool, _typer.Option('--show_mixed_correl', '-m', help = "Show correlations between different fields.")] = False,
1323
+ version: _Annotated[bool, _typer.Option('--version', '-v', help = 'Show version and exit.')] = False,
1324
+ ):
1325
+ """
1326
+ [b]Purpose:[/b]
1327
+
1328
+ Reads data from an input file, computes p-value and T estimates, and print out the results.
1329
+ """
1330
+ if version:
1331
+ print(__version__)
1332
+ return None
1333
+
1334
+ if input is None:
1335
+ datastring = ''.join(sys.stdin)
1336
+ elif isinstance(input, str):
1337
+ with open(input) as fid:
1338
+ datastring = fid.read()
1339
+
1340
+ data = _cd.read_str(datastring)
1341
+
1342
+ E = Engine()
1343
+
1344
+ D47eq, D48eq, p = E.nearest_D47eq(data['D47'], data['D48'])
1345
+ Teq = E.T_as_function_of_D47(D47eq)
1346
+ data['eq_pvalue'] = p
1347
+ data['Teq'] = Teq
1348
+
1349
+ if isinstance(kslope, str):
1350
+ kslope = kslope.split(')')[0]
1351
+ kslope = kslope.split('(')
1352
+ kslope = _uc.ufloat(float(kslope[0]), float(kslope[1]))
1353
+
1354
+ D47kp, D48kp = E.projected_D47eq(data['D47'], data['D48'], kinetic_slope = kslope)
1355
+ Tkp = E.T_as_function_of_D47(D47kp)
1356
+
1357
+ data['kslope'] = _cd.uarray([kslope for _ in data['D47']])
1358
+
1359
+ data['Tkp'] = Tkp
1360
+
1361
+ ffmt = {
1362
+ 'D47': '.6f',
1363
+ 'D48': '.6f',
1364
+ 'kslope': lambda x: f'{x:z.6f}'.rstrip('0'),
1365
+ 'Teq': 'z.6f',
1366
+ 'Tkp': 'z.6f',
1367
+ } if hpoutput else {
1368
+ 'D47': '.4f',
1369
+ 'D48': '.4f',
1370
+ 'kslope': lambda x: f'{x:z.6f}'.rstrip('0'),
1371
+ 'Teq': 'z.2f',
1372
+ 'Tkp': 'z.2f',
1373
+ }
1374
+
1375
+ out = data.str(
1376
+ float_format = ffmt,
1377
+ show_mixed_correl = show_mixed_correl,
1378
+ exclude_fields = ['correl_kslope'],
1379
+ )
1380
+
1381
+ if output is None:
1382
+ print(out)
1383
+ elif isinstance(output, str):
1384
+ with open(output, 'w') as fid:
1385
+ fid.write(out)
1386
+
1387
+ def __cli(): __app()