earthsciencekit 0.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.
Files changed (102) hide show
  1. earthsciencekit/__init__.py +6 -0
  2. earthsciencekit/_validation.py +62 -0
  3. earthsciencekit/atmosphere/__init__.py +57 -0
  4. earthsciencekit/atmosphere/core/__init__.py +5 -0
  5. earthsciencekit/atmosphere/core/base.py +10 -0
  6. earthsciencekit/atmosphere/systems/__init__.py +1 -0
  7. earthsciencekit/atmosphere/systems/dynamics.py +208 -0
  8. earthsciencekit/atmosphere/systems/hydrostatics.py +214 -0
  9. earthsciencekit/atmosphere/systems/moisture.py +162 -0
  10. earthsciencekit/atmosphere/systems/thermodynamics.py +222 -0
  11. earthsciencekit/atmosphere/tests/__init__.py +0 -0
  12. earthsciencekit/atmosphere/tests/test_models.py +741 -0
  13. earthsciencekit/atmosphere/tests/test_physics.py +81 -0
  14. earthsciencekit/atmosphere/visualizers/__init__.py +9 -0
  15. earthsciencekit/climate/__init__.py +63 -0
  16. earthsciencekit/climate/core/__init__.py +5 -0
  17. earthsciencekit/climate/core/base.py +10 -0
  18. earthsciencekit/climate/systems/__init__.py +1 -0
  19. earthsciencekit/climate/systems/carbon.py +76 -0
  20. earthsciencekit/climate/systems/forcing.py +124 -0
  21. earthsciencekit/climate/systems/radiation.py +214 -0
  22. earthsciencekit/climate/systems/response.py +402 -0
  23. earthsciencekit/climate/systems/sea_level.py +77 -0
  24. earthsciencekit/climate/tests/__init__.py +0 -0
  25. earthsciencekit/climate/tests/test_models.py +1015 -0
  26. earthsciencekit/climate/tests/test_physics.py +16 -0
  27. earthsciencekit/climate/visualizers/__init__.py +9 -0
  28. earthsciencekit/constants/__init__.py +23 -0
  29. earthsciencekit/geophysics/__init__.py +77 -0
  30. earthsciencekit/geophysics/core/__init__.py +5 -0
  31. earthsciencekit/geophysics/core/base.py +10 -0
  32. earthsciencekit/geophysics/systems/__init__.py +1 -0
  33. earthsciencekit/geophysics/systems/deformation.py +161 -0
  34. earthsciencekit/geophysics/systems/dynamo.py +159 -0
  35. earthsciencekit/geophysics/systems/figure.py +292 -0
  36. earthsciencekit/geophysics/systems/gravity.py +235 -0
  37. earthsciencekit/geophysics/systems/magnetism.py +354 -0
  38. earthsciencekit/geophysics/tests/__init__.py +0 -0
  39. earthsciencekit/geophysics/tests/test_models.py +898 -0
  40. earthsciencekit/geophysics/tests/test_physics.py +171 -0
  41. earthsciencekit/geophysics/visualizers/__init__.py +9 -0
  42. earthsciencekit/integrators/__init__.py +62 -0
  43. earthsciencekit/oceanography/__init__.py +61 -0
  44. earthsciencekit/oceanography/core/__init__.py +5 -0
  45. earthsciencekit/oceanography/core/base.py +10 -0
  46. earthsciencekit/oceanography/systems/__init__.py +1 -0
  47. earthsciencekit/oceanography/systems/circulation.py +388 -0
  48. earthsciencekit/oceanography/systems/hydrography.py +212 -0
  49. earthsciencekit/oceanography/systems/inertial.py +86 -0
  50. earthsciencekit/oceanography/systems/waves.py +281 -0
  51. earthsciencekit/oceanography/tests/__init__.py +0 -0
  52. earthsciencekit/oceanography/tests/test_models.py +916 -0
  53. earthsciencekit/oceanography/tests/test_physics.py +102 -0
  54. earthsciencekit/oceanography/visualizers/__init__.py +9 -0
  55. earthsciencekit/plate_tectonics/__init__.py +59 -0
  56. earthsciencekit/plate_tectonics/core/__init__.py +5 -0
  57. earthsciencekit/plate_tectonics/core/base.py +10 -0
  58. earthsciencekit/plate_tectonics/systems/__init__.py +1 -0
  59. earthsciencekit/plate_tectonics/systems/boundaries.py +201 -0
  60. earthsciencekit/plate_tectonics/systems/cooling.py +238 -0
  61. earthsciencekit/plate_tectonics/systems/isostasy.py +157 -0
  62. earthsciencekit/plate_tectonics/systems/kinematics.py +290 -0
  63. earthsciencekit/plate_tectonics/tests/__init__.py +0 -0
  64. earthsciencekit/plate_tectonics/tests/test_models.py +920 -0
  65. earthsciencekit/plate_tectonics/tests/test_physics.py +94 -0
  66. earthsciencekit/plate_tectonics/visualizers/__init__.py +9 -0
  67. earthsciencekit/py.typed +0 -0
  68. earthsciencekit/results.py +73 -0
  69. earthsciencekit/seismology/__init__.py +59 -0
  70. earthsciencekit/seismology/core/__init__.py +5 -0
  71. earthsciencekit/seismology/core/base.py +10 -0
  72. earthsciencekit/seismology/systems/__init__.py +1 -0
  73. earthsciencekit/seismology/systems/interfaces.py +102 -0
  74. earthsciencekit/seismology/systems/rays.py +221 -0
  75. earthsciencekit/seismology/systems/sources.py +249 -0
  76. earthsciencekit/seismology/systems/waves.py +268 -0
  77. earthsciencekit/seismology/tests/__init__.py +0 -0
  78. earthsciencekit/seismology/tests/test_models.py +852 -0
  79. earthsciencekit/seismology/tests/test_physics.py +100 -0
  80. earthsciencekit/seismology/visualizers/__init__.py +9 -0
  81. earthsciencekit/tests/__init__.py +0 -0
  82. earthsciencekit/tests/test_core.py +181 -0
  83. earthsciencekit/tests/test_relations.py +36 -0
  84. earthsciencekit/units/__init__.py +66 -0
  85. earthsciencekit/visualizers/__init__.py +45 -0
  86. earthsciencekit/volcanology/__init__.py +61 -0
  87. earthsciencekit/volcanology/core/__init__.py +5 -0
  88. earthsciencekit/volcanology/core/base.py +10 -0
  89. earthsciencekit/volcanology/systems/__init__.py +1 -0
  90. earthsciencekit/volcanology/systems/eruptions.py +161 -0
  91. earthsciencekit/volcanology/systems/geochemistry.py +226 -0
  92. earthsciencekit/volcanology/systems/geochronology.py +304 -0
  93. earthsciencekit/volcanology/systems/magma.py +190 -0
  94. earthsciencekit/volcanology/tests/__init__.py +0 -0
  95. earthsciencekit/volcanology/tests/test_models.py +485 -0
  96. earthsciencekit/volcanology/tests/test_physics.py +176 -0
  97. earthsciencekit/volcanology/visualizers/__init__.py +9 -0
  98. earthsciencekit-0.2.0.dist-info/METADATA +127 -0
  99. earthsciencekit-0.2.0.dist-info/RECORD +102 -0
  100. earthsciencekit-0.2.0.dist-info/WHEEL +5 -0
  101. earthsciencekit-0.2.0.dist-info/licenses/LICENSE +15 -0
  102. earthsciencekit-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,161 @@
