Skip to content

aragog.core

The aragog.core package carries the core evolution module: the core as a component with its own state rather than an isothermal reservoir. Radial structure uses the closed-form Gaussian profile family (Labrosse et al. 2001; Nimmo 2015), so every budget term is an analytic integral or a fixed-order quadrature and the module stays at ODE cost. All evaluation runs through jax.numpy at 64-bit precision and is jit- and grad-safe.

Name Role
GaussianCoreProfiles Density, enclosed mass, gravity (exact erf form), hydrostatic pressure (quadrature or the printed Labrosse closed form), gravitational potential, and the adiabat of the core.
IronMeltingCurve Pure-iron melting curve (Anzellini et al. 2013, the PALEOS prescription) with a multiplicative light-element depression.
QuadraticMeltingCurve The quadratic-in-pressure parameterisation of Nimmo (2015, Eq. 6), interchangeable with the iron curve; the form benchmark models are defined in.
CoreEnergyBudget Effective-heat-capacity energy balance: secular cooling, inner-core-nucleation latent heat, and the gravitational energy of light-element rejection; a legacy mode reproduces the isothermal-reservoir closure exactly.
CoreEntropyBudget Entropy balance on top of the energy budget: cooling, latent, gravitational, and radiogenic sources against the conduction sink (closed form for the Gaussian adiabat), the dynamo entropy margin, and field strength via the Christensen, Holzwarth & Reiners (2009) energy-flux scaling.
CoreModule Standalone coupling: holds the core state and advances it over an externally supplied heat-flow interval with jit-compiled Runge-Kutta sub-steps, recording the sub-step trajectory.
build_core_module_budget Config-dict factory for the solver coupling (core_bc = 'core_module'); geometry always comes from the mesh.
check_ra_crit Validate the CMB critical Rayleigh number: a positive, finite real number, returned as a float.
cmb_boundary_layer_flux CMB heat flux of the solver coupling from the core-mantle temperature contrast through a mantle-side boundary layer (Foley & Driscoll 2016; Thiriet et al. 2019), never below conduction across the bottom half cell.
crystallization_regime Regime code from the superheat profile: fully liquid, bottom-up, top-down, snow, or fully frozen (taxonomy of Breuer et al. 2015). refuse_unmodelled_regime raises for the regimes the budget does not model.
adiabatic_ratio, stratification_depth Subadiabatic-onset ratio and the equilibrium thickness of the stably stratified sub-CMB layer from conductive matching.

The budget terms are cross-validated against the open-source Leeds thermal_history implementation (secular exact, boundary terms to 0.5%), and the profile and melting machinery is pinned against Nimmo (2015) Table 2 (adiabatic length scales, ICB temperatures, melting gradients, adiabatic heat flows). The budget assumes bottom-up crystallization; parameter sets whose melting curve rises above the adiabat at the CMB (a top-down or snow topology) book no boundary terms, and the solver refuses a call that enters such a regime.

core

Core evolution module.

Replaces the isothermal-reservoir core boundary condition with a core that carries its own state: energy budget with inner-core nucleation, entropy budget and dynamo diagnostics, and stable stratification with crystallization-regime flags. The module receives the CMB heat flow and returns the CMB temperature; with every feature disabled it reproduces the isothermal-reservoir closure, which is the regression anchor.

All radial structure uses the closed-form Gaussian profile family (Labrosse et al. 2001; Nimmo 2015, Treatise on Geophysics 9.08), so every budget term is an analytic integral and the whole module stays at ODE cost.

CoreEnergyBudget(profiles, melting_curve, *, ds_fusion, icn_width, latent_heat=None, alpha_c=0.0, c_light=0.0, capacity_mode='profile', legacy_rho_core=None, legacy_tfac=None, stratification=False, k_core=None, layer=None)

Energy budget of a well-mixed core with inner-core growth.

Parameters:

Name Type Description Default
profiles GaussianCoreProfiles

Radial structure the budget integrates over.

required
melting_curve IronMeltingCurve or QuadraticMeltingCurve

Alloy melting curve; sets the freezing point profile T_m(P).

required
ds_fusion float

Entropy of fusion at the inner-core boundary [J kg-1 K-1]; sets the latent heat per unit mass as T_icb * ds_fusion.

required
icn_width float

Temperature width [K] of the nucleation diagnostic sigmoid. Positive; governs diagnostic regime indicators.

required
latent_heat float

Constant latent heat of fusion [J/kg]. When given it replaces the T_icb * ds_fusion form; parameterised models in the literature (Nimmo 2015 Table 2 uses 750 kJ/kg) prescribe the constant.

None
alpha_c float

Compositional expansivity of the outer-core alloy (fractional density decrease per unit light-element mass fraction), for the gravitational-energy term. Zero disables the term.

0.0
c_light float

Light-element mass fraction of the outer core, rejected entirely by the solid on freezing (the complete-rejection limit). Zero disables the gravitational term.

0.0
capacity_mode str

'profile' integrates the secular capacity over the Gaussian profiles; 'legacy' uses the isothermal-reservoir constants below and disables nucleation, reproducing the boundary-condition closure this module replaces.

'profile'
legacy_rho_core float

Uniform core density [kg m-3] of the legacy closure (required in legacy mode).

None
legacy_tfac float

Core-temperature factor of the legacy closure (required in legacy mode; 1.147 is the Earth-like default used across the ecosystem).

None
stratification bool

When true, the outer core above the base of a resolved shell (:class:aragog.core.layer.CoreShell, on shell) can stratify: its temperatures are state the caller evolves with :meth:core_rates, and the capacity integrals of the convecting core run to the shell base. Profile mode only.

False
k_core float

Core thermal conductivity [W m-1 K-1]; required when stratification is on.

None
layer dict

Keyword arguments of :class:aragog.core.layer.CoreShell.

None

Raises:

Type Description
ValueError

On a non-positive ds_fusion or icn_width, an unknown capacity_mode, or missing legacy constants in legacy mode.

Source code in src/aragog/core/budget.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def __init__(
    self,
    profiles: GaussianCoreProfiles,
    melting_curve: IronMeltingCurve,
    *,
    ds_fusion: float,
    icn_width: float,
    latent_heat: float | None = None,
    alpha_c: float = 0.0,
    c_light: float = 0.0,
    capacity_mode: str = 'profile',
    legacy_rho_core: float | None = None,
    legacy_tfac: float | None = None,
    stratification: bool = False,
    k_core: float | None = None,
    layer: dict | None = None,
) -> None:
    if not float(ds_fusion) > 0.0:
        raise ValueError(f'ds_fusion must be positive, got {ds_fusion}')
    if not float(icn_width) > 0.0:
        raise ValueError(f'icn_width must be positive, got {icn_width}')
    if latent_heat is not None and not float(latent_heat) > 0.0:
        raise ValueError(f'latent_heat must be positive, got {latent_heat}')
    if float(alpha_c) < 0.0 or float(c_light) < 0.0:
        raise ValueError('alpha_c and c_light must be non-negative')
    if capacity_mode not in ('profile', 'legacy'):
        raise ValueError(f'unknown capacity_mode {capacity_mode!r}')
    if capacity_mode == 'legacy' and (legacy_rho_core is None or legacy_tfac is None):
        raise ValueError('legacy mode needs legacy_rho_core and legacy_tfac')
    if stratification:
        if capacity_mode == 'legacy':
            raise ValueError(
                'stratification needs the profile capacity mode; the '
                'legacy reservoir has no volume to reduce'
            )
        if k_core is None or not float(k_core) > 0.0:
            raise ValueError(f'stratification needs a positive k_core, got {k_core}')
    self.profiles = profiles
    self.melting_curve = melting_curve
    self.ds_fusion = float(ds_fusion)
    self.icn_width = float(icn_width)
    self.latent_heat = None if latent_heat is None else float(latent_heat)
    self.alpha_c = float(alpha_c)
    self.c_light = float(c_light)
    self.capacity_mode = capacity_mode
    self.legacy_rho_core = None if legacy_rho_core is None else float(legacy_rho_core)
    self.legacy_tfac = None if legacy_tfac is None else float(legacy_tfac)
    self.stratification = bool(stratification)
    self.k_core = None if k_core is None else float(k_core)
    self.shell = None
    if stratification:
        from aragog.core.layer import CoreShell

        self.shell = CoreShell(profiles, self.k_core, **(layer or {}))
    self.r_convecting = self.shell.r_base if stratification else profiles.r_cmb

r_icb_batch cached property

r_icb vectorised and compiled, once per budget.

regime_batch cached property

crystallization_regime vectorised and compiled, once per budget.

t_freeze property

CMB temperature [K] for full core freeze-out at the CMB.

t_onset property

CMB temperature [K] for inner-core nucleation onset at the centre.

conducted_adiabatic_flow(r, t_cmb)

Heat flow [W] conducted along the adiabat through radius r (needs k_core).

Source code in src/aragog/core/budget.py
158
159
160
161
162
def conducted_adiabatic_flow(self, r, t_cmb):
    """Heat flow [W] conducted along the adiabat through radius ``r`` (needs ``k_core``)."""
    if self.k_core is None:
        raise ValueError('conducted_adiabatic_flow needs k_core (a stratified budget)')
    return _q_ad(self.profiles, self.k_core, r, t_cmb)

core_rates(t_cmb, t_shell, q_cmb, q_sources=0.0)

Rates of the convecting core [K/s] and of the shell cells [K/s] of a stratified core.

The convecting core loses the flow into the shell base and keeps the mass share of the internal sources q_sources [W] below it; the shell gets the rest, cell by cell.

