Skip to content

proteus.outgas.binodal

The bulk H2 split between atmosphere and melt from the H2-MgSiO3 binodal. The config loader rejects outgas.h2_binodal = true.

For how this part works, see Outgassing, rock vapour and volatile trapping.

binodal

Binodal-controlled H2 partitioning between atmosphere and magma ocean.

Wraps the Rogers+2025 H2-MgSiO3 miscibility model from Zalmoxis to redistribute H2 between the atmosphere and the dissolved (liquid mantle) reservoir after CALLIOPE's equilibrium chemistry solve.

Physics

When T_magma > T_binodal(P_surf, x_H2): H2 is miscible with MgSiO3 melt. The dissolved H2 mass fraction is controlled by thermodynamic miscibility at the mass mixing ratio (not Henry's law):

sigma = rogers2025_suppression_weight(P_surf, T_magma, w_H2, w_sil)
H2_kg_liquid = sigma * H2_kg_total
H2_kg_atm = (1 - sigma) * H2_kg_total

When immiscible (sigma ~ 0): all H2 stays in the atmosphere.

apply_binodal_h2(hf_row, config)

Apply Rogers+2025 binodal to partition H2 between atm and mantle.

Modifies hf_row in place: redistributes H2_kg_atm, H2_kg_liquid, and updates H2_bar and H2_vmr accordingly. Reads T_magma [K], P_surf [bar], M_mantle [kg], R_int [m] and gravity [m s-2] from the row, and skips the step when T_magma, M_mantle, R_int or gravity is not positive.

Does nothing if H2 is not included or has zero total mass. It does not read outgas.h2_binodal: the outgassing wrapper calls it only when that flag is true.

Parameters:

Name Type Description Default
hf_row dict

Current helpfile row with volatile mass and state keys.

required
config Config

PROTEUS configuration object.

required
Source code in src/proteus/outgas/binodal.py
 41
 42
 43
 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
 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
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def apply_binodal_h2(hf_row: dict, config: Config) -> None:
    """Apply Rogers+2025 binodal to partition H2 between atm and mantle.

    Modifies hf_row in place: redistributes H2_kg_atm, H2_kg_liquid,
    and updates H2_bar and H2_vmr accordingly. Reads ``T_magma`` [K],
    ``P_surf`` [bar], ``M_mantle`` [kg], ``R_int`` [m] and ``gravity``
    [m s-2] from the row, and skips the step when ``T_magma``, ``M_mantle``,
    ``R_int`` or ``gravity`` is not positive.

    Does nothing if H2 is not included or has zero total mass. It does not
    read ``outgas.h2_binodal``: the outgassing wrapper calls it only when that
    flag is true.

    Parameters
    ----------
    hf_row : dict
        Current helpfile row with volatile mass and state keys.
    config : Config
        PROTEUS configuration object.
    """
    from zalmoxis.binodal import rogers2025_suppression_weight

    # Check if H2 is included and has meaningful mass
    if not config.outgas.calliope.is_included('H2'):
        return

    H2_kg_total = float(hf_row.get('H2_kg_total', 0.0))
    if H2_kg_total <= 0:
        return

    # Get state variables
    T_magma = float(hf_row.get('T_magma', 0.0))
    P_surf_Pa = float(hf_row.get('P_surf', 0.0)) * 1e5  # bar -> Pa
    M_mantle = float(hf_row.get('M_mantle', 0.0))
    R_int = float(hf_row.get('R_int', 0.0))
    gravity = float(hf_row.get('gravity', 0.0))

    if T_magma <= 0 or M_mantle <= 0 or R_int <= 0 or gravity <= 0:
        log.debug('Skipping binodal: missing state variables')
        return

    # Compute mass fractions for the binary H2-MgSiO3 system.
    # w_H2 is the total H2 mass relative to the mantle + H2 total.
    w_H2 = H2_kg_total / (M_mantle + H2_kg_total)
    w_sil = 1.0 - w_H2

    # Compute suppression weight (sigma)
    sigma = rogers2025_suppression_weight(
        P_Pa=P_surf_Pa,
        T_K=T_magma,
        w_H2=w_H2,
        w_sil=w_sil,
    )

    # Partition H2 between liquid mantle (dissolved) and atmosphere
    H2_kg_liquid_new = sigma * H2_kg_total
    H2_kg_atm_new = (1.0 - sigma) * H2_kg_total

    # Store previous values for logging
    H2_kg_atm_old = float(hf_row.get('H2_kg_atm', 0.0))
    H2_kg_liquid_old = float(hf_row.get('H2_kg_liquid', 0.0))

    # Update hf_row
    hf_row['H2_kg_liquid'] = H2_kg_liquid_new
    hf_row['H2_kg_atm'] = H2_kg_atm_new
    # Solid H2 is zero (H2 does not partition into solid silicate)
    hf_row['H2_kg_solid'] = 0.0

    # The element-level hydrogen totals that the outgassing module wrote
    # before binodal partitioning must follow the H2 just moved. H2 is pure
    # hydrogen by mass (M_H2 = 2 M_H), so the hydrogen mass shifted equals the
    # H2 mass shifted. Without this the *_kg_atm element ratios would still
    # count the relocated hydrogen in the atmosphere.
    dH_atm = H2_kg_atm_new - H2_kg_atm_old
    dH_liquid = H2_kg_liquid_new - H2_kg_liquid_old
    # A round-off negative becomes 0; NaN or a larger negative is kept for the
    # impact check to refuse, since it marks a ledger defect.
    H_atm = float(hf_row.get('H_kg_atm', 0.0)) + dH_atm
    round_off = _H_ATM_ROUND_OFF_REL * max(abs(H2_kg_atm_old), abs(H2_kg_atm_new))
    floor = math.isfinite(round_off) and -round_off <= H_atm < 0.0
    hf_row['H_kg_atm'] = 0.0 if floor else H_atm
    hf_row['H_kg_liquid'] = float(hf_row.get('H_kg_liquid', 0.0)) + dH_liquid

    # Recompute H2 partial pressure from atmospheric mass
    # P_H2 = m_H2 * g / (4 * pi * R^2)
    area = 4.0 * math.pi * R_int**2
    if area > 0 and gravity > 0:
        hf_row['H2_bar'] = H2_kg_atm_new * gravity / area / 1e5  # Pa -> bar
    else:
        hf_row['H2_bar'] = 0.0

    # Recompute total surface pressure (sum of all partial pressures)
    from proteus.utils.constants import gas_list

    P_total = sum(float(hf_row.get(s + '_bar', 0.0)) for s in gas_list)
    hf_row['P_surf'] = P_total

    # Recompute VMRs
    if P_total > 0:
        for s in gas_list:
            hf_row[s + '_vmr'] = float(hf_row.get(s + '_bar', 0.0)) / P_total
    else:
        for s in gas_list:
            hf_row[s + '_vmr'] = 0.0

    # Update molar quantities
    import scipy.constants as const

    N_av = const.Avogadro
    hf_row['H2_mol_liquid'] = H2_kg_liquid_new / _MU_H2 / N_av
    hf_row['H2_mol_atm'] = H2_kg_atm_new / _MU_H2 / N_av
    hf_row['H2_mol_solid'] = 0.0
    hf_row['H2_mol_total'] = H2_kg_total / _MU_H2 / N_av

    # Recompute atmospheric MMW
    _recompute_atm_mmw(hf_row)

    # Note: sigma was evaluated at the pre-redistribution P_surf. A fixed-point
    # iteration (recompute sigma at updated P_surf, re-partition, repeat) would
    # be more self-consistent but the correction is small: the H2 partial
    # pressure shift is typically < 1% of total P_surf, so one pass suffices.
    log.info(
        'Binodal H2: sigma=%.4f, H2_atm %.2e->%.2e kg, '
        'H2_liquid %.2e->%.2e kg (T=%.0f K, P=%.1f bar)',
        sigma,
        H2_kg_atm_old,
        H2_kg_atm_new,
        H2_kg_liquid_old,
        H2_kg_liquid_new,
        T_magma,
        float(hf_row['P_surf']),
    )