1
+ """Eruptions: buoyant plume rise, eruption-column height, the Volcanic Explosivity Index and a rising column parcel.
2
+
3
+ SI units unless stated otherwise.
4
+ """
5
+
6
+ import numpy as np
7
+
8
+ from earthsciencekit._validation import model, quantity, require, safe_arithmetic
9
+ from earthsciencekit.results import TimeSeries
10
+
11
+ __all__ = [
12
+ "plume_rise_height",
13
+ "column_height",
14
+ "volcanic_explosivity_index",
15
+ "simulate_eruption_column",
16
+ ]
17
+
18
+
19
+ @model
20
+ def plume_rise_height(buoyancy_flux, buoyancy_frequency):
21
+ """Maximum height of a buoyant plume in a stratified fluid.
22
+
23
+ Turbulent entrainment dilutes a plume until the stratification removes its buoyancy, which caps its height at a value
24
+ set by only the source buoyancy flux and the stratification.
25
+
26
+ Parameters
27
+ ----------
28
+ buoyancy_flux : float or array_like
29
+ m^4/s^3, source ``reduced_gravity * velocity * radius**2`` (the factor pi omitted); constraint: positive.
30
+ buoyancy_frequency : float or array_like
31
+ 1/s; constraint: positive.
32
+
33
+ Returns
34
+ -------
35
+ numpy scalar or ndarray
36
+ Model value [m]. Scalar input returns a NumPy scalar; arrays broadcast.
37
+
38
+ Notes
39
+ -----
40
+ Morton, Taylor and Turner (1956) for a steady point source of buoyancy in uniform stratification, with Briggs's
41
+ constant 5.0 (entrainment about 0.1); source momentum, wind and moisture are neglected.
42
+ Equation: ``5.0 * buoyancy_flux**0.25 * buoyancy_frequency**-0.75``.
43
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
44
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
45
+ """
46
+ buoyancy_flux = quantity("buoyancy_flux", buoyancy_flux, lower=0, strict_lower=True)
47
+ buoyancy_frequency = quantity("buoyancy_frequency", buoyancy_frequency, lower=0, strict_lower=True)
48
+ return 5.0 * buoyancy_flux**0.25 * buoyancy_frequency**-0.75
49
+
50
+
51
+ @model
52
+ def column_height(heat_flux):
53
+ """Height of a Plinian eruption column from its heat flux.
54
+
55
+ The thermal energy released at the vent, not the eruption velocity, controls how high a volcanic column rises.
56
+
57
+ Parameters
58
+ ----------
59
+ heat_flux : float or array_like
60
+ W, rate of heat release ``mass_eruption_rate * specific_heat * temperature_excess``; constraint: positive.
61
+
62
+ Returns
63
+ -------
64
+ numpy scalar or ndarray
65
+ Model value [m]. Scalar input returns a NumPy scalar; arrays broadcast.
66
+
67
+ Notes
68
+ -----
69
+ Wilson, Sparks, Huang and Watkins (1978), JGR 83: 1829-1836, for a standard atmosphere and a strong steady column;
70
+ wind bends weak plumes lower.
71
+ Equation: ``8.2 * heat_flux**0.25``.
72
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
73
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
74
+ """
75
+ heat_flux = quantity("heat_flux", heat_flux, lower=0, strict_lower=True)
76
+ return 8.2 * heat_flux**0.25
77
+
78
+
79
+ @model
80
+ def volcanic_explosivity_index(erupted_volume):
81
+ """Volcanic Explosivity Index from the erupted tephra volume.
82
+
83
+ A single logarithmic number lets eruptions of very different style and size be compared and catalogued.
84
+
85
+ Parameters
86
+ ----------
87
+ erupted_volume : float or array_like
88
+ m^3, bulk tephra volume; constraint: nonnegative.
89
+
90
+ Returns
91
+ -------
92
+ numpy scalar or ndarray
93
+ Model value [1], an integer from 0 to 8. Scalar input returns a NumPy scalar; arrays broadcast.
94
+
95
+ Notes
96
+ -----
97
+ Newhall and Self (1982), volume criterion only: VEI 0 below 1e4 m^3, 1 below 1e6 m^3, then one step per factor of
98
+ ten up to VEI 8 at 1e12 m^3 and above. Column height and other criteria are not used.
99
+ Equation: ``np.where(erupted_volume < 1e4, 0, np.where(erupted_volume < 1e6, 1,
100
+ np.clip(np.floor(np.log10(erupted_volume)) - 4, 2, 8)))``.
101
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
102
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
103
+ """
104
+ erupted_volume = quantity("erupted_volume", erupted_volume, lower=0)
105
+ steps = np.clip(np.floor(np.log10(np.maximum(erupted_volume, 1e6))) - 4, 2, 8)
106
+ return np.where(erupted_volume < 1e4, 0.0, np.where(erupted_volume < 1e6, 1.0, steps))[()]
107
+
108
+
109
+ @safe_arithmetic
110
+ def simulate_eruption_column(vent_radius, vent_velocity, reduced_gravity, buoyancy_frequency, time, *, entrainment=0.12):
111
+ """Follow a parcel up the axis of a steady buoyant eruption column.
112
+
113
+ Parameters
114
+ ----------
115
+ vent_radius : float
116
+ Positive scalar source radius [m].
117
+ vent_velocity : float
118
+ Positive scalar source velocity [m/s].
119
+ reduced_gravity : float
120
+ Positive scalar source buoyancy ``g (rho_air - rho_plume) / rho_air`` [m/s^2].
121
+ buoyancy_frequency : float
122
+ Nonnegative scalar Brunt-Vaisala frequency of the atmosphere [1/s].
123
+ time : array_like
124
+ Nonnegative strictly increasing travel times since the parcel left the vent [s].
125
+ entrainment : float
126
+ Positive scalar top-hat entrainment coefficient [1]; 0.12 reproduces the constant 5.0 of plume_rise_height.
127
+
128
+ Returns
129
+ -------
130
+ TimeSeries
131
+ Parcel height [m], vertical velocity [m/s] and reduced gravity [m/s^2].
132
+
133
+ Notes
134
+ -----
135
+ The Morton, Taylor and Turner (1956) top-hat plume equations for the fluxes Q = b**2 w, M = b**2 w**2 and
136
+ F = b**2 w g' (pi omitted), carried by a parcel moving at w = M / Q: dz/dt = M / Q, dQ/dt = 2 alpha abs(M)**1.5 / Q,
137
+ dM/dt = F and dF/dt = -N**2 M. In a uniform atmosphere the parcel stops near plume_rise_height. Past that height the
138
+ steady theory ends; the parcel is continued as a fountain that falls back with the same entrainment law and oscillates
139
+ about the level of neutral buoyancy, near three quarters of the maximum height, where the umbrella cloud spreads.
140
+ This illustrative closure omits wind, the dense gas-thrust region, particle fallout and moisture.
141
+ """
142
+ from earthsciencekit._validation import time_grid
143
+ from earthsciencekit.integrators import integrate_ode
144
+
145
+ radius = quantity("vent_radius", vent_radius, lower=0, strict_lower=True)
146
+ velocity = quantity("vent_velocity", vent_velocity, lower=0, strict_lower=True)
147
+ buoyancy = quantity("reduced_gravity", reduced_gravity, lower=0, strict_lower=True)
148
+ frequency = quantity("buoyancy_frequency", buoyancy_frequency, lower=0)
149
+ alpha = quantity("entrainment", entrainment, lower=0, strict_lower=True)
150
+ require(all(v.ndim == 0 for v in (radius, velocity, buoyancy, frequency, alpha)), "eruption column parameters must be scalars")
151
+ time = time_grid(time)
152
+
153
+ def rhs(t, state):
154
+ _, volume, momentum, flux = state
155
+ return [momentum / volume, 2 * alpha * np.abs(momentum) ** 1.5 / volume, flux, -(frequency**2) * momentum]
156
+
157
+ initial = [0.0, radius**2 * velocity, radius**2 * velocity**2, radius**2 * velocity * buoyancy]
158
+ fluxes = integrate_ode(rhs, initial, time - time[0], names=("height", "Q", "M", "F"), units=("m", "m^3/s", "m^4/s^2", "m^4/s^3"))
159
+ _, volume, momentum, flux = fluxes.values.T
160
+ values = np.column_stack([fluxes.values[:, 0], momentum / volume, flux / volume])
161
+ return TimeSeries(time, values, ("height", "vertical_velocity", "reduced_gravity"), ("m", "m/s", "m/s^2"))
@@ -0,0 +1,226 @@
1
+ """Geochemistry: Rayleigh fractionation, delta notation, melt crystallization, lattice strain and the oxygen-isotope thermometer.
2
+
3
+ SI units unless stated otherwise; isotope delta values are per mil.
4
+ """
5
+
6
+ import numpy as np
7
+
8
+ from earthsciencekit._validation import model, quantity
9
+ from earthsciencekit.constants import AVOGADRO, R_GAS
10
+
11
+ __all__ = [
12
+ "rayleigh_fractionation",
13
+ "delta_value",
14
+ "fractional_crystallization_melt",
15
+ "equilibrium_crystallization_melt",
16
+ "lattice_strain_partition",
17
+ "isotope_temperature",
18
+ ]
19
+
20
+
21
+ @model
22
+ def rayleigh_fractionation(initial_ratio, remaining_fraction, fractionation_factor):
23
+ """Isotope ratio of a reservoir depleted by Rayleigh distillation.
24
+
25
+ Removing a product that is always slightly enriched in one isotope drives the remaining reservoir further and further
26
+ from its starting composition.
27
+
28
+ Parameters
29
+ ----------
30
+ initial_ratio : float or array_like
31
+ 1, heavy-to-light isotope ratio of the reservoir at the start; constraint: positive.
32
+ remaining_fraction : float or array_like
33
+ 1, fraction of the reservoir left; constraint: fraction above zero.
34
+ fractionation_factor : float or array_like
35
+ 1, product ratio over reservoir ratio at each instant; constraint: positive.
36
+
37
+ Returns
38
+ -------
39
+ numpy scalar or ndarray
40
+ Model value [1]. Scalar input returns a NumPy scalar; arrays broadcast.
41
+
42
+ Notes
43
+ -----
44
+ Rayleigh (1896): the product leaves as soon as it forms, the reservoir stays well mixed and the factor is constant.
45
+ Equation: ``initial_ratio * remaining_fraction**(fractionation_factor - 1)``.
46
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
47
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
48
+ """
49
+ initial_ratio = quantity("initial_ratio", initial_ratio, lower=0, strict_lower=True)
50
+ remaining_fraction = quantity("remaining_fraction", remaining_fraction, lower=0, upper=1, strict_lower=True)
51
+ fractionation_factor = quantity("fractionation_factor", fractionation_factor, lower=0, strict_lower=True)
52
+ return initial_ratio * remaining_fraction ** (fractionation_factor - 1)
53
+
54
+
55
+ @model
56
+ def delta_value(ratio, standard_ratio):
57
+ """Isotope delta value relative to a standard.
58
+
59
+ Isotope ratios vary in the fourth decimal place, so geochemists report deviations from a standard in per mil.
60
+
61
+ Parameters
62
+ ----------
63
+ ratio : float or array_like
64
+ 1, heavy-to-light isotope ratio of the sample; constraint: positive.
65
+ standard_ratio : float or array_like
66
+ 1, the same ratio in the standard (e.g. VSMOW, VPDB); constraint: positive.
67
+
68
+ Returns
69
+ -------
70
+ numpy scalar or ndarray
71
+ Model value [per mil]. Scalar input returns a NumPy scalar; arrays broadcast.
72
+
73
+ Notes
74
+ -----
75
+ Linear in the ratio; delta values of different standards must be converted before they are compared.
76
+ Equation: ``(ratio / standard_ratio - 1) * 1000``.
77
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
78
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
79
+ """
80
+ ratio = quantity("ratio", ratio, lower=0, strict_lower=True)
81
+ standard_ratio = quantity("standard_ratio", standard_ratio, lower=0, strict_lower=True)
82
+ return (ratio / standard_ratio - 1) * 1000
83
+
84
+
85
+ @model
86
+ def fractional_crystallization_melt(initial_concentration, melt_fraction, partition_coefficient):
87
+ """Trace-element concentration in a melt during fractional crystallization.
88
+
89
+ Crystals removed as soon as they form leave the residual melt enriched in the elements they reject.
90
+
91
+ Parameters
92
+ ----------
93
+ initial_concentration : float or array_like
94
+ Concentration in the parent melt (any unit, e.g. ppm); constraint: nonnegative.
95
+ melt_fraction : float or array_like
96
+ 1, fraction of melt remaining; constraint: fraction above zero.
97
+ partition_coefficient : float or array_like
98
+ 1, bulk solid/melt concentration ratio; constraint: nonnegative.
99
+
100
+ Returns
101
+ -------
102
+ numpy scalar or ndarray
103
+ Model value [unit of initial_concentration]. Scalar input returns a NumPy scalar; arrays broadcast.
104
+
105
+ Notes
106
+ -----
107
+ Rayleigh fractional crystallization (Bowen 1928; Gast 1968): crystals do not re-equilibrate and the bulk partition
108
+ coefficient is constant.
109
+ Equation: ``initial_concentration * melt_fraction**(partition_coefficient - 1)``.
110
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
111
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
112
+ """
113
+ initial_concentration = quantity("initial_concentration", initial_concentration, lower=0)
114
+ melt_fraction = quantity("melt_fraction", melt_fraction, lower=0, upper=1, strict_lower=True)
115
+ partition_coefficient = quantity("partition_coefficient", partition_coefficient, lower=0)
116
+ return initial_concentration * melt_fraction ** (partition_coefficient - 1)
117
+
118
+
119
+ @model
120
+ def equilibrium_crystallization_melt(initial_concentration, melt_fraction, partition_coefficient):
121
+ """Trace-element concentration in a melt during equilibrium crystallization.
122
+
123
+ Crystals that keep reacting with the melt, as in Bowen's continuous series, limit how far the melt can evolve.
124
+
125
+ Parameters
126
+ ----------
127
+ initial_concentration : float or array_like
128
+ Concentration in the parent melt (any unit, e.g. ppm); constraint: nonnegative.
129
+ melt_fraction : float or array_like
130
+ 1, fraction of melt remaining; constraint: fraction.
131
+ partition_coefficient : float or array_like
132
+ 1, bulk solid/melt concentration ratio; constraint: positive.
133
+
134
+ Returns
135
+ -------
136
+ numpy scalar or ndarray
137
+ Model value [unit of initial_concentration]. Scalar input returns a NumPy scalar; arrays broadcast.
138
+
139
+ Notes
140
+ -----
141
+ Batch mass balance (Shaw 1970): solid and melt stay in equilibrium, so an incompatible element is enriched at most by
142
+ 1 / partition_coefficient.
143
+ Equation: ``initial_concentration / (partition_coefficient + melt_fraction * (1 - partition_coefficient))``.
144
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
145
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
146
+ """
147
+ initial_concentration = quantity("initial_concentration", initial_concentration, lower=0)
148
+ melt_fraction = quantity("melt_fraction", melt_fraction, lower=0, upper=1)
149
+ partition_coefficient = quantity("partition_coefficient", partition_coefficient, lower=0, strict_lower=True)
150
+ return initial_concentration / (partition_coefficient + melt_fraction * (1 - partition_coefficient))
151
+
152
+
153
+ @model
154
+ def lattice_strain_partition(ionic_radius, optimal_radius, peak_partition, youngs_modulus, temperature):
155
+ """Mineral-melt partition coefficient from the strain of the crystal lattice.
156
+
157
+ Ions close in size to the site they replace enter a crystal easily, which is Goldschmidt's first rule of substitution.
158
+
159
+ Parameters
160
+ ----------
161
+ ionic_radius : float or array_like
162
+ m; constraint: positive.
163
+ optimal_radius : float or array_like
164
+ m, radius that fits the site without strain; constraint: positive.
165
+ peak_partition : float or array_like
166
+ 1, partition coefficient of an ion of the optimal radius; constraint: positive.
167
+ youngs_modulus : float or array_like
168
+ Pa, apparent Young's modulus of the site; constraint: positive.
169
+ temperature : float or array_like
170
+ K; constraint: positive.
171
+
172
+ Returns
173
+ -------
174
+ numpy scalar or ndarray
175
+ Model value [1]. Scalar input returns a NumPy scalar; arrays broadcast.
176
+
177
+ Notes
178
+ -----
179
+ Blundy and Wood (1994) quantification of Goldschmidt (1937) for isovalent ions on one site; charge differences
180
+ need a separate parabola.
181
+ Equation: ``peak_partition * np.exp(-4 * np.pi * youngs_modulus * AVOGADRO * (optimal_radius / 2 * (ionic_radius - optimal_radius)**2
182
+ + (ionic_radius - optimal_radius)**3 / 3) / (R_GAS * temperature))``.
183
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
184
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
185
+ """
186
+ ionic_radius = quantity("ionic_radius", ionic_radius, lower=0, strict_lower=True)
187
+ optimal_radius = quantity("optimal_radius", optimal_radius, lower=0, strict_lower=True)
188
+ peak_partition = quantity("peak_partition", peak_partition, lower=0, strict_lower=True)
189
+ youngs_modulus = quantity("youngs_modulus", youngs_modulus, lower=0, strict_lower=True)
190
+ temperature = quantity("temperature", temperature, lower=0, strict_lower=True)
191
+ misfit = ionic_radius - optimal_radius
192
+ strain = 4 * np.pi * youngs_modulus * AVOGADRO * (optimal_radius / 2 * misfit**2 + misfit**3 / 3)
193
+ return peak_partition * np.exp(-strain / (R_GAS * temperature))
194
+
195
+
196
+ @model
197
+ def isotope_temperature(delta_calcite, delta_water):
198
+ """Calcite-water oxygen-isotope paleotemperature.
199
+
200
+ The oxygen-isotope fractionation between a shell and the water it grew in falls with temperature, so fossil shells
201
+ record ancient ocean temperatures.
202
+
203
+ Parameters
204
+ ----------
205
+ delta_calcite : float or array_like
206
+ per mil, delta18O of the carbonate (CO2 from the calcite, PDB scale); constraint: real.
207
+ delta_water : float or array_like
208
+ per mil, delta18O of the water on the same scale; constraint: real.
209
+
210
+ Returns
211
+ -------
212
+ numpy scalar or ndarray
213
+ Model value [K]. Scalar input returns a NumPy scalar; arrays broadcast.
214
+
215
+ Notes
216
+ -----
217
+ Urey (1947) predicted the effect; this is the empirical calibration of Epstein, Buchsbaum, Lowenstam and Urey (1953),
218
+ valid for about 7-30 degC and equilibrium calcite.
219
+ Equation: ``273.15 + 16.5 - 4.3 * d + 0.14 * d**2`` with ``d = delta_calcite - delta_water``.
220
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
221
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
222
+ """
223
+ delta_calcite = quantity("delta_calcite", delta_calcite)
224
+ delta_water = quantity("delta_water", delta_water)
225
+ difference = delta_calcite - delta_water
226
+ return 273.15 + 16.5 - 4.3 * difference + 0.14 * difference**2
@@ -0,0 +1,304 @@
1
+ """Geochronology: radioactive decay, the age equation, radiocarbon, lead-lead ages, the concordia and the isochron.
2
+
3
+ SI units unless stated otherwise; ages and half-lives are seconds and decay constants 1/s.
4
+ """
5
+
6
+ import numpy as np
7
+ from scipy.optimize import brentq
8
+
9
+ from earthsciencekit._validation import model, quantity, require
10
+ from earthsciencekit.constants import LAMBDA_U235, LAMBDA_U238, LIBBY_MEAN_LIFE, SECONDS_PER_YEAR, U238_U235
11
+
12
+ __all__ = [
13
+ "decay_constant",
14
+ "parent_fraction",
15
+ "radiometric_age",
16
+ "radiogenic_daughter",
17
+ "radiocarbon_age",
18
+ "radiogenic_lead_ratio",
19
+ "lead_lead_age",
20
+ "concordia_ratios",
21
+ "isochron_fit",
22
+ ]
23
+
24
+ _YOUNGEST, _OLDEST = SECONDS_PER_YEAR, 1e10 * SECONDS_PER_YEAR # s, the bracket of lead_lead_age
25
+
26
+
27
+ @model
28
+ def decay_constant(half_life):
29
+ """Decay constant from a half-life.
30
+
31
+ Every radioactive nuclide has a fixed probability per unit time of decaying, set by its half-life.
32
+
33
+ Parameters
34
+ ----------
35
+ half_life : float or array_like
36
+ s; constraint: positive.
37
+
38
+ Returns
39
+ -------
40
+ numpy scalar or ndarray
41
+ Model value [1/s]. Scalar input returns a NumPy scalar; arrays broadcast.
42
+
43
+ Notes
44
+ -----
45
+ Rutherford and Soddy (1902): first-order decay, independent of temperature, pressure and chemistry.
46
+ Equation: ``np.log(2) / half_life``.
47
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
48
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
49
+ """
50
+ half_life = quantity("half_life", half_life, lower=0, strict_lower=True)
51
+ return np.log(2) / half_life
52
+
53
+
54
+ @model
55
+ def parent_fraction(decay_constant, time):
56
+ """Fraction of radioactive parent atoms remaining.
57
+
58
+ Exponential decay is the clock behind every radiometric age.
59
+
60
+ Parameters
61
+ ----------
62
+ decay_constant : float or array_like
63
+ 1/s; constraint: positive.
64
+ time : float or array_like
65
+ s; constraint: nonnegative.
66
+
67
+ Returns
68
+ -------
69
+ numpy scalar or ndarray
70
+ Model value [1]. Scalar input returns a NumPy scalar; arrays broadcast.
71
+
72
+ Notes
73
+ -----
74
+ Rutherford and Soddy (1902): a closed system with no parent added after time zero.
75
+ Equation: ``np.exp(-decay_constant * time)``.
76
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
77
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
78
+ """
79
+ decay_constant = quantity("decay_constant", decay_constant, lower=0, strict_lower=True)
80
+ time = quantity("time", time, lower=0)
81
+ return np.exp(-decay_constant * time)
82
+
83
+
84
+ @model
85
+ def radiometric_age(daughter_parent_ratio, decay_constant):
86
+ """Age from a radiogenic daughter-to-parent ratio.
87
+
88
+ The ratio of accumulated daughter atoms to surviving parent atoms grows with time, so measuring it dates the rock.
89
+
90
+ Parameters
91
+ ----------
92
+ daughter_parent_ratio : float or array_like
93
+ 1, radiogenic daughter atoms per parent atom; constraint: nonnegative.
94
+ decay_constant : float or array_like
95
+ 1/s; constraint: positive.
96
+
97
+ Returns
98
+ -------
99
+ numpy scalar or ndarray
100
+ Model value [s]. Scalar input returns a NumPy scalar; arrays broadcast.
101
+
102
+ Notes
103
+ -----
104
+ Closed system with no initial daughter; Boltwood (1907) applied it to lead in uranium minerals.
105
+ Equation: ``np.log1p(daughter_parent_ratio) / decay_constant``.
106
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
107
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
108
+ """
109
+ daughter_parent_ratio = quantity("daughter_parent_ratio", daughter_parent_ratio, lower=0)
110
+ decay_constant = quantity("decay_constant", decay_constant, lower=0, strict_lower=True)
111
+ return np.log1p(daughter_parent_ratio) / decay_constant
112
+
113
+
114
+ @model
115
+ def radiogenic_daughter(parent, decay_constant, time):
116
+ """Radiogenic daughter accumulated beside the surviving parent.
117
+
118
+ Forward accumulation shows how the daughter-to-parent ratio of a rock records its age.
119
+
120
+ Parameters
121
+ ----------
122
+ parent : float or array_like
123
+ Surviving parent amount today (atoms, moles or any consistent unit); constraint: nonnegative.
124
+ decay_constant : float or array_like
125
+ 1/s; constraint: positive.
126
+ time : float or array_like
127
+ s; constraint: nonnegative.
128
+
129
+ Returns
130
+ -------
131
+ numpy scalar or ndarray
132
+ Model value [unit of parent]. Scalar input returns a NumPy scalar; arrays broadcast.
133
+
134
+ Notes
135
+ -----
136
+ Closed system with no initial daughter; the inverse of radiometric_age. Holmes (1913) used it to build the first
137
+ radiometric time scale.
138
+ Equation: ``parent * np.expm1(decay_constant * time)``.
139
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
140
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
141
+ """
142
+ parent = quantity("parent", parent, lower=0)
143
+ decay_constant = quantity("decay_constant", decay_constant, lower=0, strict_lower=True)
144
+ time = quantity("time", time, lower=0)
145
+ return parent * np.expm1(decay_constant * time)
146
+
147
+
148
+ @model
149
+ def radiocarbon_age(fraction_modern):
150
+ """Conventional radiocarbon age.
151
+
152
+ Living matter exchanges carbon-14 with the atmosphere; after death the carbon-14 decays, dating organic remains.
153
+
154
+ Parameters
155
+ ----------
156
+ fraction_modern : float or array_like
157
+ 1, sample 14C/12C relative to the modern standard; constraint: positive.
158
+
159
+ Returns
160
+ -------
161
+ numpy scalar or ndarray
162
+ Model value [s]; negative for bomb carbon above the standard. Scalar input returns a NumPy scalar; arrays broadcast.
163
+
164
+ Notes
165
+ -----
166
+ Libby et al. (1949) with the conventional 5568-year (Libby) half-life, mean life LIBBY_MEAN_LIFE; it is a radiocarbon
167
+ age, not a calendar age, until calibrated.
168
+ Equation: ``-LIBBY_MEAN_LIFE * np.log(fraction_modern)``.
169
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
170
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
171
+ """
172
+ fraction_modern = quantity("fraction_modern", fraction_modern, lower=0, strict_lower=True)
173
+ return -LIBBY_MEAN_LIFE * np.log(fraction_modern)
174
+
175
+
176
+ @model
177
+ def radiogenic_lead_ratio(time):
178
+ """Radiogenic 207Pb/206Pb ratio produced over a given age.
179
+
180
+ Two uranium isotopes decaying at different rates make the ratio of their lead daughters a clock that needs no uranium
181
+ measurement.
182
+
183
+ Parameters
184
+ ----------
185
+ time : float or array_like
186
+ s; constraint: positive.
187
+
188
+ Returns
189
+ -------
190
+ numpy scalar or ndarray
191
+ Model value [1]. Scalar input returns a NumPy scalar; arrays broadcast.
192
+
193
+ Notes
194
+ -----
195
+ Patterson (1956): closed system with today's 238U/235U = U238_U235 and the Jaffey et al. (1971) decay constants. The
196
+ ratio tends to LAMBDA_U235 / (U238_U235 * LAMBDA_U238) at zero age.
197
+ Equation: ``np.expm1(LAMBDA_U235 * time) / (U238_U235 * np.expm1(LAMBDA_U238 * time))``.
198
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
199
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
200
+ """
201
+ time = quantity("time", time, lower=0, strict_lower=True)
202
+ return np.expm1(LAMBDA_U235 * time) / (U238_U235 * np.expm1(LAMBDA_U238 * time))
203
+
204
+
205
+ @model
206
+ def lead_lead_age(lead_ratio):
207
+ """Age from a radiogenic 207Pb/206Pb ratio.
208
+
209
+ Patterson dated the Earth from the slope of a meteorite lead isochron, which equals this ratio.
210
+
211
+ Parameters
212
+ ----------
213
+ lead_ratio : float or array_like
214
+ 1, radiogenic 207Pb*/206Pb* or the slope of a Pb-Pb isochron; constraint: between the ratios at one year
215
+ (about 0.0461) and at 10 Ga.
216
+
217
+ Returns
218
+ -------
219
+ numpy scalar or ndarray
220
+ Model value [s]. Scalar input returns a NumPy scalar; arrays broadcast.
221
+
222
+ Notes
223
+ -----
224
+ Inverts radiogenic_lead_ratio by Brent's method between one year and 10 Ga; same closed-system assumptions.
225
+ Equation: ``radiogenic_lead_ratio(age) = lead_ratio``.
226
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
227
+ """
228
+ lead_ratio = quantity("lead_ratio", lead_ratio)
229
+ require(lead_ratio >= radiogenic_lead_ratio(_YOUNGEST), "lead_ratio is below the ratio at one year")
230
+ require(lead_ratio <= radiogenic_lead_ratio(_OLDEST), "lead_ratio exceeds the ratio at 10 Ga")
231
+
232
+ def solve(ratio):
233
+ return brentq(lambda age: radiogenic_lead_ratio(age) - ratio, _YOUNGEST, _OLDEST, xtol=1e-6 * SECONDS_PER_YEAR, rtol=1e-14)
234
+
235
+ return np.vectorize(solve, otypes=[float])(lead_ratio)[()]
236
+
237
+
238
+ @model
239
+ def concordia_ratios(time):
240
+ """Point on the Wetherill U-Pb concordia.
241
+
242
+ Both uranium-lead clocks agree only on this curve, so a zircon off it has lost lead and its chord dates the loss.
243
+
244
+ Parameters
245
+ ----------
246
+ time : float or array_like
247
+ s; constraint: nonnegative.
248
+
249
+ Returns
250
+ -------
251
+ ndarray, shape (..., 2)
252
+ Model value [1]: ``[207Pb*/235U, 206Pb*/238U]``, the horizontal and vertical concordia coordinates.
253
+
254
+ Notes
255
+ -----
256
+ Wetherill (1956): closed system with the Jaffey et al. (1971) decay constants. Episodic lead loss puts samples on a
257
+ straight chord (discordia) between the concordia points of formation and loss.
258
+ Equation: ``[np.expm1(LAMBDA_U235 * time), np.expm1(LAMBDA_U238 * time)]``.
259
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
260
+ NumPy broadcasting applies; singular and overflowing outputs raise ValueError.
261
+ """
262
+ time = quantity("time", time, lower=0)
263
+ return np.stack([np.expm1(LAMBDA_U235 * time), np.expm1(LAMBDA_U238 * time)], axis=-1)
264
+
265
+
266
+ @model
267
+ def isochron_fit(parent_ratio, daughter_ratio, decay_constant):
268
+ """Age and initial ratio from a whole-rock or mineral isochron.
269
+
270
+ Cogenetic minerals with different parent contents line up on a straight line whose slope gives the age without knowing
271
+ the initial daughter.
272
+
273
+ Parameters
274
+ ----------
275
+ parent_ratio : array_like, shape (n,), n >= 2
276
+ 1, parent over a stable reference isotope (e.g. 87Rb/86Sr); constraint: nonnegative, not all equal.
277
+ daughter_ratio : array_like, shape (n,)
278
+ 1, daughter over the same reference isotope (e.g. 87Sr/86Sr); constraint: nonnegative.
279
+ decay_constant : float
280
+ 1/s; constraint: positive scalar.
281
+
282
+ Returns
283
+ -------
284
+ numpy.ndarray, shape (2,)
285
+ ``[age, initial_ratio]``: the age [s] from the least-squares slope and the intercept [1].
286
+
287
+ Notes
288
+ -----
289
+ Nicolaysen (1961): every sample formed at the same time with the same initial ratio and stayed closed; ordinary least
290
+ squares without measurement-error weighting (a York fit would weight them). The slope must be positive.
291
+ Equation: ``daughter_ratio = initial_ratio + parent_ratio * np.expm1(decay_constant * age)``.
292
+ Invalid, empty, nonreal or nonfinite inputs raise ValueError.
293
+ """
294
+ parent_ratio = quantity("parent_ratio", parent_ratio, lower=0)
295
+ daughter_ratio = quantity("daughter_ratio", daughter_ratio, lower=0)
296
+ decay_constant = quantity("decay_constant", decay_constant, lower=0, strict_lower=True)
297
+ require(parent_ratio.ndim == 1 and parent_ratio.size >= 2, "parent_ratio must be a vector of at least two samples")
298
+ require(daughter_ratio.shape == parent_ratio.shape, "daughter_ratio must match parent_ratio")
299
+ require(decay_constant.ndim == 0, "decay_constant must be a scalar")
300
+ anomaly = parent_ratio - parent_ratio.mean()
301
+ require(np.any(anomaly != 0), "parent_ratio must vary")
302
+ slope = np.dot(anomaly, daughter_ratio - daughter_ratio.mean()) / np.dot(anomaly, anomaly)
303
+ require(slope > 0, "isochron slope must be positive")
304
+ return np.array([np.log1p(slope) / decay_constant, daughter_ratio.mean() - slope * parent_ratio.mean()])