Source code in src/aragog/core/budget.py
442
443
444
445
446
447
448
449
450
451
452
453
454
455
def core_rates(self, t_cmb, t_shell, q_cmb, q_sources=0.0):
    """Rates of the convecting core [K/s] and of the shell cells [K/s] of a stratified core.

    The convecting core loses the flow into the shell base and keeps the mass share of the
    internal sources ``q_sources`` [W] below it; the shell gets the rest, cell by cell.
    """
    p, shell = self.profiles, self.shell
    heating = q_sources / p.enclosed_mass(p.r_cmb)
    d_shell, q_base = shell.rates(t_shell, t_cmb, q_cmb, heating)
    q_conv = heating * p.enclosed_mass(self.r_convecting)
    capacity = self.effective_capacity(
        t_cmb, gravitational_upper=shell.layer_base(t_shell, t_cmb)
    )
    return (q_conv - q_base) / capacity, d_shell

dtcmb_dt(t_cmb, q_cmb, q_sources=0.0)

CMB cooling rate [K/s] for heat flow q_cmb [W] out of an unstratified core.

dT_cmb/dt = (q_sources - q_cmb) / C_eff(T_cmb); positive q_cmb cools the core, and internal sources (radiogenic, tidal) offset it. A stratified core has shell state: see :meth:core_rates.

Source code in src/aragog/core/budget.py
431
432
433
434
435
436
437
438
439
440
def dtcmb_dt(self, t_cmb, q_cmb, q_sources=0.0):
    """CMB cooling rate [K/s] for heat flow ``q_cmb`` [W] out of an unstratified core.

    ``dT_cmb/dt = (q_sources - q_cmb) / C_eff(T_cmb)``; positive
    ``q_cmb`` cools the core, and internal sources (radiogenic, tidal)
    offset it. A stratified core has shell state: see :meth:`core_rates`.
    """
    if self.stratification:
        raise ValueError('a stratified core evolves its shell too: use core_rates')
    return (q_sources - q_cmb) / self.effective_capacity(t_cmb)

effective_capacity(t_cmb, *, gravitational_upper=None)

Total dQ/d(dT_cmb/dt) [J/K] of the convecting core: secular plus latent plus gravitational (profile mode).

The secular integral runs to the convecting radius, the CMB or the shell base; the light elements mix up to gravitational_upper (default the same radius), the base of the stable layer when a shell is resolved.

Source code in src/aragog/core/budget.py
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
def effective_capacity(self, t_cmb, *, gravitational_upper=None):
    """Total dQ/d(dT_cmb/dt) [J/K] of the convecting core: secular plus
    latent plus gravitational (profile mode).

    The secular integral runs to the convecting radius, the CMB or the shell base; the
    light elements mix up to ``gravitational_upper`` (default the same radius), the base of
    the stable layer when a shell is resolved.
    """
    if self.capacity_mode == 'legacy':
        return self.secular_capacity()
    upper = self.r_convecting
    top = upper if gravitational_upper is None else gravitational_upper
    return (
        self.secular_capacity(upper=upper)
        + self.latent_capacity(t_cmb)
        + self.gravitational_capacity(t_cmb, upper=top)
    )

effective_tfac()

Mass-weighted mean of T_a/T_cmb over the core (profile mode).

Source code in src/aragog/core/budget.py
185
186
187
188
189
190
191
192
193
def effective_tfac(self):
    """Mass-weighted mean of ``T_a/T_cmb`` over the core (profile mode)."""
    p = self.profiles

    def mass_integrand(r):
        return p.density(r) * 4.0 * jnp.pi * r**2

    mass = self._quad_0_upper(p.r_cmb, mass_integrand)
    return self.secular_capacity() / (p.c_p * mass)

gravitational_capacity(t_cmb, upper=None)

Gravitational contribution to the effective heat capacity [J/K].

Light elements rejected by the growing inner core mix through the outer core and release gravitational energy. Per unit cooling: [int_oc rho psi dV - M_oc psi(r_icb)] * alpha_c * Cc * |dr_icb/dT_cmb| with Cc = 4 pi r_icb^2 rho(r_icb) c_light / M_oc the enrichment per unit boundary advance in the complete-rejection limit; the structure of the Leeds thermal_history core model (Greenwood et al. 2021 lineage, Gubbins et al. 2003 formalism). Zero when either compositional parameter is zero, before onset, and after freeze-out. The outer-core region runs to upper (default the CMB radius), which the stratified budget reduces to the convecting radius.

Source code in src/aragog/core/budget.py
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
def gravitational_capacity(self, t_cmb, upper=None):
    """Gravitational contribution to the effective heat capacity [J/K].

    Light elements rejected by the growing inner core mix through the
    outer core and release gravitational energy. Per unit cooling:
    ``[int_oc rho psi dV - M_oc psi(r_icb)] * alpha_c * Cc *
    |dr_icb/dT_cmb|`` with ``Cc = 4 pi r_icb^2 rho(r_icb) c_light /
    M_oc`` the enrichment per unit boundary advance in the
    complete-rejection limit; the structure of the Leeds
    ``thermal_history`` core model (Greenwood et al. 2021 lineage,
    Gubbins et al. 2003 formalism). Zero when either compositional
    parameter is zero, before onset, and after freeze-out. The
    outer-core region runs to ``upper`` (default the CMB radius),
    which the stratified budget reduces to the convecting radius.
    """
    p = self.profiles
    radius = self.r_icb(t_cmb)
    top = p.r_cmb if upper is None else upper
    # A layer below the ICB leaves no convecting shell: clamp the top at the ICB so the
    # integral closes to zero rather than flipping sign (a negative half-width would
    # negate both moments and pass the mass guard with a wrong-sign capacity).
    top = jnp.maximum(top, radius)

    def integrand(r):
        return p.density(r) * p.potential(r) * 4.0 * jnp.pi * r**2

    # Outer-core integrals on [r_icb, upper] with the shared GL panel.
    half = (top - radius) / 2.0
    centre = (top + radius) / 2.0
    s = centre[..., None] + half[..., None] * _GL_X
    rho_psi = half * jnp.sum(_GL_W * integrand(s), axis=-1)
    mass_oc = half * jnp.sum(_GL_W * p.density(s) * 4.0 * jnp.pi * s**2, axis=-1)
    potential_moment = rho_psi - mass_oc * p.potential(radius)

    enrichment = 4.0 * jnp.pi * radius**2 * p.density(radius) * self.c_light
    safe_mass = jnp.where(mass_oc > 0.0, mass_oc, 1.0)
    return (
        potential_moment
        * self.alpha_c
        * (enrichment / safe_mass)
        * self._boundary_sensitivity(t_cmb)
    )

heat_content(t_cmb)

Core heat content [J] at CMB temperature t_cmb of the full, unstratified core.

t_cmb * C_secular minus the latent and gravitational energy released between nucleation onset and t_cmb (zero above onset), so that heat_content(T2) - heat_content(T1) is the integral of the effective capacity from T1 to T2. The substitution T = T_onset - u^2 turns the square-root cusp of the latent and gravitational terms at onset, C ~ (T_onset - T)^(1/2), into a smooth integrand. Evaluated eagerly (t_onset is a Python float).

Raises:

Type Description
ValueError

For a stratified budget, whose capacity covers only the convecting volume and depends on the CMB heat flow.

Source code in src/aragog/core/budget.py
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
def heat_content(self, t_cmb) -> float:
    """Core heat content [J] at CMB temperature ``t_cmb`` of the full, unstratified core.

    ``t_cmb * C_secular`` minus the latent and gravitational energy released
    between nucleation onset and ``t_cmb`` (zero above onset), so that
    ``heat_content(T2) - heat_content(T1)`` is the integral of the effective
    capacity from ``T1`` to ``T2``. The substitution ``T = T_onset - u^2``
    turns the square-root cusp of the latent and gravitational terms at onset,
    ``C ~ (T_onset - T)^(1/2)``, into a smooth integrand.
    Evaluated eagerly (``t_onset`` is a Python float).

    Raises
    ------
    ValueError
        For a stratified budget, whose capacity covers only the convecting
        volume and depends on the CMB heat flow.
    """
    if self.stratification:
        raise ValueError(
            'heat_content is the full-core content; a stratified budget evolves '
            'only its convecting volume'
        )
    secular = float(t_cmb) * float(self.secular_capacity())
    if self.capacity_mode == 'legacy' or float(t_cmb) >= self.t_onset:
        return secular
    u_max = float(_np.sqrt(self.t_onset - float(t_cmb)))
    boundary = jax.vmap(lambda t: self.latent_capacity(t) + self.gravitational_capacity(t))
    # One Gauss-Legendre panel per side of the freeze-out jump in the latent term.
    u_freeze = float(_np.sqrt(max(self.t_onset - self.t_freeze, 0.0)))
    edges = [0.0, u_freeze, u_max] if 0.0 < u_freeze < u_max else [0.0, u_max]
    released = 0.0
    for lo, hi in zip(edges[:-1], edges[1:]):
        u = lo + 0.5 * (hi - lo) * (_GL_X + 1.0)
        values = boundary(self.t_onset - u**2) * 2.0 * u
        released += 0.5 * (hi - lo) * float(jnp.sum(_GL_W * values))
    return secular - released

latent_capacity(t_cmb)

Latent contribution to the effective heat capacity [J/K].

L rho(r_icb) 4 pi r_icb^2 |dr_icb/dT_cmb| with the latent heat per unit mass L either the prescribed constant or T_icb * ds_fusion. Zero before nucleation onset and after freeze-out completion when the boundary is pinned.

Source code in src/aragog/core/budget.py
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
def latent_capacity(self, t_cmb):
    """Latent contribution to the effective heat capacity [J/K].

    ``L rho(r_icb) 4 pi r_icb^2 |dr_icb/dT_cmb|`` with the latent heat
    per unit mass ``L`` either the prescribed constant or
    ``T_icb * ds_fusion``. Zero before nucleation onset and after
    freeze-out completion when the boundary is pinned.
    """
    p = self.profiles
    radius = self.r_icb(t_cmb)
    if self.latent_heat is not None:
        heat = self.latent_heat
    else:
        heat = p.adiabat(radius, t_cmb) * self.ds_fusion
    area_mass = p.density(radius) * 4.0 * jnp.pi * radius**2
    return heat * area_mass * self._boundary_sensitivity(t_cmb)

nucleation_factor(t_cmb)

Diagnostic activation in [0, 1]: sigmoid of centre subcooling.

