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,100 @@
1
+ """Physical identities, limits and dynamic checks for earthsciencekit.seismology."""
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from earthsciencekit.seismology.systems.interfaces import reflection_coefficient, transmission_coefficient
7
+ from earthsciencekit.seismology.systems.rays import (
8
+ critical_angle,
9
+ crossover_distance,
10
+ head_wave_travel_time,
11
+ ray_parameter,
12
+ travel_time,
13
+ vertical_travel_time,
14
+ )
15
+ from earthsciencekit.seismology.systems.sources import local_magnitude, moment_from_magnitude, moment_magnitude, simulate_oscillator
16
+ from earthsciencekit.seismology.systems.waves import love_wave_angular_frequency, poisson_ratio, rayleigh_wave_speed, ricker_wavelet
17
+
18
+
19
+ def test_seismic_oscillator_analytic():
20
+ frequency, Q = 1.0, 10.0
21
+ time = np.linspace(0, 2, 101)
22
+ result = simulate_oscillator(frequency, Q, time)
23
+ omega = 2 * np.pi * frequency
24
+ damping = omega / (2 * Q)
25
+ wd = np.sqrt(omega**2 - damping**2)
26
+ expected = np.exp(-damping * time) * (np.cos(wd * time) + damping / wd * np.sin(wd * time))
27
+ np.testing.assert_allclose(result.values[:, 0], expected, atol=3e-8)
28
+ energy = result.values[:, 1] ** 2 + omega**2 * result.values[:, 0] ** 2
29
+ assert np.all(np.diff(energy) <= 1e-8)
30
+ with pytest.raises(ValueError):
31
+ simulate_oscillator([1, 2], Q, time)
32
+
33
+
34
+ def test_wave_interface_energy_balance():
35
+ z1, z2 = 2.0, 6.0
36
+ r = reflection_coefficient(z1, z2)
37
+ t = transmission_coefficient(z1, z2)
38
+ assert np.isclose(r * r + (z1 / z2) * t * t, 1)
39
+ assert np.isclose(moment_magnitude(moment_from_magnitude(7)), 7)
40
+ np.testing.assert_allclose(ricker_wavelet([-1, 0, 1], 2), ricker_wavelet([1, 0, -1], 2))
41
+
42
+
43
+ def test_rayleigh_speed_solves_rayleigh_secular_equation():
44
+ vs = 1.0
45
+ vp = np.array([1.2, np.sqrt(3), 3.0, 10.0])
46
+ c = rayleigh_wave_speed(vp, vs)
47
+ assert np.all(c < vs)
48
+ np.testing.assert_allclose((2 - c**2 / vs**2) ** 2, 4 * np.sqrt(1 - c**2 / vp**2) * np.sqrt(1 - c**2 / vs**2), atol=1e-12)
49
+ # Viktorov's approximation (0.862 + 1.14 nu) / (1 + nu) holds to about 0.3% for nu >= 0.25.
50
+ nu = poisson_ratio(vp[1:], vs)
51
+ np.testing.assert_allclose(c[1:], (0.862 + 1.14 * nu) / (1 + nu), rtol=3e-3)
52
+
53
+
54
+ def test_head_wave_crosses_direct_wave_and_starts_at_reflection():
55
+ h, v1, v2 = 30e3, 6e3, 8e3
56
+ x_cross = crossover_distance(h, v1, v2)
57
+ assert np.isclose(head_wave_travel_time(x_cross, h, v1, v2), travel_time(x_cross, v1))
58
+ x_crit = 2 * h * np.tan(critical_angle(v1, v2))
59
+ assert np.isclose(head_wave_travel_time(x_crit, h, v1, v2), np.hypot(x_crit, 2 * h) / v1)
60
+ x = np.array([2e5, 3e5])
61
+ assert np.isclose(np.diff(head_wave_travel_time(x, h, v1, v2))[0], 1e5 / v2)
62
+
63
+
64
+ def test_love_dispersion_root_and_limits():
65
+ h, b1, b2, m1, m2 = 35e3, 3.5e3, 4.5e3, 3.4e10, 6.7e10
66
+ c = np.linspace(3.6e3, 4.4e3, 9)
67
+ omega = love_wave_angular_frequency(c, h, b1, b2, m1, m2)
68
+ k = omega / c
69
+ s1, s2 = np.sqrt(c**2 / b1**2 - 1), np.sqrt(1 - c**2 / b2**2)
70
+ np.testing.assert_allclose(np.tan(k * h * s1), m2 * s2 / (m1 * s1))
71
+ assert np.all(np.diff(omega) < 0)
72
+ assert love_wave_angular_frequency(b2 * (1 - 1e-12), h, b1, b2, m1, m2) < 1e-5
73
+ assert love_wave_angular_frequency(b1 * (1 + 1e-12), h, b1, b2, m1, m2) > 1e3
74
+
75
+
76
+ def test_ray_parameter_is_snell_on_a_sphere():
77
+ r, v_above, v_below = 3.48e6, 13e3, 8e3
78
+ above = np.deg2rad(50.0)
79
+ p = ray_parameter(r, v_above, above)
80
+ below = np.arcsin(p * v_below / r)
81
+ assert np.isclose(ray_parameter(r, v_below, below), p)
82
+ # A straight ray in a uniform shell keeps r sin(i) fixed, hence the same p at every radius.
83
+ impact = r * np.sin(above)
84
+ for radius in [r, 4e6, 6.371e6]:
85
+ assert np.isclose(ray_parameter(radius, v_above, np.arcsin(impact / radius)), p)
86
+
87
+
88
+ def test_local_magnitude_is_logarithmic_in_amplitude():
89
+ assert np.isclose(local_magnitude(1e-6, 1e5), 0)
90
+ np.testing.assert_allclose(np.diff(local_magnitude([1e-4, 1e-3, 1e-2], 3e5)), 1)
91
+ assert local_magnitude(1e-3, 3e5) > local_magnitude(1e-3, 1e5)
92
+
93
+
94
+ def test_vertical_travel_time_profiles():
95
+ depth = np.linspace(0, 1e5, 11)
96
+ np.testing.assert_allclose(vertical_travel_time(depth, np.full(11, 5e3)), travel_time(depth, 5e3))
97
+ v0, gradient = 5e3, 0.02
98
+ np.testing.assert_allclose(vertical_travel_time(depth, v0 + gradient * depth), np.log1p(gradient * depth / v0) / gradient)
99
+ jump = vertical_travel_time([0, 1e4, 1e4, 2e4], [5e3, 5e3, 8e3, 8e3])
100
+ np.testing.assert_allclose(jump, [0, 2, 2, 2 + 1.25])
@@ -0,0 +1,9 @@
1
+ """Plots for earthsciencekit.seismology.
2
+
3
+ The dynamic models return a TimeSeries, whose ``plot`` and ``animate`` methods
4
+ call these package-wide helpers.
5
+ """
6
+
7
+ from earthsciencekit.visualizers import animate_series, plot_series
8
+
9
+ __all__ = ["plot_series", "animate_series"]
File without changes
@@ -0,0 +1,181 @@
1
+ """Independent identities, conservation checks and failure contracts."""
2
+
3
+ from types import SimpleNamespace
4
+
5
+ import matplotlib
6
+
7
+ matplotlib.use("Agg")
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ import pytest
11
+
12
+ from earthsciencekit import __version__, atmosphere, oceanography, seismology
13
+ from earthsciencekit._validation import model, quantity, require, time_grid
14
+ from earthsciencekit.integrators import integrate_ode
15
+ from earthsciencekit.results import TimeSeries
16
+ from earthsciencekit.units import available_units, convert
17
+ from earthsciencekit.visualizers import animate_series, plot_series
18
+
19
+
20
+ def test_version():
21
+ assert __version__ == "0.2.0"
22
+
23
+
24
+ def test_bounds_and_scalar_array_contract():
25
+ np.testing.assert_array_equal(quantity("x", [0, 1], lower=0, upper=1), [0, 1])
26
+ assert quantity("x", 1).ndim == 0
27
+ for kwargs, value in [
28
+ ({"lower": 0}, -1),
29
+ ({"lower": 0, "strict_lower": True}, 0),
30
+ ({"upper": 1}, 2),
31
+ ({"upper": 1, "strict_upper": True}, 1),
32
+ ]:
33
+ with pytest.raises(ValueError):
34
+ quantity("x", value, **kwargs)
35
+ with pytest.raises(ValueError):
36
+ require([True, False], "invalid")
37
+ require([True, True], "valid")
38
+
39
+
40
+ def test_arithmetic_rejection():
41
+ with pytest.raises(ValueError, match="arithmetic"):
42
+ seismology.moment_from_magnitude(1000)
43
+
44
+ @model
45
+ def nonfinite():
46
+ return float("inf")
47
+
48
+ with pytest.raises(ValueError, match="nonfinite"):
49
+ nonfinite()
50
+
51
+
52
+ @pytest.mark.parametrize("bad", [[], [1], [0, 0], [1, 0], [-1, 1], [[0, 1]], [0, np.nan]])
53
+ def test_time_grid_bad(bad):
54
+ with pytest.raises(ValueError):
55
+ time_grid(bad)
56
+
57
+
58
+ def test_units():
59
+ assert convert(1, "km", "m") == 1000
60
+ assert convert(0, "degC", "K") == 273.15
61
+ assert convert(273.15, "K", "degC") == 0
62
+ assert convert(1, "Sv", "m^3/s") == 1e6
63
+ assert convert(1, "year", "day") == 365.25
64
+ assert convert(1, "Ga", "Ma") == 1000 and convert(1, "kyr", "year") == 1000 and convert(1, "GPa", "MPa") == 1000
65
+ np.testing.assert_allclose(convert(convert([1, 5], "cm/year", "m/s"), "m/s", "cm/year"), [1, 5])
66
+ assert convert(180, "degree", "rad") == np.pi
67
+ assert np.isclose(convert(1, "mGal", "m/s^2"), 1e-5) and np.isclose(convert(5e4, "nT", "T"), 5e-5)
68
+ units = available_units()
69
+ units.pop("K")
70
+ assert "K" in available_units()
71
+ for source, target, value in [("m", "K", 1), ("bad", "m", 1), ([], "m", 1), ("K", "K", -1)]:
72
+ with pytest.raises(ValueError):
73
+ convert(value, source, target)
74
+
75
+
76
+ def test_result_copy_summary_and_plots(tmp_path):
77
+ time = np.array([0.0, 1.0, 2.0])
78
+ values = np.array([[1.0, 2.0], [0.0, 3.0], [-1.0, 4.0]])
79
+ result = TimeSeries(time, values, ("a", "b"), ("m", "K"))
80
+ time[0] = -100
81
+ values[0, 0] = 999
82
+ assert result.time[0] == 0 and result.values[0, 0] == 1
83
+ assert not result.time.flags.writeable and not result.values.flags.writeable
84
+ assert result.summary()["a"] == {"minimum": -1.0, "maximum": 1.0, "final": -1.0, "unit": "m"}
85
+ figure, axes = result.plot()
86
+ assert len(axes) == 2 and axes[0].get_ylabel() == "a [m]"
87
+ plt.close(figure)
88
+ animation = result.animate(interval=15)
89
+ animation.save(tmp_path / "history.gif", writer="pillow")
90
+ assert (tmp_path / "history.gif").stat().st_size > 100
91
+ plt.close(animation._fig)
92
+ with pytest.raises(ValueError):
93
+ plot_series([1])
94
+ with pytest.raises(ValueError):
95
+ animate_series(result, interval=0)
96
+ with pytest.raises(ValueError):
97
+ animate_series(result, interval=[1, 2])
98
+
99
+
100
+ @pytest.mark.parametrize(
101
+ "time, values, names, units",
102
+ [
103
+ ([0], [[1]], ("x",), ("m",)),
104
+ ([0, 0], [[1], [2]], ("x",), ("m",)),
105
+ ([0, 1], [1, 2], ("x",), ("m",)),
106
+ ([0, 1], [[1]], ("x",), ("m",)),
107
+ ([0, 1], [[1], [2]], (), ()),
108
+ ([0, 1], [[1], [2]], ("",), ("m",)),
109
+ ([0, 1], [[1, 2], [3, 4]], ("x", "x"), ("m", "m")),
110
+ ],
111
+ )
112
+ def test_result_rejects_bad_shape_and_labels(time, values, names, units):
113
+ with pytest.raises(ValueError):
114
+ TimeSeries(time, values, names, units)
115
+
116
+
117
+ def test_ode_reference_and_defaults():
118
+ time = np.linspace(0, 3, 40)
119
+ result = integrate_ode(lambda t, y: -y, [2], time)
120
+ np.testing.assert_allclose(result.values[:, 0], 2 * np.exp(-time), rtol=3e-8)
121
+ assert result.names == ("x0",) and result.units == ("1",)
122
+ reference = integrate_ode(lambda t, y: [y[1], -y[0]], [1, 0], time, names=("position", "velocity"), units=("m", "m/s"), method="DOP853")
123
+ np.testing.assert_allclose(reference.values[:, 0], np.cos(time), atol=2e-8)
124
+ np.testing.assert_allclose(np.sum(reference.values**2, axis=1), 1, atol=4e-8)
125
+
126
+
127
+ @pytest.mark.parametrize(
128
+ "kwargs", [{"rtol": 0}, {"atol": np.nan}, {"atol": [1, 2]}, {"method": "BAD"}, {"names": ["a", "b"]}, {"units": [""]}]
129
+ )
130
+ def test_ode_invalid_options(kwargs):
131
+ with pytest.raises(ValueError):
132
+ integrate_ode(lambda t, y: y, [1], [0, 1], **kwargs)
133
+
134
+
135
+ def test_ode_failures(monkeypatch):
136
+ for rhs, initial, time in [
137
+ (0, [1], [0, 1]),
138
+ (lambda t, y: y, [[1]], [0, 1]),
139
+ (lambda t, y: y, [1], [0]),
140
+ (lambda t, y: y, [1], [1, 0]),
141
+ (lambda t, y: [1, 2], [1], [0, 1]),
142
+ (lambda t, y: [np.nan], [1], [0, 1]),
143
+ ]:
144
+ with pytest.raises(ValueError):
145
+ integrate_ode(rhs, initial, time)
146
+ monkeypatch.setattr("earthsciencekit.integrators.solve_ivp", lambda *a, **k: SimpleNamespace(success=False, message="test failure"))
147
+ with pytest.raises(ValueError, match="test failure"):
148
+ integrate_ode(lambda t, y: -y, [1], [0, 1])
149
+
150
+
151
+ def test_humidity_and_wave_limits():
152
+ ratio = atmosphere.mixing_ratio(1000, 100000)
153
+ humidity = atmosphere.specific_humidity(ratio)
154
+ assert np.isclose(humidity / (1 - humidity), ratio)
155
+ assert atmosphere.coriolis_parameter(-np.pi / 4) < 0 < atmosphere.coriolis_parameter(np.pi / 4)
156
+ k, h, g = 1e-6, 10.0, 9.81
157
+ omega = oceanography.wave_angular_frequency(k, h, g)
158
+ assert np.isclose(omega / k, oceanography.shallow_wave_speed(h, g), rtol=1e-9)
159
+ k, h = 1.0, 100.0
160
+ omega = oceanography.wave_angular_frequency(k, h, g)
161
+ assert np.isclose(omega / k, oceanography.deep_wave_speed(2 * np.pi / k, g))
162
+
163
+
164
+ def test_dynamic_overflow_is_normalized():
165
+ with pytest.raises(ValueError, match="arithmetic"):
166
+ seismology.simulate_oscillator(1e308, 10, [0, 1])
167
+
168
+
169
+ @pytest.mark.parametrize("name", ["plate_tectonics", "seismology", "atmosphere", "climate", "oceanography", "geophysics", "volcanology"])
170
+ def test_domain_family_layout(name):
171
+ import importlib
172
+ import inspect
173
+
174
+ domain = importlib.import_module(f"earthsciencekit.{name}")
175
+ core = importlib.import_module(f"earthsciencekit.{name}.core")
176
+ plots = importlib.import_module(f"earthsciencekit.{name}.visualizers")
177
+ assert core.TimeSeries is TimeSeries
178
+ assert plots.plot_series is plot_series and plots.animate_series is animate_series
179
+ for public in domain.__all__:
180
+ function = getattr(domain, public)
181
+ assert inspect.getmodule(function).__name__.startswith(f"earthsciencekit.{name}.systems."), public
@@ -0,0 +1,36 @@
1
+ """Cross-parameter relations that simple scalar bounds cannot establish."""
2
+
3
+ import pytest
4
+
5
+ from earthsciencekit import atmosphere as a
6
+ from earthsciencekit import climate as c
7
+ from earthsciencekit import plate_tectonics as p
8
+ from earthsciencekit import seismology as s
9
+
10
+
11
+ @pytest.mark.parametrize(
12
+ "function,args",
13
+ [
14
+ (p.plate_speed, [1, 1, 4]),
15
+ (p.subduction_travel_time, [1, 1, 0]),
16
+ (p.airy_root, [1, 3, 2]),
17
+ (p.halfspace_temperature, [1, 1, 1, 300, 200]),
18
+ (p.halfspace_heat_flux, [1, 1, 1, 300, 200]),
19
+ (p.ocean_depth, [1, 2500, 1000, 1000, 1, 1, 300, 1300]),
20
+ (p.ocean_depth, [1, 2500, 2, 1, 1, 1, 300, 200]),
21
+ (s.poisson_ratio, [1, 1]),
22
+ (s.critical_angle, [2, 1]),
23
+ (s.rayleigh_wave_speed, [1, 1]),
24
+ (s.head_wave_travel_time, [4, 1, 2, 1]),
25
+ (s.head_wave_travel_time, [1, 1, 1, 2]),
26
+ (s.crossover_distance, [1, 2, 2]),
27
+ (s.love_wave_angular_frequency, [1, 1, 1, 2, 1, 1]),
28
+ (s.love_wave_angular_frequency, [2, 1, 1, 2, 1, 1]),
29
+ (a.mixing_ratio, [1000, 100]),
30
+ (a.two_layer_temperature, [0, 288, 0.0065, 50000]),
31
+ (c.ice_albedo, [273, 0.2, 0.6, 273, 5]),
32
+ ],
33
+ )
34
+ def test_cross_parameter_rejections(function, args):
35
+ with pytest.raises(ValueError):
36
+ function(*args)
@@ -0,0 +1,66 @@
1
+ """Small explicit unit conversions; raw model arguments carry no unit metadata."""
2
+
3
+ from earthsciencekit._validation import model, quantity, require
4
+ from earthsciencekit.constants import SECONDS_PER_YEAR
5
+
6
+ # scale and offset convert each unit to its SI base; temperature offsets are exact.
7
+ _UNITS = {
8
+ "m": ("length", 1, 0),
9
+ "km": ("length", 1000, 0),
10
+ "cm": ("length", 0.01, 0),
11
+ "s": ("time", 1, 0),
12
+ "day": ("time", 86400, 0),
13
+ "year": ("time", SECONDS_PER_YEAR, 0),
14
+ "kyr": ("time", 1e3 * SECONDS_PER_YEAR, 0),
15
+ "Ma": ("time", 1e6 * SECONDS_PER_YEAR, 0),
16
+ "Ga": ("time", 1e9 * SECONDS_PER_YEAR, 0),
17
+ "Pa": ("pressure", 1, 0),
18
+ "hPa": ("pressure", 100, 0),
19
+ "MPa": ("pressure", 1e6, 0),
20
+ "GPa": ("pressure", 1e9, 0),
21
+ "K": ("temperature", 1, 0),
22
+ "degC": ("temperature", 1, 273.15),
23
+ "m/s": ("speed", 1, 0),
24
+ "cm/year": ("speed", 0.01 / SECONDS_PER_YEAR, 0),
25
+ "m/s^2": ("acceleration", 1, 0),
26
+ "mGal": ("acceleration", 1e-5, 0),
27
+ "T": ("magnetic field", 1, 0),
28
+ "nT": ("magnetic field", 1e-9, 0),
29
+ "rad": ("angle", 1, 0),
30
+ "degree": ("angle", 0.017453292519943295, 0),
31
+ "m^3/s": ("volume flow", 1, 0),
32
+ "Sv": ("volume flow", 1e6, 0),
33
+ }
34
+
35
+
36
+ @model
37
+ def convert(value, source, target):
38
+ """Convert a finite scalar or array between listed compatible units.
39
+
40
+ Parameters
41
+ ----------
42
+ value : float or array_like
43
+ Quantity in source units. Temperature conversions require nonnegative kelvin.
44
+ source, target : str
45
+ Exact case-sensitive unit labels from available_units().
46
+
47
+ Returns
48
+ -------
49
+ numpy scalar or ndarray
50
+ Quantity in target units. Unknown units or incompatible dimensions fail.
51
+ """
52
+ value = quantity("value", value)
53
+ require(isinstance(source, str) and isinstance(target, str), "unit labels must be strings")
54
+ require(source in _UNITS and target in _UNITS, "unsupported unit label")
55
+ dimension, scale, offset = _UNITS[source]
56
+ target_dimension, target_scale, target_offset = _UNITS[target]
57
+ require(dimension == target_dimension, "incompatible unit dimensions")
58
+ base = value * scale + offset
59
+ if dimension == "temperature":
60
+ require(base >= 0, "temperature is below absolute zero")
61
+ return (base - target_offset) / target_scale
62
+
63
+
64
+ def available_units():
65
+ """Return a new mapping of supported unit labels to dimension names."""
66
+ return {name: spec[0] for name, spec in _UNITS.items()}
@@ -0,0 +1,45 @@
1
+ """Labeled figures and lightweight animations for sampled histories."""
2
+
3
+ import matplotlib.pyplot as plt
4
+ import numpy as np
5
+ from matplotlib.animation import FuncAnimation
6
+
7
+ from earthsciencekit._validation import quantity, require
8
+ from earthsciencekit.results import TimeSeries
9
+
10
+ __all__ = ["plot_series", "animate_series"]
11
+
12
+
13
+ def plot_series(series):
14
+ """Plot a TimeSeries with one state and explicit unit label per axis."""
15
+ require(isinstance(series, TimeSeries), "series must be a TimeSeries")
16
+ figure, axes = plt.subplots(len(series.names), 1, squeeze=False, sharex=True)
17
+ axes = axes[:, 0]
18
+ for i, axis in enumerate(axes):
19
+ axis.plot(series.time, series.values[:, i])
20
+ axis.set_ylabel(f"{series.names[i]} [{series.units[i]}]")
21
+ axis.grid(alpha=0.2)
22
+ axes[-1].set_xlabel("time [s]")
23
+ figure.tight_layout()
24
+ return figure, axes
25
+
26
+
27
+ def animate_series(series, *, interval=40):
28
+ """Reveal a sampled history progressively; return a Matplotlib animation.
29
+
30
+ Keep the returned object alive when displaying it, or call ``save`` with an
31
+ appropriate Matplotlib writer. Interval is positive scalar milliseconds.
32
+ """
33
+ interval = quantity("interval", interval, lower=0, strict_lower=True)
34
+ require(interval.ndim == 0, "interval must be a scalar")
35
+ figure, axes = plot_series(series)
36
+ lines = [axis.lines[0] for axis in axes]
37
+ for line in lines:
38
+ line.set_data([], [])
39
+
40
+ def update(frame):
41
+ for i, line in enumerate(lines):
42
+ line.set_data(series.time[: frame + 1], series.values[: frame + 1, i])
43
+ return lines
44
+
45
+ return FuncAnimation(figure, update, frames=np.arange(series.time.size), interval=float(interval), blit=True)
@@ -0,0 +1,61 @@
1
+ """Inspectable volcanology and geochemistry models; SI units unless stated otherwise."""
2
+
3
+ from earthsciencekit.volcanology.systems.eruptions import (
4
+ column_height,
5
+ plume_rise_height,
6
+ simulate_eruption_column,
7
+ volcanic_explosivity_index,
8
+ )
9
+ from earthsciencekit.volcanology.systems.geochemistry import (
10
+ delta_value,
11
+ equilibrium_crystallization_melt,
12
+ fractional_crystallization_melt,
13
+ isotope_temperature,
14
+ lattice_strain_partition,
15
+ rayleigh_fractionation,
16
+ )
17
+ from earthsciencekit.volcanology.systems.geochronology import (
18
+ concordia_ratios,
19
+ decay_constant,
20
+ isochron_fit,
21
+ lead_lead_age,
22
+ parent_fraction,
23
+ radiocarbon_age,
24
+ radiogenic_daughter,
25
+ radiogenic_lead_ratio,
26
+ radiometric_age,
27
+ )
28
+ from earthsciencekit.volcanology.systems.magma import (
29
+ arrhenius_viscosity,
30
+ dike_ascent_velocity,
31
+ mogi_displacement,
32
+ shaw_viscosity,
33
+ water_solubility,
34
+ )
35
+
36
+ __all__ = [
37
+ "rayleigh_fractionation",
38
+ "delta_value",
39
+ "decay_constant",
40
+ "parent_fraction",
41
+ "radiometric_age",
42
+ "radiogenic_daughter",
43
+ "fractional_crystallization_melt",
44
+ "equilibrium_crystallization_melt",
45
+ "lattice_strain_partition",
46
+ "isotope_temperature",
47
+ "radiocarbon_age",
48
+ "radiogenic_lead_ratio",
49
+ "lead_lead_age",
50
+ "concordia_ratios",
51
+ "plume_rise_height",
52
+ "mogi_displacement",
53
+ "isochron_fit",
54
+ "arrhenius_viscosity",
55
+ "shaw_viscosity",
56
+ "column_height",
57
+ "dike_ascent_velocity",
58
+ "water_solubility",
59
+ "volcanic_explosivity_index",
60
+ "simulate_eruption_column",
61
+ ]
@@ -0,0 +1,5 @@
1
+ """Result containers for earthsciencekit.volcanology."""
2
+
3
+ from earthsciencekit.volcanology.core.base import TimeSeries
4
+
5
+ __all__ = ["TimeSeries"]
@@ -0,0 +1,10 @@
1
+ """Result containers for earthsciencekit.volcanology.
2
+
3
+ Algebraic models return NumPy scalars or arrays. The sampled dynamic models
4
+ return the package-wide immutable :class:`~earthsciencekit.results.TimeSeries`,
5
+ which is re-exported here as this domain's result type.
6
+ """
7
+
8
+ from earthsciencekit.results import TimeSeries
9
+
10
+ __all__ = ["TimeSeries"]
@@ -0,0 +1 @@
1
+ """Models of earthsciencekit.volcanology, grouped by topic."""