Skip to content

Commit de386ae

Browse files
authored
Merge pull request #24 from boriskaus/bk/zircon_growth
ZirconGrowth interaction
2 parents e0d9462 + aa4373c commit de386ae

6 files changed

Lines changed: 336 additions & 1 deletion

File tree

Project.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ TimerOutputs = "0.5"
4545
WriteVTK = "1"
4646
julia = "1.10"
4747

48+
[weakdeps]
49+
ZirconGrowth = "5085b5e1-578a-4bbf-9dfc-2862bf7461c3"
50+
51+
[extensions]
52+
ZirconGrowthExt = "ZirconGrowth"
53+
4854
[extras]
4955
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
5056
Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7"

docs/make.jl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ makedocs(;
112112
"MTK_GMG 3D Examples" => "man/example_mtk_gmg_3d.md"
113113
],
114114
"Numerics and Physics" => "man/numerics.md",
115+
"ZirconGrowth Integration" => "man/zircon_growth.md",
115116
"Ongoing Development" => "man/development.md",
116117
],
117118
"API" => Any[

docs/src/man/zircon_growth.md

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
# ZirconGrowth Integration
2+
3+
MagmaThermoKinematics has an optional integration with
4+
[ZirconGrowth.jl](https://github.com/JuliaGeodynamics/ZirconGrowth.jl), a package that
5+
simulates zircon crystal growth along a magma Tt-path using an implicit finite-difference
6+
scheme on a 1-D spherical grid. The integration is implemented as a
7+
[Julia package extension](https://pkgdocs.julialang.org/v1/creating-packages/#Conditional-loading-of-code-in-packages-(Extensions))
8+
and is loaded automatically when you do `using ZirconGrowth` in your session.
9+
10+
## Installation
11+
12+
ZirconGrowth.jl is a separate package:
13+
14+
```julia
15+
] add https://github.com/JuliaGeodynamics/ZirconGrowth.jl # while unregistered
16+
] add ZirconGrowth # once registered
17+
```
18+
19+
## Workflow
20+
21+
A typical MagmaThermoKinematics run records the temperature–time history of each passive
22+
tracer particle. After the simulation, these Tt-paths can be passed directly to the
23+
ZirconGrowth model:
24+
25+
```julia
26+
using MagmaThermoKinematics
27+
using ZirconGrowth # activates the extension
28+
29+
# Option 1 — point at an output directory
30+
age_years, zircon_radius_um = simulate_zircon_growth_from_tracers("MyRun/")
31+
32+
# Option 2 — pass tracers already loaded in memory
33+
using JLD2
34+
Tracers = JLD2.load("MyRun/Tracers_SimParams.jld2", "Tracers")
35+
age_years, zircon_radius_um = simulate_zircon_growth_from_tracers(Tracers)
36+
37+
# Option 3 — single tracer
38+
result = simulate_zircon_growth_from_tracers(Tracers[1])
39+
```
40+
41+
## Return values
42+
43+
`simulate_zircon_growth_from_tracers` returns a `NamedTuple` with one entry per
44+
successfully simulated tracer (tracers with fewer than 2 time steps are skipped):
45+
46+
| Field | Type | Description |
47+
|:--------------------|:------------------|:------------|
48+
| `age_years` | `Vector{Float64}` | Volume-averaged crystallisation age in years before the end of the simulation |
49+
| `zircon_radius_um` | `Vector{Float64}` | Final crystal radius in µm |
50+
51+
### Volume-averaged age
52+
53+
The volume-averaged age weights each concentric shell of the crystal by its volume.
54+
The shell between radii $r_i$ and $r_{i+1}$ crystallised at simulation time $t_i$ and has
55+
volume proportional to $r_{i+1}^3 - r_i^3$. Its age (years before the end of the
56+
simulation) is $t_\text{end} - \tfrac{1}{2}(t_i + t_{i+1})$. The weighted mean is:
57+
58+
$$\bar{t} = \frac{\sum_i (t_\text{end} - \bar{t}_i)\,(r_{i+1}^3 - r_i^3)}{\sum_i (r_{i+1}^3 - r_i^3)}$$
59+
60+
This can also be called directly on a `SimulationResult`:
61+
62+
```julia
63+
age = volume_averaged_age(results[1]) # single crystal → Float64
64+
ages = volume_averaged_age(results) # vector → Vector{Float64}
65+
```
66+
67+
## Keyword arguments
68+
69+
```julia
70+
simulate_zircon_growth_from_tracers(Tracers;
71+
params = nothing, # ZirconGrowth.GrowthParams (one per tracer if nothing)
72+
elements = ZirconGrowth.default_element_data(), # trace elements to track
73+
filename = nothing, # save age_years & zircon_radius_um to this JLD2 file
74+
return_results = false) # also return Vector{SimulationResult}
75+
```
76+
77+
### `return_results`
78+
79+
Set `return_results = true` to retrieve the full `ZirconGrowth.SimulationResult` objects.
80+
This uses more memory but gives access to the complete crystal profile, trace-element
81+
concentrations, growth rates, etc.:
82+
83+
```julia
84+
age_years, zircon_radius_um, results = simulate_zircon_growth_from_tracers(
85+
Tracers; return_results = true)
86+
87+
# example: inspect the radius–time history of the first crystal
88+
results[1].time_years
89+
results[1].zircon_radius_um
90+
```
91+
92+
### `filename`
93+
94+
When a `filename` is provided the summary vectors are saved to a JLD2 file:
95+
96+
```julia
97+
# explicit filename
98+
simulate_zircon_growth_from_tracers(Tracers; filename = "MyRun/ZirconGrowth.jld2")
99+
100+
# directory form saves to dirname/ZirconGrowth.jld2 automatically
101+
simulate_zircon_growth_from_tracers("MyRun/")
102+
103+
# suppress saving when using the directory form
104+
simulate_zircon_growth_from_tracers("MyRun/"; filename = nothing)
105+
```
106+
107+
## Performance
108+
109+
Each tracer is independent, so the loop is parallelised with `Threads.@threads`. Start
110+
Julia with multiple threads for a proportional speedup:
111+
112+
```bash
113+
julia --threads auto
114+
```
115+
116+
or set the environment variable permanently:
117+
118+
```bash
119+
export JULIA_NUM_THREADS=auto
120+
```
121+
122+
Check the thread count at runtime with `Threads.nthreads()`.

ext/ZirconGrowthExt.jl

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
module ZirconGrowthExt
2+
3+
using MagmaThermoKinematics
4+
using MagmaThermoKinematics: JLD2
5+
using ZirconGrowth
6+
7+
"""
8+
simulate_zircon_growth_from_tracers(Tracers;
9+
params = nothing,
10+
elements = ZirconGrowth.default_element_data(),
11+
filename = nothing,
12+
return_results = false)
13+
simulate_zircon_growth_from_tracers(tr::Tracer; kwargs...)
14+
simulate_zircon_growth_from_tracers(dirname::AbstractString; kwargs...)
15+
16+
Run the [ZirconGrowth.jl](https://github.com/JuliaGeodynamics/ZirconGrowth.jl) crystal-growth
17+
model on tracer Tt-paths recorded during a MagmaThermoKinematics simulation.
18+
19+
Three call forms are supported:
20+
- Pass a `StructArray` of `Tracer` objects directly.
21+
- Pass a single `Tracer`.
22+
- Pass a directory path; `dirname/Tracers_SimParams.jld2` is loaded automatically
23+
(saves to `dirname/ZirconGrowth.jld2` by default).
24+
25+
Tracers with fewer than 2 time steps are skipped.
26+
`Tracer.time_vec` must be in **Myr** and `Tracer.T_vec` in **°C**.
27+
The loop runs on all available Julia threads (`julia --threads auto`).
28+
29+
## Return value
30+
31+
By default returns a `NamedTuple` with two `Vector{Float64}` fields, one entry per
32+
successfully simulated tracer:
33+
34+
| Field | Description |
35+
|:--------------------|:------------|
36+
| `age_years` | Volume-averaged crystallisation age in years before the end of the simulation (see [`volume_averaged_age`](@ref)) |
37+
| `zircon_radius_um` | Final crystal radius in µm |
38+
39+
When `return_results = true` a third field `results` is included, containing a
40+
`Vector{SimulationResult}` with the full ZirconGrowth output for each tracer.
41+
42+
## Arguments
43+
- `params` : `ZirconGrowth.GrowthParams`; if `nothing` (default) a fresh instance
44+
is constructed from each tracer's Tt-path individually.
45+
- `nx` : number of 1-D spatial grid points for the ZirconGrowth solver
46+
(default 100). Ignored when `params` is provided explicitly.
47+
- `elements` : `ZirconGrowth.ElementData` selecting which trace elements are tracked;
48+
defaults to `ZirconGrowth.default_element_data()`.
49+
- `filename` : path to a JLD2 file; if provided the `age_years` and
50+
`zircon_radius_um` vectors (and `results` when requested) are saved
51+
there. Default `nothing` (no saving) for the `Tracers` form;
52+
`dirname/ZirconGrowth.jld2` for the `dirname` form.
53+
- `return_results` : set to `true` to include the full `Vector{SimulationResult}` in the
54+
return value. Default `false` to save memory.
55+
56+
## Examples
57+
58+
```julia
59+
using MagmaThermoKinematics
60+
using ZirconGrowth # triggers the extension
61+
62+
# from a directory produced by a simulation run
63+
age_years, zircon_radius_um = simulate_zircon_growth_from_tracers("MyRun/")
64+
65+
# from tracers already in memory
66+
using JLD2
67+
Tracers = JLD2.load("MyRun/Tracers_SimParams.jld2", "Tracers")
68+
age_years, zircon_radius_um = simulate_zircon_growth_from_tracers(Tracers)
69+
70+
# single tracer
71+
res = simulate_zircon_growth_from_tracers(Tracers[1])
72+
73+
# also retrieve full SimulationResult objects
74+
age_years, zircon_radius_um, results = simulate_zircon_growth_from_tracers(
75+
Tracers; return_results = true)
76+
```
77+
"""
78+
function MagmaThermoKinematics.simulate_zircon_growth_from_tracers(Tracers;
79+
params::Union{Nothing, ZirconGrowth.GrowthParams} = nothing,
80+
nx::Int = 100,
81+
elements::ZirconGrowth.ElementData = ZirconGrowth.default_element_data(),
82+
filename::Union{Nothing, AbstractString} = nothing,
83+
return_results::Bool = false)
84+
85+
n = length(Tracers)
86+
age_years = Vector{Union{Nothing, Float64}}(nothing, n)
87+
zircon_radius_um = Vector{Union{Nothing, Float64}}(nothing, n)
88+
_results = return_results ? Vector{Union{Nothing, ZirconGrowth.SimulationResult}}(nothing, n) : nothing
89+
done = Threads.Atomic{Int}(0)
90+
interval = max(1, n ÷ 20)
91+
92+
println("Simulating zircon growth for $n tracers on $(Threads.nthreads()) thread(s)...")
93+
Threads.@threads for i in eachindex(Tracers)
94+
tr = Tracers[i]
95+
isempty(tr.time_vec) && continue
96+
97+
time_Myr = Float64.(tr.time_vec) # already in Myr
98+
T_C = Float64.(tr.T_vec)
99+
100+
length(time_Myr) < 2 && continue
101+
102+
p = isnothing(params) ? ZirconGrowth.GrowthParams(time_Myr, T_C; nx=nx) : params
103+
104+
res = ZirconGrowth.simulate_from_cooling_path(time_Myr, T_C;
105+
params = p, elements = elements)
106+
107+
age_years[i] = MagmaThermoKinematics.volume_averaged_age(res)
108+
zircon_radius_um[i] = res.zircon_radius_um[end]
109+
return_results && (_results[i] = res)
110+
111+
k = Threads.atomic_add!(done, 1) + 1
112+
k % interval == 0 && print("\r $k / $n done...")
113+
end
114+
115+
age_years = Float64[v for v in age_years if !isnothing(v)]
116+
zircon_radius_um = Float64[v for v in zircon_radius_um if !isnothing(v)]
117+
println("\rDone: $(length(age_years)) / $n tracers simulated. ")
118+
119+
if !isnothing(filename)
120+
JLD2.jldsave(filename; age_years, zircon_radius_um)
121+
println("Saved ZirconGrowth results to: $filename ($(length(age_years)) tracers)")
122+
end
123+
124+
if return_results
125+
results = ZirconGrowth.SimulationResult[r for r in _results if !isnothing(r)]
126+
return (; age_years, zircon_radius_um, results)
127+
end
128+
129+
return (; age_years, zircon_radius_um)
130+
end
131+
132+
function MagmaThermoKinematics.simulate_zircon_growth_from_tracers(tr::MagmaThermoKinematics.Tracer; kwargs...)
133+
return MagmaThermoKinematics.simulate_zircon_growth_from_tracers((tr,); kwargs...)
134+
end
135+
136+
"""
137+
simulate_zircon_growth_from_tracers(dirname::AbstractString; kwargs...)
138+
139+
Load tracers from `dirname/Tracers_SimParams.jld2` and call
140+
`simulate_zircon_growth_from_tracers(Tracers; kwargs...)`.
141+
"""
142+
function MagmaThermoKinematics.simulate_zircon_growth_from_tracers(dirname::AbstractString;
143+
filename::Union{Nothing, AbstractString} = joinpath(dirname, "ZirconGrowth.jld2"),
144+
kwargs...)
145+
146+
Tracers = JLD2.load(joinpath(dirname, "Tracers_SimParams.jld2"), "Tracers")
147+
return MagmaThermoKinematics.simulate_zircon_growth_from_tracers(Tracers;
148+
filename = filename, kwargs...)
149+
end
150+
151+
"""
152+
volume_averaged_age(result::SimulationResult) -> Float64
153+
154+
Compute the volume-averaged crystallisation age (in years) of a single zircon crystal.
155+
156+
Each concentric shell crystallised at a different time. The shell between radii
157+
`r[i]` and `r[i+1]` has volume ∝ r[i+1]³ − r[i]³ and an age of
158+
`time_years[end] − time_years[i]` (measured back from the end of the simulation).
159+
Shells with zero or negative growth are excluded.
160+
161+
Returns the volume-weighted mean age in **years**.
162+
"""
163+
function MagmaThermoKinematics.volume_averaged_age(result::ZirconGrowth.SimulationResult)
164+
t = result.time_years
165+
r = result.zircon_radius_um
166+
167+
age_sum = 0.0
168+
vol_sum = 0.0
169+
t_end = t[end]
170+
171+
for i in 1:(length(r) - 1)
172+
dV = r[i+1]^3 - r[i]^3 # proportional to shell volume; 4π/3 cancels
173+
dV <= 0 && continue
174+
age_mid = t_end - 0.5*(t[i] + t[i+1]) # age of shell midpoint
175+
age_sum += age_mid * dV
176+
vol_sum += dV
177+
end
178+
179+
return vol_sum > 0 ? age_sum / vol_sum : 0.0
180+
end
181+
182+
"""
183+
volume_averaged_age(results::AbstractVector) -> Vector{Float64}
184+
185+
Apply `volume_averaged_age` to each element of `results` and return a `Vector{Float64}`.
186+
"""
187+
function MagmaThermoKinematics.volume_averaged_age(results::AbstractVector)
188+
return [MagmaThermoKinematics.volume_averaged_age(r) for r in results]
189+
end
190+
191+
end # module ZirconGrowthExt

src/MagmaThermoKinematics.jl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ export AdvectTemperature, Interpolate!, CorrectBounds, evaluate_interp_2D, evalu
230230
# Shared utility routines and MTK/GMG structs are loaded at module initialization
231231
# to avoid defining new global bindings inside environment!.
232232
include("Utils.jl")
233-
export Process_ZirconAges, copy_arrays_GPU2CPU!, copy_arrays_CPU2GPU!
233+
export Process_ZirconAges, simulate_zircon_growth_from_tracers, volume_averaged_age, copy_arrays_GPU2CPU!, copy_arrays_CPU2GPU!
234234

235235
include("MTK_GMG_structs.jl")
236236
export NumParam, DikeParam, TimeDepProps

src/Utils.jl

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@ function Process_ZirconAges(dirname; ZirconData=ZirconAgeData())
5757
return nothing
5858
end
5959

60+
"""
61+
simulate_zircon_growth_from_tracers(dirname; params, elements)
62+
63+
Requires `ZirconGrowth.jl` to be loaded. Load the extension with `using ZirconGrowth`.
64+
"""
65+
function simulate_zircon_growth_from_tracers end
66+
67+
"""
68+
volume_averaged_age(result)
69+
volume_averaged_age(results)
70+
71+
Requires `ZirconGrowth.jl` to be loaded. Load the extension with `using ZirconGrowth`.
72+
"""
73+
function volume_averaged_age end
74+
6075
function copy_arrays_GPU2CPU!(T_CPU::AbstractArray, ϕ_CPU::AbstractArray, T_GPU::AbstractArray, ϕ_GPU::AbstractArray)
6176

6277
T_CPU .= Array(T_GPU)

0 commit comments

Comments
 (0)