Source code in src/aragog/core/budget.py
290
291
292
def nucleation_factor(self, t_cmb):
    """Diagnostic activation in [0, 1]: sigmoid of centre subcooling."""
    return jax.nn.sigmoid(-self.superheat(0.0, t_cmb) / self.icn_width)

r_icb(t_cmb)

Inner-core boundary radius [m] at t_cmb.

Fixed-iteration bisection of the superheat on [0, r_cmb]; with no crossing it converges to 0 (fully liquid) or r_cmb (fully frozen), so the value is always defined and trace-safe. A liquid centre returns zero regardless of frozen shells above it (the top-down and snow topologies have no inner core in the bottom-up sense); the boundary terms drop to zero there, and the solver refuses those states (aragog.core.regime.refuse_unmodelled_regime).

The derivative is the implicit-function sensitivity attached as a custom JVP: a comparison-driven bisection carries no gradient of its own (autodiff sees only the converged constant trace), so without this rule anything differentiating through the boundary would silently read zero.

Source code in src/aragog/core/budget.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
def r_icb(self, t_cmb):
    """Inner-core boundary radius [m] at ``t_cmb``.

    Fixed-iteration bisection of the superheat on [0, r_cmb]; with no
    crossing it converges to 0 (fully liquid) or r_cmb (fully frozen),
    so the value is always defined and trace-safe. A liquid centre
    returns zero regardless of frozen shells above it (the top-down
    and snow topologies have no inner core in the bottom-up sense);
    the boundary terms drop to zero there, and the solver refuses those
    states (``aragog.core.regime.refuse_unmodelled_regime``).

    The derivative is the implicit-function sensitivity attached as a
    custom JVP: a comparison-driven bisection carries no gradient of
    its own (autodiff sees only the converged constant trace), so
    without this rule anything differentiating through the boundary
    would silently read zero.
    """
    return self._r_icb_root(t_cmb)

secular_capacity(upper=None)

Secular heat capacity dQ_s / d(dT_cmb/dt) [J/K].

Profile mode: c_p int rho(r) f(r) 4 pi r^2 dr over the convecting volume (upper defaults to the CMB radius), with f the adiabat shape T_a(r)/T_cmb, i.e. c_p M tfac_eff. Legacy mode: (4/3) pi r_cmb^3 rho c_p tfac, the reservoir constant.

Source code in src/aragog/core/budget.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def secular_capacity(self, upper=None):
    """Secular heat capacity dQ_s / d(dT_cmb/dt) [J/K].

    Profile mode: ``c_p int rho(r) f(r) 4 pi r^2 dr`` over the
    convecting volume (``upper`` defaults to the CMB radius), with
    ``f`` the adiabat shape ``T_a(r)/T_cmb``, i.e. ``c_p M
    tfac_eff``. Legacy mode: ``(4/3) pi r_cmb^3 rho c_p tfac``, the
    reservoir constant.
    """
    p = self.profiles
    if self.capacity_mode == 'legacy':
        volume = 4.0 / 3.0 * jnp.pi * p.r_cmb**3
        return volume * self.legacy_rho_core * p.c_p * self.legacy_tfac

    def integrand(r):
        shape = p.adiabat(r, 1.0)  # T_a / T_cmb: anchor-independent
        return p.density(r) * shape * 4.0 * jnp.pi * r**2

    top = p.r_cmb if upper is None else upper
    return p.c_p * self._quad_0_upper(top, integrand)

superheat(r, t_cmb)

Adiabat minus melting curve [K] at radius r; positive = liquid.

Source code in src/aragog/core/budget.py
210
211
212
213
def superheat(self, r, t_cmb):
    """Adiabat minus melting curve [K] at radius ``r``; positive = liquid."""
    p = self.profiles
    return p.adiabat(r, t_cmb) - self.melting_curve.t_melt(p.pressure(r))

CoreEntropyBudget(budget, *, k_core, f_ohm=1.0, flux_geometry='const_flux')

Entropy balance and dynamo diagnostics on top of the energy budget.

Parameters:

Name Type Description Default
budget CoreEnergyBudget

The energy budget whose profiles, melting curve, and capacity terms this balance reuses; entropy efficiencies attach to its terms.

required
k_core float

Thermal conductivity of the core [W m-1 K-1], treated as constant.

required
f_ohm float

Fraction of dissipation that is ohmic, in (0, 1]; CHR09 adopt 1 for planets.

1.0
flux_geometry str

'const_flux' or 'zero_outer': which of the two printed Earth-core efficiency factors converts thermodynamic properties into the CHR09 F.

'const_flux'

Raises:

Type Description
ValueError

On non-positive k_core, f_ohm outside (0, 1], or an unknown flux_geometry.

Source code in src/aragog/core/entropy.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def __init__(
    self,
    budget: CoreEnergyBudget,
    *,
    k_core: float,
    f_ohm: float = 1.0,
    flux_geometry: str = 'const_flux',
) -> None:
    if not float(k_core) > 0.0:
        raise ValueError(f'k_core must be positive, got {k_core}')
    if not 0.0 < float(f_ohm) <= 1.0:
        raise ValueError(f'f_ohm must be in (0, 1], got {f_ohm}')
    if flux_geometry not in _CHR09_F_GEOMETRY:
        raise ValueError(f'unknown flux_geometry {flux_geometry!r}')
    if (
        getattr(budget, 'stratification', False)
        and budget.k_core is not None
        and float(budget.k_core) != float(k_core)
    ):
        raise ValueError(
            'one core, one conductivity: the stratified budget carries '
            f'k_core={budget.k_core} but the entropy budget was given '
            f'{k_core}; the layer depth and the conduction sink would '
            'disagree about the same physical quantity'
        )
    self.budget = budget
    self.k_core = float(k_core)
    self.f_ohm = float(f_ohm)
    self.flux_geometry = flux_geometry

adiabatic_heat_flow(t_cmb)

Heat conducted down the adiabat at the CMB, Qk [W].

Source code in src/aragog/core/entropy.py
217
218
219
220
def adiabatic_heat_flow(self, t_cmb):
    """Heat conducted down the adiabat at the CMB, ``Qk`` [W]."""
    p = self.budget.profiles
    return -4.0 * jnp.pi * p.r_cmb**2 * self.k_core * p.adiabat_gradient(p.r_cmb, t_cmb)

b_dipole_cmb(t_cmb, q_cmb, dipolarity=1.0)

Dipole field at the CMB [T]: the core rms field times a dipolarity fraction; a potential-field continuation to the planet surface is the caller's (r_cmb / r_planet)^3 factor.

Source code in src/aragog/core/entropy.py
248
249
250
251
252
def b_dipole_cmb(self, t_cmb, q_cmb, dipolarity: float = 1.0):
    """Dipole field at the CMB [T]: the core rms field times a
    dipolarity fraction; a potential-field continuation to the planet
    surface is the caller's ``(r_cmb / r_planet)^3`` factor."""
    return dipolarity * self.b_rms_core(t_cmb, q_cmb)

b_rms_core(t_cmb, q_cmb)

Volume-averaged core field strength [T] from CHR09 Eq. 2.

The reference flux is the superadiabatic part of the CMB heat flow over the CMB area; with nothing superadiabatic the field estimate is zero even where compositional convection keeps the entropy margin positive (a conservative floor, see the module docstring).

Source code in src/aragog/core/entropy.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
def b_rms_core(self, t_cmb, q_cmb):
    """Volume-averaged core field strength [T] from CHR09 Eq. 2.

    The reference flux is the superadiabatic part of the CMB heat flow
    over the CMB area; with nothing superadiabatic the field estimate
    is zero even where compositional convection keeps the entropy
    margin positive (a conservative floor, see the module docstring).
    """
    p = self.budget.profiles
    area = 4.0 * jnp.pi * p.r_cmb**2
    q_o = jnp.maximum(q_cmb - self.adiabatic_heat_flow(t_cmb), 0.0) / area
    mean_rho = p.enclosed_mass(p.r_cmb) / (4.0 / 3.0 * jnp.pi * p.r_cmb**3)
    energy_density = (
        _CHR09_C
        * self.f_ohm
        * mean_rho ** (1.0 / 3.0)
        * (self.chr09_efficiency_factor() * q_o) ** (2.0 / 3.0)
    )
    return jnp.sqrt(2.0 * MU0 * energy_density)

chr09_efficiency_factor()

The printed Earth-core F: 0.88 (or 0.45) alpha g r / c_p.

Source code in src/aragog/core/entropy.py
222
223
224
225
226
def chr09_efficiency_factor(self):
    """The printed Earth-core ``F``: ``0.88 (or 0.45) alpha g r / c_p``."""
    p = self.budget.profiles
    g_cmb = p.gravity(p.r_cmb)
    return _CHR09_F_GEOMETRY[self.flux_geometry] * p.alpha * g_cmb * p.r_cmb / p.c_p

conduction_sink(upper=None)

Entropy sink of conduction along the adiabat, Ek [W/K].

4 pi k int (dTa/dr / Ta)^2 r^2 dr over the convecting volume (upper defaults to the CMB radius), independent of T_cmb: by quadrature, or in the small-radius mode, where the ratio is 2 r / D^2, as 16 pi k upper^5 / (5 D^4).

Source code in src/aragog/core/entropy.py
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
def conduction_sink(self, upper=None):
    """Entropy sink of conduction along the adiabat, ``Ek`` [W/K].

    ``4 pi k int (dTa/dr / Ta)^2 r^2 dr`` over the convecting volume (``upper``
    defaults to the CMB radius), independent of ``T_cmb``: by quadrature, or in the
    small-radius mode, where the ratio is ``2 r / D^2``, as ``16 pi k upper^5 / (5 D^4)``.
    """
    p = self.budget.profiles
    top = p.r_cmb if upper is None else upper
    if p.adiabat_mode == 'small_radius':
        return 16.0 * jnp.pi * self.k_core * top**5 / (5.0 * p.d_scale**4)

    def integrand(r):  # d ln T / dr = -alpha g / c_p
        return (p.alpha * p.gravity(r) / p.c_p) ** 2 * r**2

    return 4.0 * jnp.pi * self.k_core * self.budget._quad_0_upper(top, integrand)

