Skip to content

aragog.eos

The aragog.eos package provides the pressure-entropy equation of state used by the solver. The production path is a single phase-aware evaluator backed by SPIDER-format \((P, S)\) tables; there is no abstract evaluator protocol or single/mixed/composite hierarchy.

Name Role
EntropyEOS P-S table loader and bilinear interpolator. Provides temperature(P, S), density(P, S), melt_fraction(P, S), solidus_entropy(P), liquidus_entropy(P), latent_heat(P), solidus_entropy_dP(P), liquidus_entropy_dP(P).
EntropyPhaseEvaluator Wraps EntropyEOS with the SPIDER-parity two-stage phase blend, viscosity tanh transition, gravitational-separation velocity, and the per-cell property cache.
mobility_function Three-regime permeability over porosity \(F(\phi)\) [m²] that the gravitational-separation velocity uses, from the porosity and the grain size.
consistency.residuals How far the P-S tables of one phase are from \((\partial T/\partial S)_P = T/c_p\), from the tabulated \((\partial T/\partial P)_S\), from \((\partial T/\partial P)_S = -(1/\rho)(\partial \ln\rho/\partial S)_P\) and from \((\partial T/\partial P)_S = \alpha T/(\rho c_p)\), as relative numbers. Slopes come from central differences on the table grid; a step that meets a missing cell gives none.
consistency.check Tests each cell of those tables against a Richardson bound: the round-off floor of the stored values, at 9 significant digits at best, plus twice the \(h^4\) term measured at the cell. The floor also covers the rounding of the stored \(P\) and \(S\) coordinates, taken as the departure of each coordinate from the uniform or geometric fit of its axis ($\epsilon

For the file format expected by EntropyEOS, see Reference: data.

eos

EOS subpackage: equation of state evaluators.

Provides entropy-formulation EOS (PALEOS P-S tables) and phase evaluator.

EntropyEOS(eos_dir, strict_range=False)

Entropy-based EOS from PALEOS P-S tables.

Provides property lookups (P, S) -> T, rho, Cp, alpha, dTdPs, phi for a single mantle material with solid and melt phases.

Parameters:

Name Type Description Default
eos_dir Path or str

Directory containing the SPIDER-format P-S table files.

required
strict_range bool

If True, raise a RuntimeError when a lookup entropy falls outside a phase table's own S range (or is non-finite), instead of only logging a warning and clamping to the table edge.

False
Source code in src/aragog/eos/entropy.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
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
430
431
432
433
434
def __init__(self, eos_dir: Path | str, strict_range: bool = False):
    eos_dir = Path(eos_dir)
    if not eos_dir.is_dir():
        raise FileNotFoundError(f'EOS directory not found: {eos_dir}')

    self.strict_range = strict_range

    # Per-context out-of-range warning counts, reset per instance so a
    # fresh solve warns again. The RHS calls _check_entropy_range every
    # step, so the log line is throttled to powers of ten; the raise is not.
    self._range_warning_counts: dict[str, int] = {}

    logger.info('Loading entropy EOS from %s', eos_dir)

    # Load property tables for solid and melt
    self._tables: dict[str, dict] = {}
    for phase in ('solid', 'melt'):
        self._tables[f'temperature_{phase}'] = _load_spider_ps_table(
            eos_dir / f'temperature_{phase}.dat'
        )
        self._tables[f'density_{phase}'] = _load_spider_ps_table(
            eos_dir / f'density_{phase}.dat'
        )
        self._tables[f'heat_capacity_{phase}'] = _load_spider_ps_table(
            eos_dir / f'heat_capacity_{phase}.dat'
        )
        self._tables[f'dTdPs_{phase}'] = _load_spider_ps_table(
            eos_dir / f'adiabat_temp_grad_{phase}.dat'
        )

        # Thermal expansivity: load from table if available (matches
        # SPIDER exactly), otherwise derived from thermodynamic identity.
        alpha_file = eos_dir / f'thermal_exp_{phase}.dat'
        if alpha_file.is_file():
            self._tables[f'thermal_exp_{phase}'] = _load_spider_ps_table(alpha_file)
    self._has_alpha_tables = (
        'thermal_exp_solid' in self._tables and 'thermal_exp_melt' in self._tables
    )
    if self._has_alpha_tables:
        logger.info('Thermal expansivity loaded from P-S tables (SPIDER parity)')
    else:
        logger.info('Thermal expansivity will be derived from T, rho, Cp, dTdPs')

    # Load phase boundaries
    self._solidus = _load_spider_phase_boundary(eos_dir / 'solidus_P-S.dat')
    self._liquidus = _load_spider_phase_boundary(eos_dir / 'liquidus_P-S.dat')

    # Store P and S ranges. P uses the temperature tables only (grid
    # shape reference for callers). S is the union across every
    # loaded table, so a value outside [S_min, S_max] is guaranteed
    # to be clamped by at least one table lookup below.
    ref_melt = self._tables['temperature_melt']
    ref_solid = self._tables['temperature_solid']
    self.P_min = float(min(ref_melt['P'][0], ref_solid['P'][0]))
    self.P_max = float(max(ref_melt['P'][-1], ref_solid['P'][-1]))
    self.S_min = float(min(tbl['S'][0] for tbl in self._tables.values()))
    self.S_max = float(max(tbl['S'][-1] for tbl in self._tables.values()))

    logger.info(
        'Entropy EOS loaded: P=[%.2e, %.2e] Pa, S=[%.0f, %.0f] J/kg/K, %d x %d grid (melt)',
        self.P_min,
        self.P_max,
        self.S_min,
        self.S_max,
        ref_melt['n_P'],
        ref_melt['n_S'],
    )

    self._build_enthalpy_table()

dTdPs(P, S)

Adiabatic temperature gradient dT/dP|_S (P, S) [K/Pa].

This is the SPIDER convention: dT/dP along the adiabat, NOT the dimensionless nabla_ad = d ln T / d ln P.

Source code in src/aragog/eos/entropy.py
1210
1211
1212
1213
1214
1215
1216
def dTdPs(self, P: npt.NDArray | float, S: npt.NDArray | float) -> npt.NDArray:
    """Adiabatic temperature gradient dT/dP|_S (P, S) [K/Pa].

    This is the SPIDER convention: dT/dP along the adiabat, NOT
    the dimensionless nabla_ad = d ln T / d ln P.
    """
    return self._lookup_phase_weighted('dTdPs', P, S)

density(P, S)

Density rho(P, S) [kg/m^3].

In the mushy zone (0 < phi < 1): harmonic mean of end-member densities evaluated at phase boundary entropies (SPIDER eos_composite.c:236-237).

Outside the mushy zone (phi=0 or phi=1): single-phase table evaluated at the actual S (clamped to table range), matching SPIDER's combine_matprop(smth=0, mixed, single) path.

Source code in src/aragog/eos/entropy.py
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
def density(self, P: npt.NDArray | float, S: npt.NDArray | float) -> npt.NDArray:
    """Density rho(P, S) [kg/m^3].

    In the mushy zone (0 < phi < 1): harmonic mean of end-member
    densities evaluated at phase boundary entropies (SPIDER
    eos_composite.c:236-237).

    Outside the mushy zone (phi=0 or phi=1): single-phase table
    evaluated at the actual S (clamped to table range), matching
    SPIDER's combine_matprop(smth=0, mixed, single) path.
    """
    P = np.asarray(P, dtype=float)
    S = np.asarray(S, dtype=float)
    phi = self._melt_fraction(P, S)
    mushy = (phi > 0) & (phi < 1)

    solid_table = self._tables['density_solid']
    melt_table = self._tables['density_melt']

    # Mushy zone: evaluate at phase boundaries (Lever Rule)
    rho_sol_boundary = self._lookup_at_phase_boundary('density', P, 'solid')
    rho_liq_boundary = self._lookup_at_phase_boundary('density', P, 'melt')
    inv_rho_mushy = phi / np.maximum(rho_liq_boundary, 1.0) + (1.0 - phi) / np.maximum(
        rho_sol_boundary, 1.0
    )
    rho_mushy = 1.0 / np.maximum(inv_rho_mushy, 1e-30)

    # Single-phase: evaluate at actual S (clamped to table range),
    # selected below by phi >= 0.5. Mushy points use rho_mushy, and
    # each non-mushy point uses only the branch its selector picks,
    # so mask each range check to the points that use that branch.
    melt_selected = phi >= 0.5
    solid_used = ~mushy & ~melt_selected
    melt_used = ~mushy & melt_selected
    self._check_entropy_range(
        np.where(solid_used, S, solid_table['S'][0]),
        solid_table['S'][0],
        solid_table['S'][-1],
        'density (solid table lookup)',
    )
    self._check_entropy_range(
        np.where(melt_used, S, melt_table['S'][0]),
        melt_table['S'][0],
        melt_table['S'][-1],
        'density (melt table lookup)',
    )
    S_solid_clamped = np.clip(S, solid_table['S'][0], solid_table['S'][-1])
    S_melt_clamped = np.clip(S, melt_table['S'][0], melt_table['S'][-1])
    P_solid_clamped = np.clip(P, solid_table['P'][0], solid_table['P'][-1])
    P_melt_clamped = np.clip(P, melt_table['P'][0], melt_table['P'][-1])
    pts_solid = np.column_stack([P_solid_clamped.ravel(), S_solid_clamped.ravel()])
    pts_melt = np.column_stack([P_melt_clamped.ravel(), S_melt_clamped.ravel()])
    rho_solid_single = solid_table['interp'](pts_solid).reshape(P.shape)
    rho_melt_single = melt_table['interp'](pts_melt).reshape(P.shape)
    # 0.5 here is a binary table-selector for the non-mushy fallback
    # (phi is essentially 0 or 1 outside the mushy band); it is NOT
    # the rheological critical melt fraction. The RCMF is
    # ``EntropyPhaseEvaluator._phi_rheo`` and drives the viscosity
    # tanh blend separately.
    rho_single = np.where(phi >= 0.5, rho_melt_single, rho_solid_single)

    return np.where(mushy, rho_mushy, rho_single)

entropy_at_temperature(P, T)

Entropy [J/kg/K] at pressure P where temperature(P, S) = T.

Brent's method over the tables' entropy range, which keeps its bracket where T(S) is flat; the range edge where T lies beyond it, and NaN for a NaN P or T or where the method does not converge.

Source code in src/aragog/eos/entropy.py
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
def entropy_at_temperature(self, P: float, T: float) -> float:
    """Entropy [J/kg/K] at pressure ``P`` where ``temperature(P, S) = T``.

    Brent's method over the tables' entropy range, which keeps its bracket where ``T(S)`` is
    flat; the range edge where ``T`` lies beyond it, and NaN for a NaN ``P`` or ``T`` or where
    the method does not converge.
    """
    if np.isnan(P + T):
        return np.nan
    solid, melt = self._tables['temperature_solid'], self._tables['temperature_melt']
    lo, hi = min(solid['S'][0], melt['S'][0]), max(solid['S'][-1], melt['S'][-1])

    def f(s):
        return self.temperature_scalar(P, s) - T

    f_lo, f_hi = f(lo), f(hi)
    if f_lo >= 0.0 or f_hi <= 0.0:
        return lo if f_lo >= 0.0 else hi
    # next to a flat T(S) Brent takes more than its default 100 iterations
    s, result = brentq(f, lo, hi, maxiter=500, full_output=True, disp=False)
    return s if result.converged else np.nan

heat_capacity(P, S)

Specific heat capacity Cp(P, S) [J/kg/K].

Linear blend of solid and melt P-S tables by melt fraction (the cp_blend = 'linear' convention). Use heat_capacity_latent_blend for SPIDER-parity mushy-zone Cp that includes the latent-heat contribution.

Source code in src/aragog/eos/entropy.py
1037
1038
1039
1040
1041
1042
1043
1044
1045
def heat_capacity(self, P: npt.NDArray | float, S: npt.NDArray | float) -> npt.NDArray:
    """Specific heat capacity Cp(P, S) [J/kg/K].

    Linear blend of solid and melt P-S tables by melt fraction
    (the ``cp_blend = 'linear'`` convention). Use
    ``heat_capacity_latent_blend`` for SPIDER-parity mushy-zone
    Cp that includes the latent-heat contribution.
    """
    return self._lookup_phase_weighted('heat_capacity', P, S)

heat_capacity_latent_blend(P, S, width=0.01)

Latent-heat-augmented Cp(P, S), matching SPIDER convention.

Mirrors SPIDER's eos_composite.c:226-232 formula:

Cp_mix = (S_liq - S_sol) / (T_liq - T_sol)
       * (T_sol + 0.5 * (T_liq - T_sol))

which captures the latent-heat contribution to apparent heat capacity in the mushy zone. Outside the phase boundary the result reduces to the pure-phase Cp via tanh smoothing.

The plain linear blend used by heat_capacity above misses the latent-heat term and underestimates Cp by up to a factor of 6 in the deep mushy regime relative to SPIDER's internal cp_s. This formula closes that gap.

Parameters:

Name Type Description Default
P array or float

Pressure [Pa].

required
S array or float

Entropy [J/kg/K].

required
width float

Tanh smoothing width across the phase boundary, in units of melt fraction. SPIDER default is 0.01.

0.01

Returns:

Type Description
ndarray

Heat capacity [J/kg/K] with the same shape as the inputs.

Source code in src/aragog/eos/entropy.py
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
def heat_capacity_latent_blend(
    self, P: npt.NDArray | float, S: npt.NDArray | float, width: float = 0.01
) -> npt.NDArray:
    """Latent-heat-augmented Cp(P, S), matching SPIDER convention.

    Mirrors SPIDER's ``eos_composite.c:226-232`` formula:

        Cp_mix = (S_liq - S_sol) / (T_liq - T_sol)
               * (T_sol + 0.5 * (T_liq - T_sol))

    which captures the latent-heat contribution to apparent heat
    capacity in the mushy zone. Outside the phase boundary the
    result reduces to the pure-phase Cp via tanh smoothing.

    The plain linear blend used by ``heat_capacity`` above misses
    the latent-heat term and underestimates Cp by up to a factor
    of 6 in the deep mushy regime relative to SPIDER's internal
    cp_s. This formula closes that gap.

    Parameters
    ----------
    P : array or float
        Pressure [Pa].
    S : array or float
        Entropy [J/kg/K].
    width : float
        Tanh smoothing width across the phase boundary, in units
        of melt fraction. SPIDER default is 0.01.

    Returns
    -------
    ndarray
        Heat capacity [J/kg/K] with the same shape as the inputs.
    """
    P = np.asarray(P, dtype=float)
    S = np.asarray(S, dtype=float)

    # Phase boundaries at this pressure
    S_sol = np.asarray(self.solidus_entropy(P)).reshape(P.shape)
    S_liq = np.asarray(self.liquidus_entropy(P)).reshape(P.shape)
    T_sol = np.asarray(self._lookup_at_phase_boundary('temperature', P, 'solid')).reshape(
        P.shape
    )
    T_liq = np.asarray(self._lookup_at_phase_boundary('temperature', P, 'melt')).reshape(
        P.shape
    )

    dS = S_liq - S_sol
    dT = T_liq - T_sol

    # Mixed-phase Cp from the latent-heat formula
    # SPIDER: Cp_mix = (S_liq - S_sol) / (T_liq - T_sol) * T_mid
    safe_dT = np.where(np.abs(dT) > 1e-10, dT, 1e-10)
    T_mid = T_sol + 0.5 * dT
    Cp_mix = (dS / safe_dT) * T_mid

    # Pure-phase Cp from the linear-blend tables
    Cp_pure = self._lookup_phase_weighted('heat_capacity', P, S)

    # Un-truncated phase fraction (SPIDER's gphi convention)
    safe_dS = np.where(np.abs(dS) > 1e-10, dS, 1e-10)
    gphi = (S - S_sol) / safe_dS

    # SPIDER-parity smoothing (util.c:245-270, get_smoothing).
    # Bell shape centred on the mushy zone: rises at gphi=0 with
    # tanh(gphi/w), falls at gphi=1 with tanh((1-gphi)/w). The
    # width parameter w controls the shoulder into the single-
    # phase regime.
    #
    # IMPORTANT: SPIDER uses tanh(x/w), not expit(x/w) which
    # equals 0.5*(1+tanh(x/(2w))). The previous expit formula
    # had an effective width of w/2, making the transition twice
    # as narrow and producing 16% less latent-heat Cp at
    # crystallization onset (gphi=0.994).
    w = max(width, 1e-6)
    smooth = np.where(
        gphi > 0.5,
        0.5 * (1.0 + np.tanh((1.0 - gphi) / w)),  # fall at liquidus
        0.5 * (1.0 + np.tanh(gphi / w)),  # rise at solidus
    )

    return smooth * Cp_mix + (1.0 - smooth) * Cp_pure

invert_temperature(P, T_target)

Find entropy S such that T(P, S) = T_target.

Uses Brent root-finding with pure-Python bilinear interpolation on the P-S temperature tables. All arithmetic inside the brentq callback uses only Python floats, avoiding numpy scalar conversions that break on numpy >= 2.4 (where ndim > 0 arrays cannot be implicitly converted to scalars).

Parameters:

Name Type Description Default
P float

Pressure [Pa].

required
T_target float

Target temperature [K].

required

Returns:

Type Description
float

Entropy [J/kg/K] such that T(P, S) ~ T_target.

Raises:

Type Description
ValueError

If T_target is outside the range of T(P, S) for this P.

Source code in src/aragog/eos/entropy.py
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
def invert_temperature(self, P: float, T_target: float) -> float:
    """Find entropy S such that T(P, S) = T_target.

    Uses Brent root-finding with pure-Python bilinear interpolation
    on the P-S temperature tables. All arithmetic inside the brentq
    callback uses only Python floats, avoiding numpy scalar
    conversions that break on numpy >= 2.4 (where ndim > 0 arrays
    cannot be implicitly converted to scalars).

    Parameters
    ----------
    P : float
        Pressure [Pa].
    T_target : float
        Target temperature [K].

    Returns
    -------
    float
        Entropy [J/kg/K] such that T(P, S) ~ T_target.

    Raises
    ------
    ValueError
        If T_target is outside the range of T(P, S) for this P.
    """
    from scipy.optimize import brentq

    # All values must be pure Python floats for numpy 2.4 safety.
    P_clamped = max(self.P_min, min(float(P), self.P_max))
    T_tgt = float(T_target)

    def residual(S_cand: float) -> float:
        # Pure-Python path: no numpy arrays created or converted.
        return self.temperature_scalar(P_clamped, S_cand) - T_tgt

    S_lo = self.S_min  # already Python float from __init__
    S_hi = self.S_max
    f_lo = residual(S_lo)
    f_hi = residual(S_hi)

    if f_lo * f_hi > 0:
        raise ValueError(
            f'Cannot invert T={T_target:.1f} K at P={P:.2e} Pa: '
            f'T(S_min={S_lo:.0f})={T_tgt + f_lo:.0f} K, '
            f'T(S_max={S_hi:.0f})={T_tgt + f_hi:.0f} K. '
            f'Target outside table range.'
        )

    S_root = brentq(residual, S_lo, S_hi, xtol=0.1, rtol=1e-10)
    # brentq returns a Python float (from C double), but be safe
    return float(S_root)

latent_heat(P)

Latent heat L(P) = T_fus × (S_liq - S_sol) [J/kg].

P-dependent, following SPIDER convention. T_fus is the average of solidus and liquidus temperatures at the given pressure.

Source code in src/aragog/eos/entropy.py
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
def latent_heat(self, P: npt.NDArray | float) -> npt.NDArray:
    """Latent heat L(P) = T_fus × (S_liq - S_sol) [J/kg].

    P-dependent, following SPIDER convention. T_fus is the average
    of solidus and liquidus temperatures at the given pressure.
    """
    P = np.asarray(P, dtype=float)
    S_sol = self.solidus_entropy(P)
    S_liq = self.liquidus_entropy(P)
    T_sol = self._lookup_at_phase_boundary('temperature', P, 'solid')
    T_liq = self._lookup_at_phase_boundary('temperature', P, 'melt')
    T_fus = 0.5 * (T_sol + T_liq)
    return T_fus * np.maximum(S_liq - S_sol, 1.0)

liquidus_entropy(P)

Liquidus entropy S_liq(P) [J/kg/K].

Source code in src/aragog/eos/entropy.py
583
584
585
def liquidus_entropy(self, P: npt.NDArray | float) -> npt.NDArray:
    """Liquidus entropy S_liq(P) [J/kg/K]."""
    return np.asarray(self._liquidus['interp'](P), dtype=float)

liquidus_entropy_dP(P)

dS_liq/dP at the given pressure(s), in J/(kg·K·Pa).

Source code in src/aragog/eos/entropy.py
600
601
602
def liquidus_entropy_dP(self, P: npt.NDArray | float) -> npt.NDArray:
    """dS_liq/dP at the given pressure(s), in J/(kg·K·Pa)."""
    return np.asarray(self._liquidus['dinterp'](P), dtype=float)

material_expansivity(P, S)

Thermal expansivity [1/K] of the phases, without the phase-change term.

The volume-weighted blend rho sum_i x_i alpha_i / rho_i of each phase's expansivity, with x_i the Lever-rule mass fractions and rho the harmonic mixture density of density. Each alpha_i is from the thermal_exp tables when present, otherwise rho cp |dT/dP|_S / T of the phase at its own entropy.

Source code in src/aragog/eos/entropy.py
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
def material_expansivity(
    self, P: npt.NDArray | float, S: npt.NDArray | float
) -> npt.NDArray:
    """Thermal expansivity [1/K] of the phases, without the phase-change term.

    The volume-weighted blend ``rho sum_i x_i alpha_i / rho_i`` of each phase's
    expansivity, with ``x_i`` the Lever-rule mass fractions and ``rho`` the harmonic
    mixture density of ``density``. Each ``alpha_i`` is from the thermal_exp tables when
    present, otherwise ``rho cp |dT/dP|_S / T`` of the phase at its own entropy.
    """
    phi = self._melt_fraction(P, S)
    melt = phi / self._lookup_at_phase_boundary('density', P, 'melt')
    volume = melt / (
        melt + (1.0 - phi) / self._lookup_at_phase_boundary('density', P, 'solid')
    )
    if self._has_alpha_tables:
        return self._lookup_phase_weighted('thermal_exp', P, S, melt_weight=volume)
    return self._lookup_phase_weighted(
        ('density', 'heat_capacity', 'dTdPs', 'temperature'),
        P,
        S,
        per_phase=lambda rho, cp, dTdPs, T: rho * cp * np.abs(dTdPs) / np.maximum(T, 1.0),
        melt_weight=volume,
    )

melt_fraction(P, S)

Melt fraction phi from position between solidus and liquidus.

phi = 0 for S <= S_sol, phi = 1 for S >= S_liq, linear between.

Source code in src/aragog/eos/entropy.py
765
766
767
768
769
770
771
772
773
774
775
def melt_fraction(self, P: npt.NDArray | float, S: npt.NDArray | float) -> npt.NDArray:
    """Melt fraction phi from position between solidus and liquidus.

    phi = 0 for S <= S_sol, phi = 1 for S >= S_liq, linear between.
    """
    S = np.asarray(S, dtype=float)
    # phi feeds the live CVODE melt-fraction step cap directly, so flag a
    # non-finite or far-out-of-range S: the clip in the core maps +-inf to
    # a valid-looking 0 or 1 and passes NaN straight through.
    self._check_entropy_range(S, self.S_min, self.S_max, 'melt_fraction')
    return self._melt_fraction(P, S)

solidus_entropy(P)

Solidus entropy S_sol(P) [J/kg/K].

Source code in src/aragog/eos/entropy.py
579
580
581
def solidus_entropy(self, P: npt.NDArray | float) -> npt.NDArray:
    """Solidus entropy S_sol(P) [J/kg/K]."""
    return np.asarray(self._solidus['interp'](P), dtype=float)

solidus_entropy_dP(P)

dS_sol/dP at the given pressure(s), in J/(kg·K·Pa).

Needed by the SPIDER-parity mixing-flux formula in entropy_state.update. SPIDER computes Jmix via a bracket expression dS/dr − [φ dS_liq/dP + (1−φ) dS_sol/dP] dP/dr (energy.c::GetMixingHeatFlux lines 307-309). Exposing the phase-boundary P-derivatives here lets Aragog match SPIDER's formula exactly without resorting to un-truncated gphi arithmetic.

Source code in src/aragog/eos/entropy.py
587
588
589
590
591
592
593
594
595
596
597
598
def solidus_entropy_dP(self, P: npt.NDArray | float) -> npt.NDArray:
    """dS_sol/dP at the given pressure(s), in J/(kg·K·Pa).

    Needed by the SPIDER-parity mixing-flux formula in
    ``entropy_state.update``. SPIDER computes Jmix via a bracket
    expression ``dS/dr − [φ dS_liq/dP + (1−φ) dS_sol/dP] dP/dr``
    (``energy.c::GetMixingHeatFlux`` lines 307-309). Exposing the
    phase-boundary P-derivatives here lets Aragog match SPIDER's
    formula exactly without resorting to un-truncated gphi
    arithmetic.
    """
    return np.asarray(self._solidus['dinterp'](P), dtype=float)

specific_enthalpy(P, S)

EOS-consistent specific enthalpy h(P, S) [J/kg].

Bilinear interpolation on the precomputed table built in _build_enthalpy_table. Inputs outside the table P or S range are clamped to the nearest table edge and logged as a warning; pass strict_range=True to the constructor to raise a RuntimeError instead of returning the clamped value.

Parameters:

Name Type Description Default
P array or float

Pressure [Pa].

required
S array or float

Entropy [J/kg/K].

required

Returns:

Type Description
ndarray

Specific enthalpy [J/kg], same shape as broadcast(P, S).

Source code in src/aragog/eos/entropy.py
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
def specific_enthalpy(
    self,
    P: npt.NDArray | float,
    S: npt.NDArray | float,
) -> npt.NDArray:
    """EOS-consistent specific enthalpy h(P, S) [J/kg].

    Bilinear interpolation on the precomputed table built in
    ``_build_enthalpy_table``. Inputs outside the table P or S
    range are clamped to the nearest table edge and logged as a
    warning; pass ``strict_range=True`` to the constructor to raise
    a ``RuntimeError`` instead of returning the clamped value.

    Parameters
    ----------
    P : array or float
        Pressure [Pa].
    S : array or float
        Entropy [J/kg/K].

    Returns
    -------
    ndarray
        Specific enthalpy [J/kg], same shape as broadcast(P, S).
    """
    P = np.asarray(P, dtype=float)
    S = np.asarray(S, dtype=float)
    self._check_entropy_range(
        S,
        self._h_grid_S[0],
        self._h_grid_S[-1],
        'specific_enthalpy',
    )
    P_clamped = np.clip(P, self._h_grid_P[0], self._h_grid_P[-1])
    S_clamped = np.clip(S, self._h_grid_S[0], self._h_grid_S[-1])
    pts = np.column_stack([P_clamped.ravel(), S_clamped.ravel()])
    return self._h_interp(pts).reshape(P_clamped.shape)

temperature(P, S)

Temperature T(P, S) [K].

Source code in src/aragog/eos/entropy.py
948
949
950
def temperature(self, P: npt.NDArray | float, S: npt.NDArray | float) -> npt.NDArray:
    """Temperature T(P, S) [K]."""
    return self._lookup_phase_weighted('temperature', P, S)

temperature_scalar(P, S)

Temperature T(P, S) for a single point, pure Python.

Uses the same Lever Rule blending as temperature() but with pure-Python arithmetic throughout. Safe for use inside scipy.optimize callbacks on numpy >= 2.4.

Parameters:

Name Type Description Default
P float

Pressure [Pa].

required
S float

Entropy [J/kg/K].

required

Returns:

Type Description
float

Temperature [K].

Source code in src/aragog/eos/entropy.py
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
def temperature_scalar(self, P: float, S: float) -> float:
    """Temperature T(P, S) for a single point, pure Python.

    Uses the same Lever Rule blending as ``temperature()`` but
    with pure-Python arithmetic throughout. Safe for use inside
    scipy.optimize callbacks on numpy >= 2.4.

    Parameters
    ----------
    P : float
        Pressure [Pa].
    S : float
        Entropy [J/kg/K].

    Returns
    -------
    float
        Temperature [K].
    """
    phi = self._melt_fraction_scalar(P, S)

    solid_table = self._tables['temperature_solid']
    melt_table = self._tables['temperature_melt']

    # Lever Rule: in the mushy zone, evaluate at phase boundary
    # entropies; outside, evaluate at actual S.
    if 0.0 < phi < 1.0:
        S_for_solid = self._solidus_entropy_scalar(P)
        S_for_melt = self._liquidus_entropy_scalar(P)
    else:
        S_for_solid = S
        S_for_melt = S

    # Only check a branch's S where it actually has nonzero weight
    # in the blend below (phi < 1.0 for solid, phi > 0.0 for melt).
    # Written as "not phi >= ..." so a NaN phi (comparisons always
    # False) still passes through the check instead of being masked.
    solid_used = not (phi >= 1.0)
    melt_used = not (phi <= 0.0)
    self._check_entropy_range(
        S_for_solid if solid_used else solid_table['S_list'][0],
        solid_table['S_list'][0],
        solid_table['S_list'][-1],
        'temperature_scalar (solid table lookup)',
    )
    self._check_entropy_range(
        S_for_melt if melt_used else melt_table['S_list'][0],
        melt_table['S_list'][0],
        melt_table['S_list'][-1],
        'temperature_scalar (melt table lookup)',
    )

    # Clamp to each table's own range
    S_sol_clamped = max(
        solid_table['S_list'][0], min(S_for_solid, solid_table['S_list'][-1])
    )
    S_melt_clamped = max(melt_table['S_list'][0], min(S_for_melt, melt_table['S_list'][-1]))
    P_sol_clamped = max(solid_table['P_list'][0], min(P, solid_table['P_list'][-1]))
    P_melt_clamped = max(melt_table['P_list'][0], min(P, melt_table['P_list'][-1]))

    val_solid = _interp_bilinear_scalar(
        solid_table['P_list'],
        solid_table['S_list'],
        solid_table['values'],
        P_sol_clamped,
        S_sol_clamped,
    )
    val_melt = _interp_bilinear_scalar(
        melt_table['P_list'],
        melt_table['S_list'],
        melt_table['values'],
        P_melt_clamped,
        S_melt_clamped,
    )

    # NaN-safe phase-weighted blend. phi > 0.0 and phi < 1.0 are both
    # False when phi is NaN, so without this check a NaN phi would
    # silently fall through to result = 0.0 instead of NaN.
    if math.isnan(phi):
        return math.nan
    result = 0.0
    if phi > 0.0:
        result += phi * val_melt
    if phi < 1.0:
        result += (1.0 - phi) * val_solid
    return result

thermal_expansivity(P, S)

Thermal expansivity alpha(P, S) [1/K].

When P-S thermal_exp tables are available (generated alongside SPIDER tables), uses them directly for exact parity with SPIDER. Otherwise, derives alpha from the thermodynamic identity: alpha = rho * Cp * |dT/dP|_S| / T.

Source code in src/aragog/eos/entropy.py
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
def thermal_expansivity(
    self, P: npt.NDArray | float, S: npt.NDArray | float
) -> npt.NDArray:
    """Thermal expansivity alpha(P, S) [1/K].

    When P-S thermal_exp tables are available (generated alongside
    SPIDER tables), uses them directly for exact parity with SPIDER.
    Otherwise, derives alpha from the thermodynamic identity:
    alpha = rho * Cp * |dT/dP|_S| / T.
    """
    if self._has_alpha_tables:
        return self._lookup_phase_weighted('thermal_exp', P, S)

    T = self.temperature(P, S)
    rho = self.density(P, S)
    Cp = self.heat_capacity(P, S)
    dTdPs_val = self.dTdPs(P, S)
    alpha = rho * Cp * np.abs(dTdPs_val) / np.maximum(T, 1.0)
    return alpha

thermal_expansivity_composite_blend(P, S, width=0.01)

Composite two-phase thermal expansivity, matching SPIDER.

Mirrors SPIDER's eos_composite.c:246 formula:

alpha_mix = (rho_solid - rho_melt) / (T_liq - T_sol) / rho

where rho is the harmonic-mean composite density. This effective alpha captures the density change from the phase transition (Clausius-Clapeyron contribution), which dominates over the single-phase alpha by a factor of ~5 in the deep mushy zone. Outside the mushy band the result reduces to the pure-phase alpha from the P-S tables via tanh smoothing (SPIDER eos_composite.c:279).

Parameters:

Name Type Description Default
P array or float

Pressure [Pa].

required
S array or float

Entropy [J/kg/K].

required
width float

Tanh smoothing width across the phase boundary.

0.01

Returns:

Type Description
ndarray

Thermal expansivity [1/K].

Source code in src/aragog/eos/entropy.py
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
def thermal_expansivity_composite_blend(
    self,
    P: npt.NDArray | float,
    S: npt.NDArray | float,
    width: float = 0.01,
) -> npt.NDArray:
    """Composite two-phase thermal expansivity, matching SPIDER.

    Mirrors SPIDER's ``eos_composite.c:246`` formula:

        alpha_mix = (rho_solid - rho_melt) / (T_liq - T_sol) / rho

    where ``rho`` is the harmonic-mean composite density. This
    effective alpha captures the density change from the phase
    transition (Clausius-Clapeyron contribution), which dominates
    over the single-phase alpha by a factor of ~5 in the deep
    mushy zone. Outside the mushy band the result reduces to the
    pure-phase alpha from the P-S tables via tanh smoothing
    (SPIDER ``eos_composite.c:279``).

    Parameters
    ----------
    P : array or float
        Pressure [Pa].
    S : array or float
        Entropy [J/kg/K].
    width : float
        Tanh smoothing width across the phase boundary.

    Returns
    -------
    ndarray
        Thermal expansivity [1/K].
    """
    P = np.asarray(P, dtype=float)
    S = np.asarray(S, dtype=float)

    # Phase-boundary quantities at this pressure
    S_sol = np.asarray(self.solidus_entropy(P)).reshape(P.shape)
    S_liq = np.asarray(self.liquidus_entropy(P)).reshape(P.shape)
    T_sol = np.asarray(self._lookup_at_phase_boundary('temperature', P, 'solid')).reshape(
        P.shape
    )
    T_liq = np.asarray(self._lookup_at_phase_boundary('temperature', P, 'melt')).reshape(
        P.shape
    )
    rho_sol = np.asarray(self._lookup_at_phase_boundary('density', P, 'solid')).reshape(
        P.shape
    )
    rho_liq = np.asarray(self._lookup_at_phase_boundary('density', P, 'melt')).reshape(
        P.shape
    )

    # Composite density (harmonic mean, SPIDER eos_composite.c:236)
    phi = self._melt_fraction(P, S)
    inv_rho = phi / np.maximum(rho_liq, 1.0) + (1.0 - phi) / np.maximum(rho_sol, 1.0)
    rho_comp = 1.0 / np.maximum(inv_rho, 1e-30)

    # Mixed-phase alpha (SPIDER eos_composite.c:246)
    dT = T_liq - T_sol
    safe_dT = np.where(np.abs(dT) > 1e-10, dT, 1e-10)
    alpha_mix = (rho_sol - rho_liq) / safe_dT / rho_comp

    # Pure-phase alpha from the linear-blend tables
    alpha_pure = self._lookup_phase_weighted('thermal_exp', P, S)

    # SPIDER-parity tanh smoothing (same as heat_capacity_latent_blend)
    dS = S_liq - S_sol
    safe_dS = np.where(np.abs(dS) > 1e-10, dS, 1e-10)
    gphi = (S - S_sol) / safe_dS

    w = max(width, 1e-6)
    smooth = np.where(
        gphi > 0.5,
        0.5 * (1.0 + np.tanh((1.0 - gphi) / w)),
        0.5 * (1.0 + np.tanh(gphi / w)),
    )

    return smooth * alpha_mix + (1.0 - smooth) * alpha_pure

EntropyPhaseEvaluator(entropy_eos, gravitational_acceleration, rheological_transition_melt_fraction=0.4, rheological_transition_width=0.15, viscosity_solid=1e+21, viscosity_liquid=0.1, grain_size=0.001, thermal_conductivity_solid=4.0, thermal_conductivity_liquid=2.0, cp_blend='latent', separation_viscosity=SEPARATION_VISCOSITY_DEFAULT, matprop_smooth_width=0.0, const_properties=False, const_rho=4000.0, const_Cp=1000.0, const_alpha=1e-05, const_cond=4.0, const_log10visc=2.0, const_T_ref=3500.0, const_S_ref=3000.0)

Phase evaluator using entropy as the state variable.

Implements the same interface as MixedPhaseEvaluator / CompositePhaseEvaluator, but all lookups use (P, S) from the EntropyEOS tables. No solidus/liquidus root-finding is needed.

Parameters:

Name Type Description Default
entropy_eos EntropyEOS

Loaded P-S EOS tables.

required
gravitational_acceleration float or array

Gravitational acceleration profile [m/s^2].

required
rheological_transition_melt_fraction float

Melt fraction at which viscosity transitions from solid to liquid.

0.4
rheological_transition_width float

Width of the tanh viscosity transition.

0.15
viscosity_solid float

Reference solid viscosity [Pa s].

1e+21
viscosity_liquid float

Reference liquid viscosity [Pa s].

0.1
grain_size float

Grain size for permeability calculation [m].

0.001
separation_viscosity str

Drag viscosity source for gravitational separation: 'melt' (single-phase liquid viscosity, matches SPIDER's GetGravitationalHeatFlux) or 'mixture' (the rheological-transition-blended bulk viscosity). Default 'melt'.

SEPARATION_VISCOSITY_DEFAULT
latent_heat_constant float

Latent heat of fusion [J/kg]. Used for gravitational separation flux.

required
Source code in src/aragog/eos/entropy_phase.py
 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
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def __init__(
    self,
    entropy_eos: EntropyEOS,
    gravitational_acceleration: FloatOrArray,
    rheological_transition_melt_fraction: float = 0.4,
    rheological_transition_width: float = 0.15,
    viscosity_solid: float = 1e21,
    viscosity_liquid: float = 1e-1,
    grain_size: float = 1e-3,
    thermal_conductivity_solid: float = 4.0,
    thermal_conductivity_liquid: float = 2.0,
    cp_blend: str = 'latent',
    separation_viscosity: str = SEPARATION_VISCOSITY_DEFAULT,
    matprop_smooth_width: float = 0.0,
    const_properties: bool = False,
    const_rho: float = 4000.0,
    const_Cp: float = 1000.0,
    const_alpha: float = 1e-5,
    const_cond: float = 4.0,
    const_log10visc: float = 2.0,
    const_T_ref: float = 3500.0,
    const_S_ref: float = 3000.0,
):
    self._eos = entropy_eos
    self._g = gravitational_acceleration
    self._phi_rheo = rheological_transition_melt_fraction
    self._phi_width = rheological_transition_width
    self._visc_solid = viscosity_solid
    self._visc_liquid = viscosity_liquid
    self._grain_size = grain_size
    self._k_solid = thermal_conductivity_solid
    self._k_liquid = thermal_conductivity_liquid
    self._matprop_smooth_width = matprop_smooth_width
    # Constant-properties mode (matches SPIDER -use_const_properties)
    self._const_properties = const_properties
    self._const_rho = const_rho
    self._const_Cp = const_Cp
    self._const_alpha = const_alpha
    self._const_cond = const_cond
    self._const_log10visc = const_log10visc
    self._const_T_ref = const_T_ref
    self._const_S_ref = const_S_ref
    # 'latent' = SPIDER-parity, latent-heat-augmented Cp
    # 'linear' = pure-phase linear blend (no latent term)
    if cp_blend not in ('latent', 'linear'):
        raise ValueError(f"cp_blend must be 'latent' or 'linear', got {cp_blend!r}")
    self._cp_blend = cp_blend
    if separation_viscosity not in SEPARATION_VISCOSITY_MODES:
        raise ValueError(
            f'separation_viscosity must be one of {SEPARATION_VISCOSITY_MODES}, '
            f'got {separation_viscosity!r}'
        )
    self._separation_viscosity = separation_viscosity

    # State arrays (set by set_entropy / set_pressure / update)
    self.entropy: npt.NDArray = np.array([])
    self.pressure: npt.NDArray = np.array([])

    # Cached properties (computed on update)
    self._temperature: npt.NDArray = np.array([])
    self._density: npt.NDArray = np.array([])
    self._heat_capacity: npt.NDArray = np.array([])
    self._thermal_expansivity: npt.NDArray = np.array([])
    self._dTdPs_val: npt.NDArray = np.array([])
    self._melt_fraction: npt.NDArray = np.array([])
    self._viscosity_val: npt.NDArray = np.array([])
    self._thermal_conductivity_val: npt.NDArray = np.array([])

capacitance()

Capacitance for the entropy equation: rho * T [kg K / m^3].

The entropy equation is rho * T * dS/dt = div(F).

Source code in src/aragog/eos/entropy_phase.py
601
602
603
604
605
606
def capacitance(self) -> npt.NDArray:
    """Capacitance for the entropy equation: rho * T [kg K / m^3].

    The entropy equation is rho * T * dS/dt = div(F).
    """
    return self._density * self._temperature

dTdPs()

Adiabatic temperature gradient dT/dP|_S [K/Pa].

Source code in src/aragog/eos/entropy_phase.py
453
454
455
def dTdPs(self) -> npt.NDArray:
    """Adiabatic temperature gradient dT/dP|_S [K/Pa]."""
    return self._dTdPs_val

dTdrs()

Adiabatic temperature gradient dT/dr|_S [K/m].

dT/dr|_S = -g * alpha * T / Cp

Source code in src/aragog/eos/entropy_phase.py
457
458
459
460
461
462
def dTdrs(self) -> npt.NDArray:
    """Adiabatic temperature gradient dT/dr|_S [K/m].

    dT/dr|_S = -g * alpha * T / Cp
    """
    return -self._g * self._thermal_expansivity * self._temperature / self._heat_capacity

delta_specific_volume()

Specific volume difference between solid and liquid [m^3/kg].

Source code in src/aragog/eos/entropy_phase.py
591
592
593
594
595
596
597
def delta_specific_volume(self) -> FloatOrArray:
    """Specific volume difference between solid and liquid [m^3/kg]."""
    if self._const_properties:
        return np.zeros_like(self._density)
    rho_s = self._eos._lookup_at_phase_boundary('density', self.pressure, 'solid')
    rho_l = self._eos._lookup_at_phase_boundary('density', self.pressure, 'melt')
    return 1.0 / np.maximum(rho_l, 1.0) - 1.0 / np.maximum(rho_s, 1.0)

latent_heat()

Latent heat L(P) = T_fus × (S_liq - S_sol) [J/kg].

P-dependent from EOS tables, matching SPIDER convention.

Source code in src/aragog/eos/entropy_phase.py
476
477
478
479
480
481
def latent_heat(self) -> FloatOrArray:
    """Latent heat L(P) = T_fus × (S_liq - S_sol) [J/kg].

    P-dependent from EOS tables, matching SPIDER convention.
    """
    return self._latent_heat_val

phase_boundary_densities()

Solid and melt densities at the phase boundary at the node pressure [kg/m^3].

Under const_properties there is no phase contrast and both equal the node density.

Source code in src/aragog/eos/entropy_phase.py
549
550
551
552
553
554
555
556
557
558
559
def phase_boundary_densities(self) -> tuple[FloatOrArray, FloatOrArray]:
    """Solid and melt densities at the phase boundary at the node pressure [kg/m^3].

    Under ``const_properties`` there is no phase contrast and both equal
    the node density.
    """
    if self._const_properties:
        return self._density.copy(), self._density.copy()
    rho_s = self._eos._lookup_at_phase_boundary('density', self.pressure, 'solid')
    rho_l = self._eos._lookup_at_phase_boundary('density', self.pressure, 'melt')
    return rho_s, rho_l

porosity()

Density-derived porosity (rho_s - rho) / (rho_s - rho_l) [1].

This is the porosity :meth:relative_velocity uses, except under const_properties, where there is no phase contrast, the mantle counts as fully liquid so this is one everywhere, and :meth:relative_velocity is zero. It is soft-clipped to [0, 1] with a width of 1e-3: its range is [-2.5e-7, 1] and it is 4.9975e-4 where the node density equals rho_s. It is not the melt fraction: a solid node whose density is below rho_s can give any value, and where rho_s <= rho_l a mush is denser than rho_s, so this is about 0 although the node holds melt. Do not use it for melt drainage where rho_s <= rho_l: test the sign of the contrast first.

Source code in src/aragog/eos/entropy_phase.py
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
def porosity(self) -> FloatOrArray:
    """Density-derived porosity ``(rho_s - rho) / (rho_s - rho_l)`` [1].

    This is the porosity :meth:`relative_velocity` uses, except under
    ``const_properties``, where there is no phase contrast, the mantle counts as
    fully liquid so this is one everywhere, and :meth:`relative_velocity` is zero.
    It is soft-clipped to [0, 1] with a width of 1e-3: its range is [-2.5e-7, 1]
    and it is 4.9975e-4 where the node density equals ``rho_s``. It is not the
    melt fraction: a solid node whose density is below ``rho_s`` can give any
    value, and where ``rho_s <= rho_l`` a mush is denser than ``rho_s``, so this
    is about 0 although the node holds melt. Do not use it for melt drainage
    where ``rho_s <= rho_l``: test the sign of the contrast first.
    """
    if self._const_properties:
        return np.ones_like(self._density)
    return self._porosity_from(*self.phase_boundary_densities())

relative_velocity()

Melt-solid relative velocity for gravitational separation [m/s].

Returns zero in const_properties mode (no phase contrast).

Uses the Abe (1995) gravitational separation velocity with the three-regime permeability of Bower et al. (2018) section 2.1, based on the density-derived porosity (see :meth:porosity), matching SPIDER's GetGravitationalHeatFlux in energy.c.

Regimes (F = K/porosity, the quantity multiplying delta_rho*g/eta): 1. Blake-Kozeny-Carman (low porosity): F = d^2 por^2 / ((1-por)^2 * 1000) 2. Rumpf-Gupte (intermediate): F = d^2 por^4.5 * (5/7) 3. Stokes settling (high porosity): F = d^2 * 2/9

The drag viscosity is set by separation_viscosity: 'melt' uses the fixed single-phase liquid viscosity, matching SPIDER's GetGravitationalHeatFlux; 'mixture' uses the rheological-transition-blended bulk viscosity, so settling locks up through the same phi_rheo transition that sets the bulk rheology.

The porosity denominator rho_s - rho_l is floored at about 1 kg/m^3, which acts where the tabulated densities cross (the melt locally denser or lighter by less than 1 kg/m^3). There the porosity is (rho_s - rho) / (1 kg/m^3) clipped to [0, 1]: about 0 where the node density is at or above rho_s, 1 where it is more than 1 kg/m^3 below, and intermediate in between. The separation direction is fixed by the non-negative contrast |rho_l - rho_s| in v_rel and cannot reverse.

Source code in src/aragog/eos/entropy_phase.py
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
def relative_velocity(self) -> FloatOrArray:
    """Melt-solid relative velocity for gravitational separation [m/s].

    Returns zero in const_properties mode (no phase contrast).

    Uses the Abe (1995) gravitational separation velocity with the
    three-regime permeability of Bower et al. (2018) section 2.1, based
    on the density-derived porosity (see :meth:`porosity`), matching
    SPIDER's GetGravitationalHeatFlux in energy.c.

    Regimes (F = K/porosity, the quantity multiplying delta_rho*g/eta):
    1. Blake-Kozeny-Carman (low porosity): F = d^2 por^2 / ((1-por)^2 * 1000)
    2. Rumpf-Gupte (intermediate): F = d^2 por^4.5 * (5/7)
    3. Stokes settling (high porosity): F = d^2 * 2/9

    The drag viscosity is set by ``separation_viscosity``: ``'melt'``
    uses the fixed single-phase liquid viscosity, matching SPIDER's
    ``GetGravitationalHeatFlux``; ``'mixture'`` uses the
    rheological-transition-blended bulk viscosity, so settling locks
    up through the same phi_rheo transition that sets the bulk
    rheology.

    The porosity denominator ``rho_s - rho_l`` is floored at about 1 kg/m^3,
    which acts where the tabulated densities cross (the melt locally denser
    or lighter by less than 1 kg/m^3). There the porosity is
    ``(rho_s - rho) / (1 kg/m^3)`` clipped to [0, 1]: about 0 where the node
    density is at or above ``rho_s``, 1 where it is more than 1 kg/m^3
    below, and intermediate in between. The separation direction is fixed by the
    non-negative contrast ``|rho_l - rho_s|`` in ``v_rel`` and cannot
    reverse.
    """
    if self._const_properties:
        return np.zeros_like(self._density)
    rho_s, rho_l = self.phase_boundary_densities()
    delta_rho = rho_l - rho_s  # typically negative (melt lighter)
    g = self._g
    # Explicit dispatch: a third mode added to SEPARATION_VISCOSITY_MODES
    # must fail here rather than silently fall back to 'melt'.
    if self._separation_viscosity == 'mixture':
        eta_l = self._viscosity_val
    elif self._separation_viscosity == 'melt':
        eta_l = self._visc_liquid
    else:
        raise ValueError(f'unhandled separation_viscosity {self._separation_viscosity!r}')

    porosity = self._porosity_from(rho_s, rho_l)
    F = mobility_function(porosity, self._grain_size)

    # Relative velocity: v = |delta_rho| * g * F / eta_liquid
    # Sign convention: positive = outward (melt rising, solid sinking)
    # Smoothed |delta_rho| via sqrt(x^2 + eps^2); eps tiny compared
    # to physical delta_rho ~ -500 kg/m^3, so bulk result unchanged.
    abs_drho = np.sqrt(delta_rho * delta_rho + 1.0e-12)
    v_rel = abs_drho * g * F / np.maximum(eta_l, 1e-10)

    return v_rel

set_entropy(entropy)

Set the entropy profile.

Source code in src/aragog/eos/entropy_phase.py
168
169
170
def set_entropy(self, entropy: npt.NDArray) -> None:
    """Set the entropy profile."""
    self.entropy = np.asarray(entropy, dtype=float)

set_pressure(pressure)

Set the pressure profile.

Source code in src/aragog/eos/entropy_phase.py
179
180
181
def set_pressure(self, pressure: npt.NDArray) -> None:
    """Set the pressure profile."""
    self.pressure = np.asarray(pressure, dtype=float)

set_temperature(temperature)

Not used in entropy mode. Use set_entropy() instead.

Source code in src/aragog/eos/entropy_phase.py
172
173
174
175
176
177
def set_temperature(self, temperature: npt.NDArray) -> None:
    """Not used in entropy mode. Use set_entropy() instead."""
    raise NotImplementedError(
        'EntropyPhaseEvaluator uses set_entropy(), not set_temperature(). '
        'This evaluator cannot be used with the T-based solver.'
    )

temperature()

Temperature from EOS lookup (not a state variable).

Source code in src/aragog/eos/entropy_phase.py
449
450
451
def temperature(self) -> FloatOrArray:
    """Temperature from EOS lookup (not a state variable)."""
    return self._temperature

update()

Recompute all cached properties from current (P, S).

Two modes: - const_properties=True: analytical T(S) with constant rho, Cp, alpha, k, visc. Matches SPIDER's -use_const_properties. No EOS table lookups. phi=1 always (no phase transitions). - const_properties=False (default): single-pass EOS evaluation mirroring SPIDER's EOSEval_Composite_TwoPhase.

Source code in src/aragog/eos/entropy_phase.py
183
184
185
186
187
188
189
190
191
192
193
194
195
def update(self) -> None:
    """Recompute all cached properties from current (P, S).

    Two modes:
    - const_properties=True: analytical T(S) with constant rho, Cp,
      alpha, k, visc. Matches SPIDER's -use_const_properties. No EOS
      table lookups. phi=1 always (no phase transitions).
    - const_properties=False (default): single-pass EOS evaluation
      mirroring SPIDER's EOSEval_Composite_TwoPhase.
    """
    if self._const_properties:
        return self._update_const()
    return self._update_eos()

mobility_function(porosity, grain_size)

Three-regime permeability over porosity, F = k(phi) / phi [m^2].

The melt-solid relative velocity of gravitational separation is |delta_rho| g F / eta (Abe 1995; Bower et al. 2018, section 2.1, Eqs. 13a-c). The regimes are blended with tanh weights centred on the equal-density-ratio crossings 0.0769452 (Blake-Kozeny-Carman to Rumpf-Gupte) and 0.771462 (Rumpf-Gupte to Stokes settling); the blend widths 0.02 and 0.05 are numerical smoothing, not physics. Inside the upper blend F exceeds the Stokes value by up to 4.4 percent (at porosity 0.805).

Parameters:

Name Type Description Default
porosity float or array

Porosity [1], as :meth:EntropyPhaseEvaluator.porosity returns it.

required
grain_size float

Grain size a [m]. It enters as a**2 with the Stokes prefactor 2/9 (Abe 1995, Eq. 39; Bower et al. 2018, Eqs. 12 and 13a), which is Stokes' law for a sphere of radius a, so it acts as the grain radius.

required

Returns:

Type Description
float or array

F [m^2], non-negative: d^2 phi^2 / (1000 (1 - phi)^2) at low porosity, (5/7) d^2 phi^4.5 at intermediate porosity and (2/9) d^2 at high porosity, with d the grain size.

Source code in src/aragog/eos/entropy_phase.py
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def mobility_function(porosity: FloatOrArray, grain_size: float) -> FloatOrArray:
    """Three-regime permeability over porosity, ``F = k(phi) / phi`` [m^2].

    The melt-solid relative velocity of gravitational separation is
    ``|delta_rho| g F / eta`` (Abe 1995; Bower et al. 2018, section 2.1,
    Eqs. 13a-c). The regimes are blended with tanh weights centred on the
    equal-density-ratio crossings 0.0769452 (Blake-Kozeny-Carman to
    Rumpf-Gupte) and 0.771462 (Rumpf-Gupte to Stokes settling); the blend
    widths 0.02 and 0.05 are numerical smoothing, not physics. Inside the upper
    blend ``F`` exceeds the Stokes value by up to 4.4 percent (at porosity 0.805).

    Parameters
    ----------
    porosity : float or array
        Porosity [1], as :meth:`EntropyPhaseEvaluator.porosity` returns it.
    grain_size : float
        Grain size ``a`` [m]. It enters as ``a**2`` with the Stokes prefactor 2/9
        (Abe 1995, Eq. 39; Bower et al. 2018, Eqs. 12 and 13a), which is Stokes' law
        for a sphere of radius ``a``, so it acts as the grain radius.

    Returns
    -------
    float or array
        ``F`` [m^2], non-negative: ``d^2 phi^2 / (1000 (1 - phi)^2)`` at low
        porosity, ``(5/7) d^2 phi^4.5`` at intermediate porosity and
        ``(2/9) d^2`` at high porosity, with ``d`` the grain size.
    """
    d = grain_size
    por = np.maximum(porosity, 1e-20)
    one_m_por = np.maximum(1.0 - porosity, 1e-20)
    F_bkc = d**2 * por**2 / (one_m_por**2 * 1000.0)
    F_rg = d**2 * por**4.5 * (5.0 / 7.0)
    F_stokes = d**2 * 2.0 / 9.0
    # zeta_1 sits 1.7e-5 below the exact BKC-RG crossing (0.02 percent); zeta_2 is exact.
    w_rg = tanh_weight(porosity, 0.0769452, 0.02)
    w_stokes = tanh_weight(porosity, 0.771462, 0.05)
    F = (1.0 - w_rg) * F_bkc + (w_rg - w_stokes) * F_rg + w_stokes * F_stokes
    return np.maximum(F, 0.0)

consistency

Thermodynamic consistency residuals of the SPIDER P-S tables of one phase.

Each identity is written as a relative residual that is 0 for an exact equation of state. The derivatives are central differences on the table's own (P, S) coordinates with a step of k grid cells; a stencil counts only when all its cells hold numbers.

check(tables, eps)

Test each cell against the Richardson bound of each identity.

A derivative identity fails where |R_x(h)| > floor_x + FACTOR * est, with R_x(kh) = (4 R_k - R_2k) / 3, floor_x = (4 floor_1 + floor_2) / 3 and est = |R_x(2h) - R_x(h)| / 15, the measured h^4 term of R_x(h) at the cell; the adiabat identity fails where |R| > floor. The floor uses eps_eff = max(eps, EPS_FLOOR): every table is checked at the 9-digit floor at best, so a single value off by about 1e-8 (relative) or less is out of reach. The floor also covers the rounding of the stored P and S coordinates. A derivative identity is tested only where 4 cells on each side along its axis hold values; NaN marks a missing value, and a stored 0 or inf is a value. Where all values an identity reads are present but its residual or bound is not finite, the cell fails; an identity is skipped only when no cell holds all values it reads. On a grid that is not uniform the h^4 estimate holds only where the spacing changes smoothly. On a grid linear in P, the h^4 term of dTdP grows as P / dP falls: on the 199 x 201 test grid a 1e-6 temperature fault is found at all 4 cells 1 and 2 steps away from P / dP of about 27, and at none below about 10.

Parameters:

Name Type Description Default
tables dict

As for residuals.

required
eps float

Relative precision of the stored columns (0.5 x 10^(1 - n) for n significant digits).

required

Returns:

Type Description
dict

Identity name -> dict with rx (the Richardson residual, defined also at untested cells), bound, tested (both finite), fail (tested and above the bound, or with all values present and a residual or bound that is not finite) arrays, and skipped, True when no cell holds all values the identity reads.

Raises:

Type Description
ValueError

If eps is not a real number in [0, 1), a table, its P, S or values is missing, a table is not a mapping or off the grid, or every identity is skipped.

Source code in src/aragog/eos/consistency.py
210
211
212
213
214
215
216
217
218
219
220
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
252
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
def check(tables, eps):
    """Test each cell against the Richardson bound of each identity.

    A derivative identity fails where |R_x(h)| > floor_x + FACTOR * est, with R_x(kh) =
    (4 R_k - R_2k) / 3, floor_x = (4 floor_1 + floor_2) / 3 and est = |R_x(2h) - R_x(h)| / 15,
    the measured h^4 term of R_x(h) at the cell; the adiabat identity fails where |R| > floor.
    The floor uses eps_eff = max(eps, EPS_FLOOR): every table is checked at the 9-digit floor
    at best, so a single value off by about 1e-8 (relative) or less is out of reach.
    The floor also covers the rounding of the stored P and S coordinates. A derivative identity
    is tested only where 4 cells on each side along its axis hold values; NaN marks a missing
    value, and a stored 0 or inf is a value. Where all values an identity reads are present but
    its residual or bound is not finite, the cell fails; an identity is skipped only when no
    cell holds all values it reads. On a grid that is not uniform the h^4 estimate holds only
    where the spacing changes smoothly. On a grid linear in P, the h^4 term of dTdP grows as
    P / dP falls: on the 199 x 201 test grid a 1e-6 temperature fault is found at all 4 cells 1
    and 2 steps away from P / dP of about 27, and at none below about 10.

    Parameters
    ----------
    tables : dict
        As for ``residuals``.
    eps : float
        Relative precision of the stored columns (0.5 x 10^(1 - n) for n significant digits).

    Returns
    -------
    dict
        Identity name -> dict with ``rx`` (the Richardson residual, defined also at untested
        cells), ``bound``, ``tested`` (both finite), ``fail`` (tested and above the bound, or
        with all values present and a residual or bound that is not finite) arrays, and
        ``skipped``, True when no cell holds all values the identity reads.

    Raises
    ------
    ValueError
        If eps is not a real number in [0, 1), a table, its P, S or values is missing, a table
        is not a mapping or off the grid, or every identity is skipped.
    """
    eps = _eps(eps)
    r = {k: residuals(tables, k) for k in (1, 2, 4)}
    ones = {
        q: {
            **tables[q],
            'values': np.where(np.isnan(np.asarray(tables[q]['values'], float)), np.nan, 1.0),
        }
        for q in QUANTITIES
    }
    present = {k: residuals(ones, k) for k in (1, 2, 4)}  # finite where every value is present
    f1, f2 = floors(tables, eps, 1), floors(tables, eps, 2)
    out = {}
    with np.errstate(invalid='ignore'):
        for n in IDENTITIES:
            if n == 'adiabat':
                rx, bound, have = r[1][n], f1[n], np.isfinite(present[1][n])
            else:
                rx = (4 * r[1][n] - r[2][n]) / 3
                est = np.abs((4 * r[2][n] - r[4][n]) / 3 - rx) / 15
                bound = (4 * f1[n] + f2[n]) / 3 + FACTOR * est
                have = np.isfinite(present[1][n] + present[2][n] + present[4][n])
            tested = np.isfinite(rx) & np.isfinite(bound)
            out[n] = {
                'rx': rx,
                'bound': bound,
                'tested': tested,
                'fail': (tested & (np.abs(rx) > bound)) | (have & ~tested),
                'skipped': not have.any(),
            }
    if all(v['skipped'] for v in out.values()):
        raise ValueError(f'no cell tested for {", ".join(out)}')
    return out

derivative(f, x, axis, k)

d f / d x along axis with a step of k cells, NaN where a stencil cell is missing.

Parameters:

Name Type Description Default
f ndarray

Values on the (P, S) grid.

required
x ndarray

Coordinates along axis, increasing.

required
axis int

0 for P, 1 for S.

required
k int

Stencil step in grid cells.

required
Source code in src/aragog/eos/consistency.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
def derivative(f, x, axis, k):
    """d f / d x along ``axis`` with a step of ``k`` cells, NaN where a stencil cell is missing.

    Parameters
    ----------
    f : ndarray
        Values on the (P, S) grid.
    x : ndarray
        Coordinates along ``axis``, increasing.
    axis : int
        0 for P, 1 for S.
    k : int
        Stencil step in grid cells.
    """
    return _stencil(f, x, axis, k, -1.0)

floors(tables, eps, k=1)

Round-off bound of each residual at step k for values stored to relative eps.

For R + 1 = D / Q the bound is (E / |Q| + |D / Q| q) eps + |D / Q| A, with E the error of the central difference from its two stored ends, q the relative error of Q, and A = (e[i+k] + e[i-k]) / (x[i+k] - x[i-k]) from the rounding error e of each coordinate: its departure from the fit of a regular axis (within AXIS_FIT steps plus eps |x| of a uniform or geometric grid), or eps |x| on every coordinate of any other axis. The adiabat identity, with no derivative, gets |R + 1| 5 eps from its five stored factors.

Parameters:

Name Type Description Default
tables dict

As for residuals.

required
eps float

Relative precision of the stored values.

required
k int

Stencil step in grid cells.

1

Returns:

Type Description
dict

Identity name -> floor array on the grid.

Raises:

Type Description
ValueError

As for residuals.

Source code in src/aragog/eos/consistency.py
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
197
198
199
200
def floors(tables, eps, k=1):
    """Round-off bound of each residual at step ``k`` for values stored to relative ``eps``.

    For R + 1 = D / Q the bound is (E / |Q| + |D / Q| q) eps + |D / Q| A, with E the error of
    the central difference from its two stored ends, q the relative error of Q, and A =
    (e[i+k] + e[i-k]) / (x[i+k] - x[i-k]) from the rounding error e of each coordinate: its
    departure from the fit of a regular axis (within AXIS_FIT steps plus eps |x| of a uniform
    or geometric grid), or eps |x| on every coordinate of any other axis.
    The adiabat identity, with no derivative, gets |R + 1| 5 eps from its five stored factors.

    Parameters
    ----------
    tables : dict
        As for ``residuals``.
    eps : float
        Relative precision of the stored values.
    k : int
        Stencil step in grid cells.

    Returns
    -------
    dict
        Identity name -> floor array on the grid.

    Raises
    ------
    ValueError
        As for ``residuals``.
    """
    _grid(tables)
    T, rho, cp, alpha, grad = (np.asarray(tables[q]['values'], float) for q in QUANTITIES)
    with np.errstate(divide='ignore', invalid='ignore'):
        out = {
            n: (E / np.abs(Q) + np.abs(D / Q) * q) * eps + np.abs(D / Q) * A
            for n, (D, E, Q, q, A) in _parts(tables, k, eps).items()
        }
        out['adiabat'] = np.abs(grad * rho * cp / (alpha * T)) * 5 * eps
    return out

residuals(tables, k=1)

Relative residuals of the P-S identities at stencil step k cells.

Parameters:

Name Type Description Default
tables dict

Quantity name (QUANTITIES) -> table as from aragog.eos.entropy._load_spider_ps_table (P, S in SI, values of shape (n_P, n_S)), all on one grid.

required
k int

Stencil step in grid cells.

1

Returns:

Type Description
dict

Identity name -> residual array on the grid (NaN where it cannot be formed): dTdS: (dT/dS)_P cp / T - 1; dTdP: (dT/dP)_S from T over the tabulated (dT/dP)_S - 1; maxwell: -(dln rho/dS)_P / (rho (dT/dP)_S) - 1, from (dT/dP)_S = (dv/dS)_P; adiabat: (dT/dP)_S rho cp / (alpha T) - 1 (no derivative).

Raises:

Type Description
ValueError

If tables or a table is not a mapping, a table or its P, S or values is missing, the tables are not on one (P, S) grid, an axis is not finite and strictly increasing with at least 2 points, or values do not have the shape of the grid.

Source code in src/aragog/eos/consistency.py
 86
 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
def residuals(tables, k=1):
    """Relative residuals of the P-S identities at stencil step ``k`` cells.

    Parameters
    ----------
    tables : dict
        Quantity name (``QUANTITIES``) -> table as from
        ``aragog.eos.entropy._load_spider_ps_table`` (``P``, ``S`` in SI, ``values`` of shape
        (n_P, n_S)), all on one grid.
    k : int
        Stencil step in grid cells.

    Returns
    -------
    dict
        Identity name -> residual array on the grid (NaN where it cannot be formed):
        ``dTdS``: (dT/dS)_P cp / T - 1; ``dTdP``: (dT/dP)_S from T over the tabulated (dT/dP)_S
        - 1; ``maxwell``: -(dln rho/dS)_P / (rho (dT/dP)_S) - 1, from (dT/dP)_S = (dv/dS)_P;
        ``adiabat``: (dT/dP)_S rho cp / (alpha T) - 1 (no derivative).

    Raises
    ------
    ValueError
        If tables or a table is not a mapping, a table or its P, S or values is missing, the
        tables are not on one (P, S) grid, an axis is not finite and strictly increasing with
        at least 2 points, or values do not have the shape of the grid.
    """
    P, S = _grid(tables)
    T, rho, cp, alpha, grad = (np.asarray(tables[q]['values'], float) for q in QUANTITIES)
    with np.errstate(divide='ignore', invalid='ignore'):
        return {
            'dTdS': derivative(T, S, 1, k) * cp / T - 1,
            'dTdP': derivative(T, P, 0, k) / grad - 1,
            'maxwell': -derivative(np.log(rho), S, 1, k) / (rho * grad) - 1,
            'adiabat': grad * rho * cp / (alpha * T) - 1,
        }