entropy_margin(t_cmb, q_cmb, q_radio=0.0, *, t_shell=None)

Entropy production available to the dynamo, dE [W/K].

The cooling rate follows from the energy budget for the given heat flow, the three cooling-proportional sources scale with it, and conduction subtracts. Positive margin sustains a dynamo. A stratified core needs its shell temperatures t_shell: the convecting core and the shell each add their sources and sinks, all delivered at the temperature of the top shell cell (Greenwood et al. 2021, eq. 4).

Source code in src/aragog/core/entropy.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
def entropy_margin(self, t_cmb, q_cmb, q_radio=0.0, *, t_shell=None):
    """Entropy production available to the dynamo, ``dE`` [W/K].

    The cooling rate follows from the energy budget for the given heat
    flow, the three cooling-proportional sources scale with it, and
    conduction subtracts. Positive margin sustains a dynamo. A stratified
    core needs its shell temperatures ``t_shell``: the convecting core and the shell
    each add their sources and sinks, all delivered at the temperature of the top shell
    cell (Greenwood et al. 2021, eq. 4).
    """
    b = self.budget
    if not b.stratification:
        cooling = -b.dtcmb_dt(t_cmb, q_cmb, q_sources=q_radio)
        capacity = (
            self.secular_entropy_capacity(t_cmb)
            + self.latent_entropy_capacity(t_cmb)
            + self.gravitational_entropy_capacity(t_cmb)
        )
        return (
            capacity * cooling
            + self.radiogenic_entropy(t_cmb, q_radio)
            - self.conduction_sink()
        )
    shell, upper = b.shell, b.r_convecting
    d_core, d_shell = b.core_rates(t_cmb, t_shell, q_cmb, q_radio)
    t_top = shell.top_temperature(t_shell)
    capacity = (
        self.secular_entropy_capacity(t_cmb, upper=upper, t_ref=t_top)
        + self.latent_entropy_capacity(t_cmb, t_ref=t_top)
        + self.gravitational_entropy_capacity(
            t_cmb, upper=shell.layer_base(t_shell, t_cmb), t_ref=t_top
        )
    )
    heating = q_radio / b.profiles.enclosed_mass(b.profiles.r_cmb)
    return (
        -capacity * d_core
        + self.radiogenic_entropy(t_cmb, q_radio, upper=upper, t_ref=t_top)
        - self.conduction_sink(upper=upper)
        + shell.entropy_rate(t_shell, d_shell, t_cmb, heating, t_top)
    )

gravitational_entropy_capacity(t_cmb, upper=None, *, t_ref=None)

Entropy per unit cooling from gravitational energy [J/K^2].

Gravitational energy dissipates in full within the convecting core: C_grav / T_ref (default T_cmb).

Source code in src/aragog/core/entropy.py
145
146
147
148
149
150
151
152
def gravitational_entropy_capacity(self, t_cmb, upper=None, *, t_ref=None):
    """Entropy per unit cooling from gravitational energy [J/K^2].

    Gravitational energy dissipates in full within the convecting
    core: ``C_grav / T_ref`` (default ``T_cmb``).
    """
    ref = t_cmb if t_ref is None else t_ref
    return self.budget.gravitational_capacity(t_cmb, upper=upper) / ref

latent_entropy_capacity(t_cmb, *, t_ref=None)

Entropy per unit cooling from latent heat [J/K^2].

The latent capacity released at T_icb and delivered at T_ref (default T_cmb): C_lat (T_icb - T_ref) / (T_icb T_ref).

Source code in src/aragog/core/entropy.py
133
134
135
136
137
138
139
140
141
142
143
def latent_entropy_capacity(self, t_cmb, *, t_ref=None):
    """Entropy per unit cooling from latent heat [J/K^2].

    The latent capacity released at ``T_icb`` and delivered at
    ``T_ref`` (default ``T_cmb``): ``C_lat (T_icb - T_ref) / (T_icb T_ref)``.
    """
    b = self.budget
    radius = b.r_icb(t_cmb)
    t_icb = b.profiles.adiabat(radius, t_cmb)
    ref = t_cmb if t_ref is None else t_ref
    return b.latent_capacity(t_cmb) * (t_icb - ref) / (t_icb * ref)

radiogenic_entropy(t_cmb, q_radio, upper=None, *, t_ref=None)

Entropy rate from internal heating [W/K].

int h rho (1/T_ref - 1/Ta) dV with the heating rate per unit mass h = q_radio / M_core uniform, integrated over the convecting volume (upper defaults to the CMB radius, T_ref to T_cmb).

Source code in src/aragog/core/entropy.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def radiogenic_entropy(self, t_cmb, q_radio, upper=None, *, t_ref=None):
    """Entropy rate from internal heating [W/K].

    ``int h rho (1/T_ref - 1/Ta) dV`` with the heating rate per unit
    mass ``h = q_radio / M_core`` uniform, integrated over the
    convecting volume (``upper`` defaults to the CMB radius, ``T_ref`` to ``T_cmb``).
    """
    p = self.budget.profiles
    mass = p.enclosed_mass(p.r_cmb)
    ref = t_cmb if t_ref is None else t_ref

    def integrand(r):
        inv_gap = 1.0 / ref - 1.0 / p.adiabat(r, t_cmb)
        return p.density(r) * inv_gap * 4.0 * jnp.pi * r**2

    top = p.r_cmb if upper is None else upper
    return (q_radio / mass) * self.budget._quad_0_upper(top, integrand)

secular_entropy_capacity(t_cmb, upper=None, *, t_ref=None)

Entropy per unit cooling from secular cooling [J/K^2].

int rho c_p (Ta/T_ref - 1) dV / T_cmb: heat extracted at temperature Ta and delivered at T_ref (default T_cmb) produces entropy in proportion to the temperature drop. Integrated over the convecting volume (upper defaults to the CMB radius).

Source code in src/aragog/core/entropy.py
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
def secular_entropy_capacity(self, t_cmb, upper=None, *, t_ref=None):
    """Entropy per unit cooling from secular cooling [J/K^2].

    ``int rho c_p (Ta/T_ref - 1) dV / T_cmb``: heat extracted at
    temperature ``Ta`` and delivered at ``T_ref`` (default ``T_cmb``) produces entropy in
    proportion to the temperature drop. Integrated over the
    convecting volume (``upper`` defaults to the CMB radius).
    """
    p = self.budget.profiles
    scale = 1.0 if t_ref is None else t_cmb / t_ref

    def integrand(r):
        shape = p.adiabat(r, 1.0)
        return p.density(r) * p.c_p * (scale * shape - 1.0) * 4.0 * jnp.pi * r**2

    top = p.r_cmb if upper is None else upper
    return self.budget._quad_0_upper(top, integrand) / t_cmb

CoreModule(budget, *, t_cmb, entropy=None, q_radio=0.0, n_substeps=32)

Stateful core evolving under an externally supplied CMB heat flow.

The core is one temperature, so a stratified budget, whose shell is state, is refused; the solver evolves it.

Parameters:

Name Type Description Default
budget CoreEnergyBudget

The energy budget that supplies the cooling rate.

required
t_cmb float

Initial CMB temperature [K], positive.

required
entropy CoreEntropyBudget

When given, diagnostics include the entropy margin and field strength alongside the energy-side quantities.

None
q_radio float

Internal source power [W] passed to every cooling-rate call.

0.0
n_substeps int

Fixed classical Runge-Kutta sub-steps per :meth:step call; positive. The trajectory arrays carry n_substeps + 1 samples.

32
Source code in src/aragog/core/module.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
def __init__(
    self,
    budget: CoreEnergyBudget,
    *,
    t_cmb: float,
    entropy: CoreEntropyBudget | None = None,
    q_radio: float = 0.0,
    n_substeps: int = 32,
) -> None:
    if not float(t_cmb) > 0.0:
        raise ValueError(f't_cmb must be positive, got {t_cmb}')
    if int(n_substeps) < 1:
        raise ValueError(f'n_substeps must be at least 1, got {n_substeps}')
    if budget.stratification:
        raise ValueError(
            'CoreModule holds one core temperature; a stratified core needs the solver'
        )
    self.budget = budget
    self.entropy = entropy
    self.t_cmb = float(t_cmb)
    self.q_radio = float(q_radio)
    self.n_substeps = int(n_substeps)
    self.last_times: jnp.ndarray | None = None
    self.last_t_cmb: jnp.ndarray | None = None
    self.last_q_cmb: jnp.ndarray | None = None
    # One compiled RHS per instance (RK4 calls it hundreds of times per step); q_radio
    # is a traced argument, since jit freezes closed-over values at the first trace
    # and a later change of the attribute would keep the old power.
    self._rhs = jax.jit(
        lambda temp, q, q_radio: self.budget.dtcmb_dt(temp, q, q_sources=q_radio)
    )

diagnostics(q_cmb=None)

Current core state as plain floats.

Always contains t_cmb, t_cen, r_icb, nucleation_factor, effective_capacity, and the phase flags inner_core_present / fully_frozen. With an entropy budget attached and q_cmb given, adds entropy_margin, dynamo_active, and b_rms_core.

Source code in src/aragog/core/module.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
def diagnostics(self, q_cmb: float | None = None) -> dict:
    """Current core state as plain floats.

    Always contains ``t_cmb``, ``t_cen``, ``r_icb``,
    ``nucleation_factor``, ``effective_capacity``, and the phase flags
    ``inner_core_present`` / ``fully_frozen``. With an entropy budget
    attached and ``q_cmb`` given, adds ``entropy_margin``,
    ``dynamo_active``, and ``b_rms_core``.
    """
    b = self.budget
    t = self.t_cmb
    radius = float(b.r_icb(t))
    out = {
        't_cmb': t,
        't_cen': float(b.profiles.t_cen(t)),
        'r_icb': radius,
        'nucleation_factor': float(b.nucleation_factor(t)),
        'effective_capacity': float(b.effective_capacity(t)),
        'inner_core_present': bool(radius > 0.0),
        'fully_frozen': bool(~b._liquid_remains(t)),
        'regime': int(crystallization_regime(b, t)),
    }
    if self.entropy is not None and q_cmb is not None:
        margin = float(self.entropy.entropy_margin(t, q_cmb, self.q_radio))
        out['entropy_margin'] = margin
        out['dynamo_active'] = bool(margin > 0.0)
        out['b_rms_core'] = float(self.entropy.b_rms_core(t, q_cmb))
    return out

step(q_cmb, dt, q_cmb_end=None)

Advance T_cmb over dt seconds of heat flow q_cmb [W].

The heat flow ramps linearly from q_cmb to q_cmb_end (equal to q_cmb when omitted) across the interval, and the state advances with n_substeps classical RK4 sub-steps. The sub-step trajectory is stored on last_times / last_t_cmb / last_q_cmb so the caller can see through the coupling interval instead of only its endpoints. Returns the new T_cmb [K].

Raises:

Type Description
ValueError

If dt is not positive.

Source code in src/aragog/core/module.py
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
def step(self, q_cmb: float, dt: float, q_cmb_end: float | None = None) -> float:
    """Advance ``T_cmb`` over ``dt`` seconds of heat flow ``q_cmb`` [W].

    The heat flow ramps linearly from ``q_cmb`` to ``q_cmb_end`` (equal
    to ``q_cmb`` when omitted) across the interval, and the state
    advances with ``n_substeps`` classical RK4 sub-steps. The sub-step
    trajectory is stored on ``last_times`` / ``last_t_cmb`` /
    ``last_q_cmb`` so the caller can see through the coupling interval
    instead of only its endpoints. Returns the new ``T_cmb`` [K].

    Raises
    ------
    ValueError
        If ``dt`` is not positive.
    """
    if not float(dt) > 0.0:
        raise ValueError(f'dt must be positive, got {dt}')
    q_end = float(q_cmb) if q_cmb_end is None else float(q_cmb_end)
    h = float(dt) / self.n_substeps

    def q_at(t: float) -> float:
        return float(q_cmb) + (q_end - float(q_cmb)) * (t / float(dt))

    def rhs(t: float, temp):
        return self._rhs(temp, q_at(t), self.q_radio)

    times = [0.0]
    temps = [self.t_cmb]
    temp = jnp.asarray(self.t_cmb)
    t = 0.0
    for _ in range(self.n_substeps):
        k1 = rhs(t, temp)
        k2 = rhs(t + h / 2.0, temp + h / 2.0 * k1)
        k3 = rhs(t + h / 2.0, temp + h / 2.0 * k2)
        k4 = rhs(t + h, temp + h * k3)
        temp = temp + h / 6.0 * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
        t += h
        times.append(t)
        temps.append(float(temp))
    self.last_times = jnp.asarray(times)
    self.last_t_cmb = jnp.asarray(temps)
    self.last_q_cmb = jnp.asarray([q_at(x) for x in times])
    self.t_cmb = float(temp)
    return self.t_cmb

GaussianCoreProfiles(*, rho_cen, length_scale, r_cmb, p_cmb, alpha, c_p, pressure_mode='quadrature', adiabat_mode='exact')

Radial density, mass, gravity, pressure, and adiabat of the core.

Parameters:

Name Type Description Default
rho_cen float

Density at the planet centre [kg m-3].

required
length_scale float

Gaussian density length scale L [m]; sets the compressibility of the profile via rho(r_cmb) = rho_cen * exp(-r_cmb^2/L^2).

required
r_cmb float

Core-mantle boundary radius [m].

required
p_cmb float

Pressure at the CMB [Pa]; anchors the hydrostatic integration.

required
alpha float

Thermal expansion coefficient [K-1], treated as constant.

required
c_p float

Isobaric specific heat capacity [J kg-1 K-1], treated as constant.

required
pressure_mode str

'quadrature' integrates hydrostatic balance against the exact erf gravity; 'labrosse' evaluates the printed closed form (Labrosse et al. 2001; Nimmo 2015, ch. 9.08, Eq. 3), whose derivation uses the small-radius gravity expansion. The two differ by a few tenths of a percent in the deep core (0.3% at the Earth centre), enough to move a tangent melting-curve crossing by hundreds of kilometres when reproducing models built on the printed form.

'quadrature'
adiabat_mode str

'exact' integrates d ln T / dr = -alpha g / c_p with the exact erf gravity; 'small_radius' takes the closed form of linear gravity, which raises T_cen / T_cmb by 2 % for the Earth's core and by more for larger cores.

'exact'

Raises:

Type Description
ValueError

If any parameter is non-positive, or length_scale does not exceed zero compression at the CMB (r_cmb >= 3 L would put the whole core in the far Gaussian tail, outside the family's regime).

Source code in src/aragog/core/profiles.py
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
def __init__(
    self,
    *,
    rho_cen: float,
    length_scale: float,
    r_cmb: float,
    p_cmb: float,
    alpha: float,
    c_p: float,
    pressure_mode: str = 'quadrature',
    adiabat_mode: str = 'exact',
) -> None:
    if pressure_mode not in ('quadrature', 'labrosse'):
        raise ValueError(f'unknown pressure_mode {pressure_mode!r}')
    if adiabat_mode not in ('exact', 'small_radius'):
        raise ValueError(f'unknown adiabat_mode {adiabat_mode!r}')
    self.pressure_mode = pressure_mode
    self.adiabat_mode = adiabat_mode
    params = {
        'rho_cen': rho_cen,
        'length_scale': length_scale,
        'r_cmb': r_cmb,
        'p_cmb': p_cmb,
        'alpha': alpha,
        'c_p': c_p,
    }
    for name, value in params.items():
        if not float(value) > 0.0:
            raise ValueError(f'{name} must be positive, got {value}')
    if r_cmb >= 3.0 * length_scale:
        raise ValueError(
            f'r_cmb={r_cmb} lies beyond 3 length scales ({length_scale}); '
            'the Gaussian family is not a credible core profile there'
        )
    self.rho_cen = float(rho_cen)
    self.length_scale = float(length_scale)
    self.r_cmb = float(r_cmb)
    self.p_cmb = float(p_cmb)
    self.alpha = float(alpha)
    self.c_p = float(c_p)
    # Adiabatic length scale from the small-r identity
    # d ln T / dr = -alpha g / c_p with g = (4 pi / 3) G rho_cen r.
    self.d_scale = float(jnp.sqrt(3.0 * c_p / (2.0 * jnp.pi * alpha * self.rho_cen * G)))

r_peak cached property

Radius [m] where the heat conducted down the adiabat, r^2 g T, peaks; r_cmb when it rises through the whole core. D sqrt(3/2) for linear gravity.

adiabat(r, t_cmb)

Adiabatic temperature [K] at radius r anchored at t_cmb; see adiabat_mode.

Hotter inward, equal to t_cmb at the CMB by construction.

Source code in src/aragog/core/profiles.py
219
220
221
222
223
224
225
226
227
def adiabat(self, r, t_cmb):
    """Adiabatic temperature [K] at radius ``r`` anchored at ``t_cmb``; see ``adiabat_mode``.

    Hotter inward, equal to ``t_cmb`` at the CMB by construction.
    """
    r = jnp.asarray(r)
    if self.adiabat_mode == 'exact':
        return t_cmb * jnp.exp(-self.alpha * self.potential(r) / self.c_p)
    return t_cmb * jnp.exp((self.r_cmb**2 - r**2) / self.d_scale**2)

adiabat_gradient(r, t_cmb)

dT/dr [K/m] of the adiabat, -alpha g T / c_p, with the gravity of the mode.

Source code in src/aragog/core/profiles.py
229
230
231
232
233
234
235
236
237
def adiabat_gradient(self, r, t_cmb):
    """``dT/dr`` [K/m] of the adiabat, ``-alpha g T / c_p``, with the gravity of the mode."""
    r = jnp.asarray(r)
    g = (
        self.gravity(r)
        if self.adiabat_mode == 'exact'
        else 4.0 * jnp.pi / 3.0 * G * self.rho_cen * r
    )
    return -self.alpha * g * self.adiabat(r, t_cmb) / self.c_p

density(r)

Density [kg m-3] at radius r [m].

Source code in src/aragog/core/profiles.py
133
134
135
136
def density(self, r):
    """Density [kg m-3] at radius ``r`` [m]."""
    r = jnp.asarray(r)
    return self.rho_cen * jnp.exp(-((r / self.length_scale) ** 2))

enclosed_mass(r)

Mass [kg] inside radius r: the closed-form Gaussian integral.

4 pi rho_cen [ (sqrt(pi) L^3 / 4) erf(r/L) - (L^2 r / 2) exp(-r^2/L^2) ], the exact antiderivative of 4 pi rho(s) s^2.

Source code in src/aragog/core/profiles.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
def enclosed_mass(self, r):
    """Mass [kg] inside radius ``r``: the closed-form Gaussian integral.

    ``4 pi rho_cen [ (sqrt(pi) L^3 / 4) erf(r/L) - (L^2 r / 2)
    exp(-r^2/L^2) ]``, the exact antiderivative of
    ``4 pi rho(s) s^2``.
    """
    r = jnp.asarray(r)
    length = self.length_scale
    x = r / length
    integral = (_SQRT_PI * length**3 / 4.0) * erf(x) - (length**2 * r / 2.0) * jnp.exp(
        -(x**2)
    )
    return 4.0 * jnp.pi * self.rho_cen * integral

from_structure(*, m_core, p_cen, r_cmb, p_cmb, alpha, c_p, pressure_mode='quadrature', adiabat_mode='exact') classmethod

Fit central density and length scale to core mass and central pressure.

Parameters:

Name Type Description Default
m_core float

Total core mass [kg], positive.

required
p_cen float

Central pressure [Pa], must exceed p_cmb.

required
r_cmb float

Core-mantle boundary radius [m], positive.

required
p_cmb float

Pressure at the core-mantle boundary [Pa], positive.

required
alpha float

Thermal expansion coefficient [K-1], positive.

required
c_p float

Isobaric specific heat capacity [J kg-1 K-1], positive.

required
pressure_mode str

Pressure mode: 'quadrature' (default) or 'labrosse'.

'quadrature'
adiabat_mode str

Adiabat mode: 'exact' (default) or 'small_radius'.

'exact'

Returns:

Type Description
GaussianCoreProfiles

Profile instance with fitted rho_cen and length_scale.

Raises:

Type Description
ValueError

If parameters are non-positive, if p_cen <= p_cmb, if the central pressure is below the incompressible sphere limit, or if the root solve does not converge within the Gaussian family regime.

Source code in src/aragog/core/profiles.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
@classmethod
def from_structure(
    cls,
    *,
    m_core: float,
    p_cen: float,
    r_cmb: float,
    p_cmb: float,
    alpha: float,
    c_p: float,
    pressure_mode: str = 'quadrature',
    adiabat_mode: str = 'exact',
) -> GaussianCoreProfiles:
    """Fit central density and length scale to core mass and central pressure.

    Parameters
    ----------
    m_core : float
        Total core mass [kg], positive.
    p_cen : float
        Central pressure [Pa], must exceed ``p_cmb``.
    r_cmb : float
        Core-mantle boundary radius [m], positive.
    p_cmb : float
        Pressure at the core-mantle boundary [Pa], positive.
    alpha : float
        Thermal expansion coefficient [K-1], positive.
    c_p : float
        Isobaric specific heat capacity [J kg-1 K-1], positive.
    pressure_mode : str, optional
        Pressure mode: ``'quadrature'`` (default) or ``'labrosse'``.
    adiabat_mode : str, optional
        Adiabat mode: ``'exact'`` (default) or ``'small_radius'``.

    Returns
    -------
    GaussianCoreProfiles
        Profile instance with fitted ``rho_cen`` and ``length_scale``.

    Raises
    ------
    ValueError
        If parameters are non-positive, if ``p_cen <= p_cmb``, if the
        central pressure is below the incompressible sphere limit, or if the
        root solve does not converge within the Gaussian family regime.
    """
    params = {
        'm_core': m_core,
        'p_cen': p_cen,
        'r_cmb': r_cmb,
        'p_cmb': p_cmb,
        'alpha': alpha,
        'c_p': c_p,
    }
    for name, value in params.items():
        if not float(value) > 0.0:
            raise ValueError(f'{name} must be positive, got {value}')

    m_core = float(m_core)
    p_cen = float(p_cen)
    r_cmb = float(r_cmb)
    p_cmb = float(p_cmb)
    alpha = float(alpha)
    c_p = float(c_p)

    if p_cen <= p_cmb:
        raise ValueError(f'p_cen ({p_cen:.4e} Pa) must exceed p_cmb ({p_cmb:.4e} Pa)')

    # Uniform density sphere has lowest central pressure for given mass.
    vol = 4.0 / 3.0 * _np.pi * r_cmb**3
    rho_avg = m_core / vol
    p_incomp = p_cmb + (2.0 / 3.0) * _np.pi * G * rho_avg**2 * r_cmb**2
    if p_cen < p_incomp:
        raise ValueError(
            f'p_cen ({p_cen:.4e} Pa) is below the incompressible central pressure '
            f'({p_incomp:.4e} Pa)'
        )

    def build(rho_c: float, length: float) -> GaussianCoreProfiles:
        return cls(
            rho_cen=rho_c,
            length_scale=length,
            r_cmb=r_cmb,
            p_cmb=p_cmb,
            alpha=alpha,
            c_p=c_p,
            pressure_mode=pressure_mode,
            adiabat_mode=adiabat_mode,
        )

    def rho_for(length: float) -> float:
        return float(m_core / float(build(1.0, length).enclosed_mass(r_cmb)))

    def residual(length: float) -> float:
        return float(build(rho_for(length), length).pressure(0.0)) - p_cen

    # Enforce the Gaussian family validity regime (r_cmb < 3 * length_scale).
    l_min = r_cmb / 2.999
    l_max = 100.0 * r_cmb

    res_min = residual(l_min)
    res_max = residual(l_max)
    if res_min < 0.0:
        p_max = float(p_cen + res_min)
        raise ValueError(
            f'p_cen ({p_cen:.4e} Pa) exceeds maximum central pressure ({p_max:.4e} Pa) '
            'achievable within valid Gaussian regime (r_cmb < 3 L)'
        )
    if res_min * res_max > 0.0:
        raise ValueError(
            f'Gaussian core profile fit did not bracket a root for m_core={m_core:.4e} kg, '
            f'p_cen={p_cen:.4e} Pa (residual span [{res_min:.4e}, {res_max:.4e}])'
        )

    sol = root_scalar(
        residual,
        bracket=[l_min, l_max],
        method='brentq',
        xtol=1e-8,
        rtol=1e-10,
    )
    if not sol.converged:
        raise ValueError(
            'Gaussian core profile fit to M_core and P_cen did not converge: ' + str(sol)
        )

    l_fit = float(sol.root)
    return build(rho_for(l_fit), l_fit)

gravity(r)

Gravitational acceleration magnitude [m s-2] at radius r.

Exact for the Gaussian density: G M(r) / r^2, with the removable singularity at the centre replaced by its analytic limit (4 pi / 3) G rho_cen r.

Source code in src/aragog/core/profiles.py
153
154
155
156
157
158
159
160
161
162
163
164
165
def gravity(self, r):
    """Gravitational acceleration magnitude [m s-2] at radius ``r``.

    Exact for the Gaussian density: ``G M(r) / r^2``, with the removable
    singularity at the centre replaced by its analytic limit
    ``(4 pi / 3) G rho_cen r``.
    """
    r = jnp.asarray(r)
    small = self.length_scale * 1e-6
    safe_r = jnp.where(r > small, r, small)
    exact = G * self.enclosed_mass(safe_r) / safe_r**2
    centre = 4.0 * jnp.pi / 3.0 * G * self.rho_cen * r
    return jnp.where(r > small, exact, centre)

potential(r)

Gravitational potential [J/kg] at radius r, zero at the CMB.

psi(r) = -int_r^{r_cmb} g(s) ds on the same fixed 32-point Gauss-Legendre panel as the pressure; negative inside the core, which is the reference the gravitational-energy budget term uses.

Source code in src/aragog/core/profiles.py
203
204
205
206
207
208
209
210
211
212
213
214
215
def potential(self, r):
    """Gravitational potential [J/kg] at radius ``r``, zero at the CMB.

    ``psi(r) = -int_r^{r_cmb} g(s) ds`` on the same fixed 32-point
    Gauss-Legendre panel as the pressure; negative inside the core,
    which is the reference the gravitational-energy budget term uses.
    """
    r = jnp.asarray(r)
    half_span = (self.r_cmb - r) / 2.0
    centre = (self.r_cmb + r) / 2.0
    s = centre[..., None] + half_span[..., None] * _GL_X
    integral = half_span * jnp.sum(_GL_W * self.gravity(s), axis=-1)
    return -integral

pressure(r)

Pressure [Pa] at radius r, from hydrostatic balance.

Quadrature mode: P(r) = p_cmb + int_r^{r_cmb} rho(s) g(s) ds on a fixed 32-point Gauss-Legendre panel, jit-safe with no adaptive control flow. Labrosse mode: the printed closed form.

Source code in src/aragog/core/profiles.py
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def pressure(self, r):
    """Pressure [Pa] at radius ``r``, from hydrostatic balance.

    Quadrature mode: ``P(r) = p_cmb + int_r^{r_cmb} rho(s) g(s) ds`` on
    a fixed 32-point Gauss-Legendre panel, jit-safe with no adaptive
    control flow. Labrosse mode: the printed closed form.
    """
    if self.pressure_mode == 'labrosse':
        return self._pressure_labrosse(r)
    r = jnp.asarray(r)
    half_span = (self.r_cmb - r) / 2.0
    centre = (self.r_cmb + r) / 2.0
    # Broadcast quadrature nodes over any leading shape of r.
    s = centre[..., None] + half_span[..., None] * _GL_X
    integrand = self.density(s) * self.gravity(s)
    integral = half_span * jnp.sum(_GL_W * integrand, axis=-1)
    return self.p_cmb + integral

t_cen(t_cmb)

Centre temperature [K] on the adiabat anchored at t_cmb.

Source code in src/aragog/core/profiles.py
258
259
260
def t_cen(self, t_cmb):
    """Centre temperature [K] on the adiabat anchored at ``t_cmb``."""
    return self.adiabat(0.0, t_cmb)

IronMeltingCurve(*, light_element_fraction=0.0, depression=0.0)

Melting temperature of the core alloy.

Parameters:

Name Type Description Default
light_element_fraction float

Mole fraction x of light elements in the core alloy, in [0, 1). Zero is pure iron.

0.0
depression float

Melting-point depression per unit mole fraction (the alpha_c of the linear alloy correction), non-negative. The product depression * x must stay below one so the curve stays positive.

0.0

Raises:

Type Description
ValueError

If x is not a real number or is outside [0, 1), depression is negative, or the combined depression factor would reach zero or below.

Source code in src/aragog/core/melting.py
88
89
90
91
92
93
def __init__(self, *, light_element_fraction: float = 0.0, depression: float = 0.0):
    dep = float(depression)
    if dep < 0.0:
        raise ValueError(f'depression must be non-negative, got {dep}')
    self.light_element_fraction = float(_checked_fraction(light_element_fraction, dep))
    self.depression = dep

t_melt(pressure, light_element_fraction=None)

Alloy melting temperature [K] at pressure [Pa].

light_element_fraction overrides the instance value when given, which allows callers to evolve the alloy composition without rebuilding the curve. A concrete override (a real scalar or array) is taken in float64 and checked like the instance value (in [0, 1), with the depressed curve kept positive); a traced override is the caller's responsibility, since a trace cannot raise on data.

Raises:

Type Description
ValueError

If a concrete override is not a real number (a bool, string, bytes or complex value included), is outside [0, 1), or drives the depression factor to zero or below.

Source code in src/aragog/core/melting.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
def t_melt(self, pressure, light_element_fraction=None):
    """Alloy melting temperature [K] at ``pressure`` [Pa].

    ``light_element_fraction`` overrides the instance value when given,
    which allows callers to evolve the alloy composition without
    rebuilding the curve. A concrete override (a real scalar or array) is
    taken in float64 and checked like the instance value (in ``[0, 1)``, with
    the depressed curve kept positive); a traced override is the caller's
    responsibility, since a trace cannot raise on data.

    Raises
    ------
    ValueError
        If a concrete override is not a real number (a bool, string,
        bytes or complex value included), is outside ``[0, 1)``, or
        drives the depression factor to zero or below.
    """
    if light_element_fraction is None:
        x = self.light_element_fraction
    else:
        x = light_element_fraction
        if not isinstance(x, jax.core.Tracer):
            x = _checked_fraction(x, self.depression)
    return self.t_melt_pure(pressure) * (1.0 - self.depression * x)

t_melt_pure(pressure) staticmethod

Pure-iron melting temperature [K] at pressure [Pa]: the two Anzellini branches joined by the smootherstep blend of the module docstring.

Source code in src/aragog/core/melting.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
@staticmethod
def t_melt_pure(pressure):
    """Pure-iron melting temperature [K] at ``pressure`` [Pa]: the two Anzellini
    branches joined by the smootherstep blend of the module docstring."""
    p = jnp.asarray(pressure)
    p_gpa = p / 1e9
    low = _T0 * ((p_gpa - _P0 / 1e9) / _DP_LOW + 1.0) ** _EXP_LOW
    high = _TT * ((p_gpa - _PT / 1e9) / _DP_HIGH + 1.0) ** _EXP_HIGH
    u = jnp.clip(
        0.5 * ((p - _PT) / IRON_MELTING_BRANCH_BLEND_HALF_WIDTH_PA + 1.0),
        0.0,
        1.0,
    )
    w = u * u * u * (u * (u * 6.0 - 15.0) + 10.0)
    return (1.0 - w) * low + w * high

QuadraticMeltingCurve(*, t_m0, t_m1, t_m2)

Quadratic-in-pressure melting curve, T_m0 (1 + T_m1 P + T_m2 P^2).

The parameterisation of Nimmo (2015, Treatise on Geophysics 9.08, Eq. 6), where T_m0 already incorporates the light-element depression; it is the form the Table 2 benchmark models are defined in, and a configurable alternative to the PALEOS prescription wherever a fitted curve is preferred. Valid over core pressures only; T_m0 is not a zero-pressure melting temperature.

Parameters:

Name Type Description Default
t_m0 float

Prefactor [K], positive.

required
t_m1 float

Linear coefficient [Pa-1]; either sign occurs in published fits.

required
t_m2 float

Quadratic coefficient [Pa-2]; either sign occurs in published fits.

required
Notes

The fit is only meaningful over the pressure range it was constructed for; outside it a negative-coefficient curve can fall through zero. Callers own the range check.

Source code in src/aragog/core/melting.py
163
164
165
166
167
168
def __init__(self, *, t_m0: float, t_m1: float, t_m2: float):
    if not float(t_m0) > 0.0:
        raise ValueError(f't_m0 must be positive, got {t_m0}')
    self.t_m0 = float(t_m0)
    self.t_m1 = float(t_m1)
    self.t_m2 = float(t_m2)

gradient(pressure)

Melting-curve slope dT_m/dP [K/Pa] at pressure [Pa].

Source code in src/aragog/core/melting.py
180
181
182
183
def gradient(self, pressure):
    """Melting-curve slope dT_m/dP [K/Pa] at ``pressure`` [Pa]."""
    p = jnp.asarray(pressure)
    return self.t_m0 * (self.t_m1 + 2.0 * self.t_m2 * p)

t_melt(pressure, light_element_fraction=None)

Melting temperature [K] at pressure [Pa].

The unused light_element_fraction keeps the call signature interchangeable with :class:IronMeltingCurve; the depression is already folded into t_m0 in this parameterisation.

Source code in src/aragog/core/melting.py
170
171
172
173
174
175
176
177
178
def t_melt(self, pressure, light_element_fraction=None):
    """Melting temperature [K] at ``pressure`` [Pa].

    The unused ``light_element_fraction`` keeps the call signature
    interchangeable with :class:`IronMeltingCurve`; the depression is
    already folded into ``t_m0`` in this parameterisation.
    """
    p = jnp.asarray(pressure)
    return self.t_m0 * (1.0 + self.t_m1 * p + self.t_m2 * p**2)

adiabatic_ratio(entropy, t_cmb, q_cmb)

ADR = Q_cmb / Q_k: below one the top of the core is subadiabatic.

Source code in src/aragog/core/stratification.py
67
68
69
def adiabatic_ratio(entropy: 'CoreEntropyBudget', t_cmb, q_cmb):
    """ADR = Q_cmb / Q_k: below one the top of the core is subadiabatic."""
    return q_cmb / entropy.adiabatic_heat_flow(t_cmb)

build_core_module_budget(params, *, r_cmb, p_cmb_fallback, m_core=None, p_cen=None)

Build a :class:CoreEnergyBudget from a flat config dict.

Recognised keys: profile parameters (rho_cen, length_scale, p_cmb, alpha, c_p, pressure_mode, adiabat_mode, m_core, p_cen, fit_profile), the melting-curve selector melting_curve ('iron' with light_element_fraction and depression, or 'quadratic' with t_m0/t_m1/t_m2), and the budget parameters (ds_fusion, icn_width, latent_heat, alpha_c, c_light, capacity_mode, legacy_rho_core, legacy_tfac, stratification, k_core), and the shell of a stratified core (layer_base_fraction, layer_cells, layer_top_cell, layer_k_mix, layer_g_mix). The CMB radius comes from the caller (the solver's mesh), never from the dict, and p_cmb falls back to the caller's value when absent.

When both m_core and p_cen are provided (and fit_profile is not False), rho_cen and length_scale are fitted to the structure constraints via :meth:GaussianCoreProfiles.from_structure.

Raises:

Type Description
ValueError

From the underlying constructors on any invalid value, or here on an unknown melting-curve selector, an unrecognised key, a quadratic curve without its three coefficients, a fit_profile that is not a boolean, one of m_core and p_cen given alone, or fit_profile = True without either.

Source code in src/aragog/core/module.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def build_core_module_budget(
    params: dict,
    *,
    r_cmb: float,
    p_cmb_fallback: float,
    m_core: float | None = None,
    p_cen: float | None = None,
) -> CoreEnergyBudget:
    """Build a :class:`CoreEnergyBudget` from a flat config dict.

    Recognised keys: profile parameters (``rho_cen``, ``length_scale``,
    ``p_cmb``, ``alpha``, ``c_p``, ``pressure_mode``, ``adiabat_mode``, ``m_core``,
    ``p_cen``, ``fit_profile``), the melting-curve selector
    ``melting_curve`` (``'iron'`` with ``light_element_fraction`` and
    ``depression``, or ``'quadratic'`` with ``t_m0``/``t_m1``/``t_m2``),
    and the budget parameters (``ds_fusion``, ``icn_width``,
    ``latent_heat``, ``alpha_c``, ``c_light``, ``capacity_mode``,
    ``legacy_rho_core``, ``legacy_tfac``, ``stratification``,
    ``k_core``), and the shell of a stratified core (``layer_base_fraction``,
    ``layer_cells``, ``layer_top_cell``, ``layer_k_mix``, ``layer_g_mix``). The CMB radius comes from the caller (the solver's
    mesh), never from the dict, and ``p_cmb`` falls back to the
    caller's value when absent.

    When both ``m_core`` and ``p_cen`` are provided (and ``fit_profile`` is
    not ``False``), ``rho_cen`` and ``length_scale`` are fitted to the
    structure constraints via :meth:`GaussianCoreProfiles.from_structure`.

    Raises
    ------
    ValueError
        From the underlying constructors on any invalid value, or here on
        an unknown melting-curve selector, an unrecognised key, a quadratic
        curve without its three coefficients, a ``fit_profile`` that is not a
        boolean, one of ``m_core`` and ``p_cen`` given alone, or
        ``fit_profile = True`` without either.
    """
    params = {**_FACTORY_DEFAULTS, **params}
    curve_kind = _check_budget_params(params)
    params.pop('melting_curve', None)
    cfg = {k: params.pop(k, None) for k in ('m_core', 'p_cen', 'fit_profile')}
    m_core = cfg['m_core'] if m_core is None else m_core
    p_cen = cfg['p_cen'] if p_cen is None else p_cen
    fit_profile = cfg['fit_profile']
    given = (m_core is not None) + (p_cen is not None)
    fit = given == 2 if fit_profile is None else bool(fit_profile)
    if given == 1:
        raise ValueError('m_core and p_cen go together: give both or neither')
    if fit and given == 0:
        raise ValueError('fit_profile = true needs both m_core and p_cen')

    profile_kwargs = {k: params[k] for k in _PROFILE_KEYS if k in params}
    profile_kwargs['r_cmb'] = r_cmb
    profile_kwargs.setdefault('p_cmb', p_cmb_fallback)

    if fit:
        profiles = GaussianCoreProfiles.from_structure(
            m_core=m_core,
            p_cen=p_cen,
            **{k: v for k, v in profile_kwargs.items() if k not in ('rho_cen', 'length_scale')},
        )
    else:
        profiles = GaussianCoreProfiles(**profile_kwargs)

    curve_kwargs = {k: params[k] for k in _CURVE_KEYS[curve_kind] if k in params}
    curve_class = {'iron': IronMeltingCurve, 'quadratic': QuadraticMeltingCurve}[curve_kind]
    curve = curve_class(**curve_kwargs)

    budget_kwargs = {k: params[k] for k in _BUDGET_KEYS if k in params}
    layer = {v: params[k] for k, v in _LAYER_KEYS.items() if params.get(k) is not None}
    if layer:
        budget_kwargs['layer'] = layer
    return CoreEnergyBudget(profiles, curve, **budget_kwargs)

check_ra_crit(ra_crit)

Return the CMB critical Rayleigh number as a float.

Raises:

Type Description
ValueError

When ra_crit is not a real number (a boolean or a string included), not positive or not finite.

Source code in src/aragog/core/boundary_layer.py
29
30
31
32
33
34
35
36
37
38
39
40
41
def check_ra_crit(ra_crit) -> float:
    """Return the CMB critical Rayleigh number as a float.

    Raises
    ------
    ValueError
        When ``ra_crit`` is not a real number (a boolean or a string included), not
        positive or not finite.
    """
    real = isinstance(ra_crit, numbers.Real) and not isinstance(ra_crit, bool)
    if not (real and 0.0 < float(ra_crit) < math.inf):
        raise ValueError(f'ra_crit_cmb must be positive and finite, got {ra_crit!r}')
    return float(ra_crit)

cmb_boundary_layer_flux(t_core, t_mantle, *, conductivity, density, heat_capacity, expansivity, viscosity, gravity, dr_half, ra_crit=RA_CRIT_CMB_DEFAULT)

Heat flux out of the core across the CMB [W/m^2].

Parameters:

Name Type Description Default
t_core float or Array

Core temperature at the CMB [K].

required
t_mantle float or Array

Mantle temperature at the CMB [K]: the bottom cell's entropy evaluated at the CMB pressure in the solver.

required
conductivity float

Bottom-cell thermal conductivity [W/m/K], density [kg/m^3], heat capacity [J/kg/K], thermal expansivity [1/K] and dynamic viscosity [Pa s]. The thermal diffusivity is k / (rho c_p); c_p and the expansivity are the material values, without a phase-change contribution.

required
density float

Bottom-cell thermal conductivity [W/m/K], density [kg/m^3], heat capacity [J/kg/K], thermal expansivity [1/K] and dynamic viscosity [Pa s]. The thermal diffusivity is k / (rho c_p); c_p and the expansivity are the material values, without a phase-change contribution.

required
heat_capacity float

Bottom-cell thermal conductivity [W/m/K], density [kg/m^3], heat capacity [J/kg/K], thermal expansivity [1/K] and dynamic viscosity [Pa s]. The thermal diffusivity is k / (rho c_p); c_p and the expansivity are the material values, without a phase-change contribution.

required
expansivity float

Bottom-cell thermal conductivity [W/m/K], density [kg/m^3], heat capacity [J/kg/K], thermal expansivity [1/K] and dynamic viscosity [Pa s]. The thermal diffusivity is k / (rho c_p); c_p and the expansivity are the material values, without a phase-change contribution.

required
viscosity float

Bottom-cell thermal conductivity [W/m/K], density [kg/m^3], heat capacity [J/kg/K], thermal expansivity [1/K] and dynamic viscosity [Pa s]. The thermal diffusivity is k / (rho c_p); c_p and the expansivity are the material values, without a phase-change contribution.

required
gravity float

Gravitational acceleration at the CMB [m/s^2].

required
dr_half float

Distance from the CMB to the bottom staggered node [m], positive.

required
ra_crit float

Critical Rayleigh number of the boundary layer.

RA_CRIT_CMB_DEFAULT

Returns:

Type Description
Array

max(q_cond, q_conv) for T_core > T_mantle, else q_cond, with q_cond = k dT / dr_half. Positive cools the core.

Source code in src/aragog/core/boundary_layer.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
def cmb_boundary_layer_flux(
    t_core,
    t_mantle,
    *,
    conductivity,
    density,
    heat_capacity,
    expansivity,
    viscosity,
    gravity,
    dr_half,
    ra_crit=RA_CRIT_CMB_DEFAULT,
):
    """Heat flux out of the core across the CMB [W/m^2].

    Parameters
    ----------
    t_core : float or jax.Array
        Core temperature at the CMB [K].
    t_mantle : float or jax.Array
        Mantle temperature at the CMB [K]: the bottom cell's entropy
        evaluated at the CMB pressure in the solver.
    conductivity, density, heat_capacity, expansivity, viscosity : float
        Bottom-cell thermal conductivity [W/m/K], density [kg/m^3], heat
        capacity [J/kg/K], thermal expansivity [1/K] and dynamic viscosity
        [Pa s]. The thermal diffusivity is ``k / (rho c_p)``; ``c_p`` and the
        expansivity are the material values, without a phase-change contribution.
    gravity : float
        Gravitational acceleration at the CMB [m/s^2].
    dr_half : float
        Distance from the CMB to the bottom staggered node [m], positive.
    ra_crit : float
        Critical Rayleigh number of the boundary layer.

    Returns
    -------
    jax.Array
        ``max(q_cond, q_conv)`` for ``T_core > T_mantle``, else ``q_cond``,
        with ``q_cond = k dT / dr_half``. Positive cools the core.
    """
    dT = t_core - t_mantle
    q_cond = conductivity * dT / dr_half
    # Safe base keeps the cube root and its derivative finite where the branch is unused.
    dT_pos = jnp.where(dT > 0.0, dT, 1.0)
    kappa = conductivity / (density * heat_capacity)
    # Floor (not zero) so a non-buoyant layer gives q_conv ~ 0 with a finite derivative.
    buoyancy = jnp.maximum(expansivity * density * gravity, 1e-300)
    q_conv = (
        conductivity
        * dT_pos ** (4.0 / 3.0)
        * (buoyancy / (ra_crit * kappa * viscosity)) ** (1.0 / 3.0)
    )
    return jnp.where(dT > 0.0, jnp.maximum(q_cond, q_conv), q_cond)

crystallization_regime(budget, t_cmb)

Regime code (see REGIME_NAMES) at t_cmb.

The superheat T_a - T_m is sampled on a fixed radial grid; its sign at the centre and the CMB plus the number of sign changes classify the state:

  • no solid anywhere: fully_liquid
  • no liquid anywhere: fully_frozen
  • one interface, solid centre: bottom_up
  • one interface, solid top: top_down
  • more than one interface: snow (at least one interior solid or liquid shell)

The grid resolves shells wider than r_cmb / 512; a thinner shell than that reads as its surrounding regime.

Source code in src/aragog/core/regime.py
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
def crystallization_regime(budget: CoreEnergyBudget, t_cmb):
    """Regime code (see ``REGIME_NAMES``) at ``t_cmb``.

    The superheat ``T_a - T_m`` is sampled on a fixed radial grid; its
    sign at the centre and the CMB plus the number of sign changes
    classify the state:

    * no solid anywhere: ``fully_liquid``
    * no liquid anywhere: ``fully_frozen``
    * one interface, solid centre: ``bottom_up``
    * one interface, solid top: ``top_down``
    * more than one interface: ``snow`` (at least one interior solid or
      liquid shell)

    The grid resolves shells wider than ``r_cmb / 512``; a thinner shell
    than that reads as its surrounding regime.
    """
    r = jnp.linspace(0.0, budget.profiles.r_cmb, _N_GRID)
    superheat = budget.superheat(r, t_cmb)
    solid = superheat < 0.0
    changes = jnp.sum(jnp.abs(jnp.diff(solid.astype(jnp.int32))))
    # jnp.select takes the first true condition, the order of the nested checks.
    return jnp.select(
        [~jnp.any(solid), jnp.all(solid), changes > 1, solid[0]],
        [REGIME_FULLY_LIQUID, REGIME_FULLY_FROZEN, REGIME_SNOW, REGIME_BOTTOM_UP],
        REGIME_TOP_DOWN,
    )

regime_name(code)

Human-readable name for a regime code (eager helper for logs).

Source code in src/aragog/core/regime.py
130
131
132
def regime_name(code) -> str:
    """Human-readable name for a regime code (eager helper for logs)."""
    return REGIME_NAMES[int(code)]

stratification_depth(entropy, t_cmb, q_cmb)

Equilibrium thickness [m] of the stably stratified sub-CMB layer.

Solves Q_ad(r_s) = Q_cmb for the layer base r_s by fixed bisection on the inner rising branch of Q_ad(r) = 4 pi r^2 k |dT_a/dr|, which peaks at profiles.r_peak (D sqrt(3/2) on the small-radius adiabat) and decreases beyond it; the thickness is r_cmb - r_s. For Earth-scale cores the peak sits outside the CMB and the branch spans the whole core; for larger cores the search is bracketed at the peak, and a layer reaching the peak is reported clamped there, since the thin-layer conductive-matching estimate has no meaning deeper. Zero when the flow is superadiabatic at the CMB (ADR >= 1); the whole core when the flow is non-positive (q_cmb <= 0).

Source code in src/aragog/core/stratification.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
def stratification_depth(entropy: 'CoreEntropyBudget', t_cmb, q_cmb):
    """Equilibrium thickness [m] of the stably stratified sub-CMB layer.

    Solves ``Q_ad(r_s) = Q_cmb`` for the layer base ``r_s`` by fixed
    bisection on the inner rising branch of ``Q_ad(r) = 4 pi r^2 k |dT_a/dr|``,
    which peaks at ``profiles.r_peak`` (``D sqrt(3/2)`` on the small-radius adiabat) and
    decreases beyond it; the thickness is ``r_cmb - r_s``. For Earth-scale cores
    the peak sits outside the CMB and the branch spans the whole core;
    for larger cores the search is bracketed at the peak, and a layer
    reaching the peak is reported clamped there, since the thin-layer
    conductive-matching estimate has no meaning deeper. Zero when the
    flow is superadiabatic at the CMB (``ADR >= 1``); the whole core
    when the flow is non-positive (``q_cmb <= 0``).
    """
    return _thickness_primal(entropy.budget.profiles, entropy.k_core, t_cmb, q_cmb)