Skip to content

Calliope.solve

solve

atmosphere_mass(pin, ddict)

Atmospheric mass of volatiles and totals for H, C, N, O, S.

Thin wrapper around _atmosphere_mass that reads fO2_shift from ddict['fO2_shift_IW']. Preserved bit-for-bit identical to the pre-refactor function for all callers.

Source code in src/calliope/solve.py
347
348
349
350
351
352
353
354
def atmosphere_mass(pin, ddict):
    """Atmospheric mass of volatiles and totals for H, C, N, O, S.

    Thin wrapper around ``_atmosphere_mass`` that reads fO2_shift from
    ``ddict['fO2_shift_IW']``. Preserved bit-for-bit identical to the
    pre-refactor function for all callers.
    """
    return _atmosphere_mass(pin, ddict['fO2_shift_IW'], ddict)

atmosphere_mean_molar_mass(p_d)

Mean molar mass of the atmosphere [g/mol].

When ptot collapses to ~0 (every partial pressure clipped to zero by the NaN-aware guard in _get_partial_pressures), the division would raise ZeroDivisionError. Return a sentinel value of 1.0 g/mol; downstream callers in _atmosphere_mass multiply by p_d[k]=0 so all element masses come out zero, which propagates a clean "atmosphere is empty here" signal to the mass-balance residual.

Source code in src/calliope/solve.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
def atmosphere_mean_molar_mass(p_d):
    """Mean molar mass of the atmosphere [g/mol].

    When ``ptot`` collapses to ~0 (every partial pressure clipped to zero
    by the NaN-aware guard in ``_get_partial_pressures``), the division
    would raise ZeroDivisionError. Return a sentinel value of 1.0 g/mol;
    downstream callers in ``_atmosphere_mass`` multiply by ``p_d[k]=0``
    so all element masses come out zero, which propagates a clean
    "atmosphere is empty here" signal to the mass-balance residual.
    """

    ptot = get_total_pressure(p_d)

    if ptot < 1e-30:
        return 1.0

    mu_atm = 0
    for key, value in p_d.items():
        mu_atm += molar_mass[key] * value
    mu_atm /= ptot

    return mu_atm

dissolved_mass(pin, ddict)

Volatile masses in the (molten) mantle.

Thin wrapper around _dissolved_mass that reads fO2_shift from ddict['fO2_shift_IW']. Preserved bit-for-bit identical to the pre-refactor function for all callers.

Source code in src/calliope/solve.py
446
447
448
449
450
451
452
453
def dissolved_mass(pin, ddict):
    """Volatile masses in the (molten) mantle.

    Thin wrapper around ``_dissolved_mass`` that reads fO2_shift from
    ``ddict['fO2_shift_IW']``. Preserved bit-for-bit identical to the
    pre-refactor function for all callers.
    """
    return _dissolved_mass(pin, ddict['fO2_shift_IW'], ddict)

equilibrium_atmosphere(target_d, ddict, hide_warnings=True, rtol=1e-05, atol=10000000000.0, xtol=1e-08, p_guess=None, nsolve=1500, nguess=7500, print_result=True, opt_solver=True, p_guess_max=P_GUESS_MAX_BAR)

Solve for surface partial pressures assuming melt-vapour equilibrium.

Parameters:

Name Type Description Default
target_d dict

Target elemental mass inventories [kg], with keys 'H', 'C', 'N', 'S'.

required
ddict dict

Dictionary of coupler options variables (planet, magma state, inclusion flags).

required
hide_warnings bool

Hide floating point runtime warnings raised by scipy for poor guesses.

True
rtol float

Relative tolerance for mass conservation.

1e-5
atol float

Absolute tolerance for mass conservation [kg].

1e10
xtol float

Relative tolerance for fsolve.

1e-8
p_guess dict or None

Dictionary of initial guess for primary-species partial pressures [bar]. Must contain the keys 'H2O', 'CO2', 'N2', 'S2', each mapping to a finite real number. If None, an internal Monte-Carlo log-uniform draw is used. A non-dict value raises TypeError; missing keys or non-finite values raise ValueError.

None
nsolve int

Maximum number of inner-solver iterations per attempt.

1500
nguess int

Maximum number of Monte-Carlo restarts before giving up.

7500
print_result bool

If True, log final outgassed partial pressures at INFO level.

True
opt_solver bool

If True, alternate between fsolve and trust-constr on each restart.

True
p_guess_max float

Upper bound [bar] of the Monte-Carlo cold-start pressure draw. Sets where the cold start samples within the fixed P_CEILING_BAR solver box; it does not raise the maximum pressure the solver can accept, so a surface pressure above the box stays out of reach. Must be finite and

= P_GUESS_MIN_BAR.

``P_GUESS_MAX_BAR``

Returns:

Name Type Description
partial_pressures dict

Volatile partial pressures [bar] keyed <species>_bar, plus per-species reservoir masses [kg], elemental totals, residuals, and atmospheric diagnostics (P_surf, M_atm, atm_kg_per_mol, ratios).

Source code in src/calliope/solve.py
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 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
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
def equilibrium_atmosphere(
    target_d,
    ddict,
    hide_warnings=True,
    rtol=1e-5,
    atol=1e10,
    xtol=1e-8,
    p_guess=None,
    nsolve=1500,
    nguess=7500,
    print_result=True,
    opt_solver=True,
    p_guess_max=P_GUESS_MAX_BAR,
):
    """Solve for surface partial pressures assuming melt-vapour equilibrium.

    Parameters
    ----------
    target_d : dict
        Target elemental mass inventories [kg], with keys 'H', 'C', 'N', 'S'.
    ddict : dict
        Dictionary of coupler options variables (planet, magma state, inclusion flags).
    hide_warnings : bool, default True
        Hide floating point runtime warnings raised by `scipy` for poor guesses.
    rtol : float, default 1e-5
        Relative tolerance for mass conservation.
    atol : float, default 1e10
        Absolute tolerance for mass conservation [kg].
    xtol : float, default 1e-8
        Relative tolerance for fsolve.
    p_guess : dict or None, default None
        Dictionary of initial guess for primary-species partial pressures [bar].
        Must contain the keys 'H2O', 'CO2', 'N2', 'S2', each mapping to a
        finite real number. If None, an internal Monte-Carlo log-uniform
        draw is used. A non-dict value raises TypeError; missing keys or
        non-finite values raise ValueError.
    nsolve : int, default 1500
        Maximum number of inner-solver iterations per attempt.
    nguess : int, default 7500
        Maximum number of Monte-Carlo restarts before giving up.
    print_result : bool, default True
        If True, log final outgassed partial pressures at INFO level.
    opt_solver : bool, default True
        If True, alternate between fsolve and trust-constr on each restart.
    p_guess_max : float, default ``P_GUESS_MAX_BAR``
        Upper bound [bar] of the Monte-Carlo cold-start pressure draw. Sets
        where the cold start samples within the fixed ``P_CEILING_BAR`` solver
        box; it does not raise the maximum pressure the solver can accept, so a
        surface pressure above the box stays out of reach. Must be finite and
        >= ``P_GUESS_MIN_BAR``.

    Returns
    -------
    partial_pressures : dict
        Volatile partial pressures [bar] keyed `<species>_bar`, plus per-species
        reservoir masses [kg], elemental totals, residuals, and atmospheric
        diagnostics (P_surf, M_atm, atm_kg_per_mol, ratios).
    """

    if print_result:
        log.info('Solving for equilibrium partial pressures at surface')
    log.debug('    target masses: %s' % str(target_d))

    # Active noble gases become extra primary unknowns appended after the
    # four CHNOS primaries. Their target masses must be present so the
    # residual can be formed; a missing key is caller error and fails loudly.
    active = _active_noble(ddict)
    missing_targets = [gas for gas in active if gas not in target_d]
    if missing_targets:
        raise KeyError(
            f'target_d is missing noble gas targets {missing_targets}. '
            f'Every included noble gas requires a target mass [kg]; got keys '
            f'{sorted(target_d.keys())}.'
        )
    n_unknown = 4 + len(active)

    # Hard ub = P_CEILING_BAR is well above any realistic magma-ocean
    # surface pressure; it prevents trust-constr from exploring
    # non-physical regions during a poor cold start.
    lb = [0.0] * n_unknown
    ub = [P_CEILING_BAR] * n_unknown

    # Two-stage cold start when noble gases are active: solve the CHNOS core
    # alone (four unknowns, robust) and warm-start the joint solve from it
    # plus analytic Henry seeds for the noble pressures. A random draw across
    # the full high-dimensional box rarely lands in the basin; the pre-solve
    # avoids that. Any failure of the pre-solve falls back to the random draw.
    if p_guess is None and active:
        try:
            ddict_core = {**ddict, **{gas + '_included': 0 for gas in active}}
            target_core = {k: v for k, v in target_d.items() if k not in active}
            core = equilibrium_atmosphere(
                target_core,
                ddict_core,
                hide_warnings=hide_warnings,
                rtol=rtol,
                atol=atol,
                xtol=xtol,
                p_guess=None,
                nsolve=nsolve,
                nguess=nguess,
                print_result=False,
                opt_solver=opt_solver,
                p_guess_max=p_guess_max,
            )
            p_guess = {s: core[s + '_bar'] for s in ('H2O', 'CO2', 'N2', 'S2')}
            p_guess.update(_noble_henry_seed(core, active, target_d, ddict))
        except RuntimeError:
            log.warning(
                'Noble gas cold start: the CHNOS core pre-solve did not '
                'converge; falling back to a random high-dimensional draw, '
                'which is less likely to converge.'
            )
            p_guess = None

    if p_guess is None:
        x0 = get_initial_pressures(target_d, p_guess_max=p_guess_max, n_extra=len(active))
    else:
        # Validate up front so a missing key surfaces as a clear ValueError
        # rather than a bare KeyError from the tuple construction below.
        # The isinstance check handles cases where a caller passes a list,
        # a pandas Series, or accidentally a non-dict object; without it,
        # the membership test below would raise an opaque TypeError.
        if not isinstance(p_guess, dict):
            raise TypeError(f'p_guess must be a dict or None, got {type(p_guess).__name__}.')
        required = ('H2O', 'CO2', 'N2', 'S2') + active
        missing = [k for k in required if k not in p_guess]
        if missing:
            raise ValueError(
                f'p_guess is missing required keys: {missing}. '
                f'Expected all of {list(required)}.'
            )
        x0 = tuple(p_guess[k] for k in required)

        # Reject non-finite values up front. NaN > 1e-10 evaluates False,
        # which would silently collapse ub to 1.0 and propagate NaN through
        # the solver to produce garbage output with no error signal.
        for k, v in zip(required, x0):
            if not np.isfinite(v):
                raise ValueError(f'p_guess[{k!r}] must be a finite real number, got {v!r}.')

        # Zero or near-zero guess collapses ub from 1e7 to 1.0 to keep
        # trust-constr from wandering inside a degenerate slot.
        for i in range(n_unknown):
            ub[i] = ub[i] if (x0[i] > 1e-10) else 1.0

    bounds = opt.Bounds(lb=lb, ub=ub)

    # Scalar CHNOS acceptance gate, keyed to the largest CHNOS budget only.
    # Noble gas budgets are deliberately excluded from this maximum: a large
    # noble inventory would otherwise inflate the tolerance and loosen the
    # closure demanded of the trace CHNOS elements. The noble gases are held
    # to their own per-gas gate below.
    tolerance = np.amax([target_d[e] for e in ('H', 'C', 'N', 'S')]) * rtol + atol + TRUNC_MASS
    log.debug('Required tolerance: %g' % tolerance)

    with warnings.catch_warnings():
        # Solver Monte-Carlo restarts produce poor guesses that trip
        # RuntimeWarning / UserWarning; the bad attempts are discarded
        # so the warnings should not reach the caller.
        if hide_warnings:
            warnings.filterwarnings('ignore', category=RuntimeWarning)
            warnings.filterwarnings('ignore', category=UserWarning)

        solver: int = 0
        for count in range(nguess):
            if solver == 0:
                sol, _, ier, _ = opt.fsolve(
                    func, x0, args=(ddict, target_d), maxfev=nsolve, xtol=xtol, full_output=True
                )
                success = bool(ier == 1)
            else:
                result = opt.minimize(
                    obj,
                    x0,
                    args=(ddict, target_d),
                    method='trust-constr',
                    bounds=bounds,
                    options={'maxiter': nsolve, 'xtol': xtol},
                )
                success = result.success
                sol = result.x

            # Reject solver-claimed successes that miss the residual
            # tolerance: fsolve and trust-constr converge by their own
            # criteria, which are not the kg-mass-balance criterion.
            this_resid = func(sol, ddict, target_d)
            # The scalar gate is keyed to the CHNOS budgets, so it must judge
            # only the CHNOS residuals (indices 0-3). Including the noble
            # residuals here would gate an abundant noble gas against the
            # CHNOS tolerance and spuriously reject an otherwise valid root;
            # the noble gases are held to their own per-gas gate below.
            chnos_resid = np.abs(np.asarray(this_resid[:4]))
            loss = np.amax(chnos_resid)
            if loss > tolerance:
                if success:
                    log.debug('Solution rejected by CHNOS residual')
                    log.debug('    d(i=%d) = %.2e kg' % (np.argmax(chnos_resid), loss))
                success = False

            # The scalar gate above keys its tolerance to the largest CHNOS
            # budget, which is loose for a trace noble gas (its whole inventory
            # can be orders of magnitude below the dominant element). Add a
            # per-gas closure check for the noble residuals so a mass-unbalanced
            # noble solution is not accepted, mirroring the per-element gate the
            # authoritative-O path already uses.
            if success and active:
                noble_resid = np.abs(np.asarray(this_resid[4:]))
                noble_tol = np.maximum(
                    np.array([target_d[gas] for gas in active]) * rtol, TRUNC_MASS
                )
                if np.any(noble_resid > noble_tol):
                    log.debug('Solution rejected by noble gas residual')
                    success = False

            if success:
                break

            x0 = get_initial_pressures(target_d, p_guess_max=p_guess_max, n_extra=len(active))

            # Alternate fsolve <-> trust-constr on each restart so a
            # basin one solver cannot escape gets a chance from the
            # other. Disable via opt_solver=False to pin to fsolve.
            if opt_solver:
                solver = 1 - solver

    if not success:
        raise RuntimeError(
            'Could not find solution for volatile abundances (max attempts, %d)' % nguess
        )

    log.debug('    Initial guess attempt number = %d' % count)

    res_l = func(sol, ddict, target_d)
    log.debug('    Residuals: %s' % res_l)

    sol_dict = {'H2O': sol[0], 'CO2': sol[1], 'N2': sol[2], 'S2': sol[3]}
    for i, gas in enumerate(active):
        sol_dict[gas] = sol[4 + i]
    p_d = get_partial_pressures(sol_dict, ddict)

    # Pass the 4-key primary dict (sol_dict), not the expanded p_d:
    # atmosphere_mass and dissolved_mass each call get_partial_pressures
    # internally, so feeding them p_d would re-run gas-phase chemistry
    # on a dict that already contains the secondaries (read but ignored).
    mass_atm_d = atmosphere_mass(sol_dict, ddict)
    mass_int_d = dissolved_mass(sol_dict, ddict)

    # Active noble gases join the reaction-network species and elements in the
    # output schema, so their partial pressures, reservoir masses, mole
    # counts, and atmospheric ratios are reported alongside the CHNOS fields.
    out_species = list(volatile_species) + list(active)
    out_elements = list(element_list_chnos) + list(active)

    # CALLIOPE has no solid-mantle reservoir; every `_kg_solid` field
    # is 0.0 and exists only for schema parity with the downstream
    # PROTEUS hf_row which carries solid/melt/atmosphere splits.
    outdict = {'M_atm': 0.0, 'P_surf': 0.0}
    for s in out_species:
        outdict[s + '_bar'] = 0.0
        outdict[s + '_kg_atm'] = 0.0
        outdict[s + '_kg_liquid'] = 0.0
        outdict[s + '_kg_solid'] = 0.0
        outdict[s + '_kg_total'] = 0.0

        if s in p_d.keys():
            outdict[s + '_bar'] = p_d[s]
            outdict['P_surf'] += outdict[s + '_bar']

    P_surf = outdict['P_surf']
    for s in out_species:
        outdict[s + '_vmr'] = (
            (outdict[s + '_bar'] / P_surf) if P_surf > P_SURF_FLOOR_BAR else 0.0
        )

        if print_result:
            log.info(
                '    %-6s : %-8.2f bar (%.2e VMR)'
                % (s, outdict[s + '_bar'], outdict[s + '_vmr'])
            )

    all = list(out_species)
    all.extend(['H', 'C', 'N', 'S', 'O'])
    for s in all:
        tot_kg = 0.0

        if s in mass_atm_d.keys():
            outdict[s + '_kg_atm'] = mass_atm_d[s]
            tot_kg += mass_atm_d[s]

        if s in mass_int_d.keys():
            outdict[s + '_kg_liquid'] = mass_int_d[s]
            outdict[s + '_kg_solid'] = 0.0
            tot_kg += mass_int_d[s]

        outdict[s + '_kg_total'] = tot_kg

    for s in out_species:
        outdict['M_atm'] += outdict[s + '_kg_atm']

    outdict['atm_kg_per_mol'] = 0.0
    for s in out_species:
        outdict[s + '_mol_atm'] = outdict[s + '_kg_atm'] / molar_mass[s]
        outdict[s + '_mol_solid'] = outdict[s + '_kg_solid'] / molar_mass[s]
        outdict[s + '_mol_liquid'] = outdict[s + '_kg_liquid'] / molar_mass[s]
        outdict[s + '_mol_total'] = (
            outdict[s + '_mol_atm'] + outdict[s + '_mol_solid'] + outdict[s + '_mol_liquid']
        )

        outdict['atm_kg_per_mol'] += outdict[s + '_vmr'] * molar_mass[s]

    for e1 in out_elements:
        for e2 in out_elements:
            if e1 == e2:
                continue
            em1 = outdict[e1 + '_kg_atm']
            em2 = outdict[e2 + '_kg_atm']
            if em2 == 0:
                continue
            outdict['%s/%s_atm' % (e1, e2)] = em1 / em2

    outdict['H_res'] = res_l[0]
    outdict['C_res'] = res_l[1]
    outdict['N_res'] = res_l[2]
    outdict['S_res'] = res_l[3]
    for i, gas in enumerate(active):
        outdict[gas + '_res'] = res_l[4 + i]

    return outdict

equilibrium_atmosphere_authoritative_O(target_d, ddict, fO2_hint=4.0, hide_warnings=True, rtol=1e-05, atol=10000000000.0, xtol=1e-08, p_guess=None, nsolve=1500, nguess=7500, print_result=True, opt_solver=True, random_seed=None, p_guess_max=P_GUESS_MAX_BAR)

Solve for partial pressures AND fO2 given total elemental masses including O.

Authoritative-oxygen solver mode. Unlike equilibrium_atmosphere (which takes fO2 as a config input via ddict['fO2_shift_IW']), this entry point treats fO2 as a fifth unknown and solves a 5x5 nonlinear mass-balance system. The user supplies a target O mass alongside H/C/N/S, and the solver returns the partial pressures plus the IW-buffer offset (fO2_shift_derived) that produces that equilibrium.

Use this when the science model declares atmospheric+dissolved O as a budget (e.g. mantle FeO inventory, or whole-planet O accounting where atmospheric escape and ingassing debit the same O reservoir). For the legacy mode where fO2 is buffered to a user-specified IW offset and O is derived, use equilibrium_atmosphere instead.

Parameters:

Name Type Description Default
target_d dict

Target elemental mass inventories [kg]. MUST contain the keys 'H', 'C', 'N', 'S', 'O'. Missing 'O' raises KeyError.

required
ddict dict

Coupler options dict (planet, magma state, inclusion flags). ddict['fO2_shift_IW'] is IGNORED by this entry point; the value is treated as a solver unknown initialised from fO2_hint instead.

required
fO2_hint float

Initial guess for the IW-buffer offset (log10 units). Provide a value close to the expected solution to speed convergence; typical PROTEUS values lie in [-4, +6]. The solver's Monte-Carlo restarts redraw from Uniform(-6, +8) if the hint does not lead to convergence.

4.0
hide_warnings bool

Hide floating point runtime warnings raised by scipy for poor guesses.

True
rtol float

Relative tolerance for mass conservation.

1e-5
atol float

Absolute tolerance for mass conservation [kg].

1e10
xtol float

Relative tolerance for fsolve.

1e-8
p_guess dict or None

Initial guess for primary-species partial pressures [bar]. Keys must include 'H2O', 'CO2', 'N2', 'S2'; the optional key 'fO2_shift_IW' overrides fO2_hint for the starting guess. Non-dict raises TypeError; missing required keys or non-finite values raise ValueError.

None
nsolve int

Maximum number of inner-solver iterations per attempt.

1500
nguess int

Maximum number of Monte-Carlo restarts before giving up.

7500
print_result bool

If True, log final outgassed partial pressures and derived fO2 at INFO level.

True
opt_solver bool

If True, alternate between fsolve and trust-constr on each restart so a basin one solver cannot escape gets a chance from the other.

True
random_seed int or None

Seed for the Monte-Carlo restart RNG. None uses the global np.random state (non-deterministic). An integer seed makes solver outcomes reproducible across calls, which is required for regression testing and for diffing two runs.

None
p_guess_max float

Upper bound [bar] of the Monte-Carlo cold-start pressure draw. Sets where the cold start samples within the fixed P_CEILING_BAR solver box; it does not raise the maximum pressure the solver can accept, so a surface pressure above the box stays out of reach. Must be finite and

= P_GUESS_MIN_BAR.

``P_GUESS_MAX_BAR``

Returns:

Name Type Description
partial_pressures dict

Volatile partial pressures [bar] keyed <species>_bar, plus per-species reservoir masses [kg], elemental totals, residuals, and atmospheric diagnostics. Two additions relative to equilibrium_atmosphere:

  • fO2_shift_derived : float The IW-buffer offset the solver converged to. Equals fO2_hint only if the hint happened to be the self-consistent value.
  • O_res : float 5th residual (O mass-balance), in kg. Pairs with the existing H_res/C_res/N_res/S_res keys.

Raises:

Type Description
KeyError

If target_d is missing the 'O' key.

(TypeError, ValueError)

If p_guess fails validation (same contract as equilibrium_atmosphere).

RuntimeError

If the solver fails to converge after nguess Monte-Carlo restarts. The error message includes the final pressures and fO2_shift attempt for diagnosis.

Notes

Mathematical model. The four existing mass-balance equations for H/C/N/S are extended with a fifth for O. The four primary partial pressures (H2O, CO2, N2, S2) are joined by fO2_shift as a fifth unknown. All seven derived partial pressures (H2, CO, CH4, NH3, O2, SO2, H2S) and the two fO2-coupled solubility laws (N2 Dasgupta, S2 Gaillard) consume fO2_shift through the same physics functions _get_partial_pressures, _atmosphere_mass, _dissolved_mass that equilibrium_atmosphere uses.

Examples:

Reproducing an equilibrium_atmosphere result through the new mode:

>>> # First, run the legacy mode at fO2_shift_IW = +4
>>> ddict = {..., 'fO2_shift_IW': 4.0}
>>> out_legacy = equilibrium_atmosphere(target_d_HCNS, ddict)
>>> # Then, run the new mode with the implied O budget
>>> target_d_HCNSO = dict(target_d_HCNS,
...                      O=out_legacy['O_kg_total'])
>>> out_new = equilibrium_atmosphere_authoritative_O(
...     target_d_HCNSO, ddict, fO2_hint=4.0)
>>> abs(out_new['fO2_shift_derived'] - 4.0) < 0.01  # round-trip
True
Source code in src/calliope/solve.py
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
1129
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
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
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
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
def equilibrium_atmosphere_authoritative_O(
    target_d,
    ddict,
    fO2_hint=4.0,
    hide_warnings=True,
    rtol=1e-5,
    atol=1e10,
    xtol=1e-8,
    p_guess=None,
    nsolve=1500,
    nguess=7500,
    print_result=True,
    opt_solver=True,
    random_seed=None,
    p_guess_max=P_GUESS_MAX_BAR,
):
    """Solve for partial pressures AND fO2 given total elemental masses including O.

    Authoritative-oxygen solver mode. Unlike ``equilibrium_atmosphere``
    (which takes fO2 as a config input via ``ddict['fO2_shift_IW']``),
    this entry point treats fO2 as a fifth unknown and solves a 5x5
    nonlinear mass-balance system. The user supplies a target O mass
    alongside H/C/N/S, and the solver returns the partial pressures
    plus the IW-buffer offset (``fO2_shift_derived``) that produces
    that equilibrium.

    Use this when the science model declares atmospheric+dissolved O
    as a budget (e.g. mantle FeO inventory, or whole-planet O accounting
    where atmospheric escape and ingassing debit the same O reservoir).
    For the legacy mode where fO2 is buffered to a user-specified IW
    offset and O is derived, use ``equilibrium_atmosphere`` instead.

    Parameters
    ----------
    target_d : dict
        Target elemental mass inventories [kg]. MUST contain the keys
        ``'H'``, ``'C'``, ``'N'``, ``'S'``, ``'O'``. Missing ``'O'``
        raises ``KeyError``.
    ddict : dict
        Coupler options dict (planet, magma state, inclusion flags).
        ``ddict['fO2_shift_IW']`` is IGNORED by this entry point; the
        value is treated as a solver unknown initialised from
        ``fO2_hint`` instead.
    fO2_hint : float, default 4.0
        Initial guess for the IW-buffer offset (log10 units). Provide
        a value close to the expected solution to speed convergence;
        typical PROTEUS values lie in [-4, +6]. The solver's Monte-Carlo
        restarts redraw from Uniform(-6, +8) if the hint does not lead
        to convergence.
    hide_warnings : bool, default True
        Hide floating point runtime warnings raised by `scipy` for poor guesses.
    rtol : float, default 1e-5
        Relative tolerance for mass conservation.
    atol : float, default 1e10
        Absolute tolerance for mass conservation [kg].
    xtol : float, default 1e-8
        Relative tolerance for fsolve.
    p_guess : dict or None, default None
        Initial guess for primary-species partial pressures [bar]. Keys
        must include ``'H2O'``, ``'CO2'``, ``'N2'``, ``'S2'``; the
        optional key ``'fO2_shift_IW'`` overrides ``fO2_hint`` for the
        starting guess. Non-dict raises TypeError; missing required keys
        or non-finite values raise ValueError.
    nsolve : int, default 1500
        Maximum number of inner-solver iterations per attempt.
    nguess : int, default 7500
        Maximum number of Monte-Carlo restarts before giving up.
    print_result : bool, default True
        If True, log final outgassed partial pressures and derived
        fO2 at INFO level.
    opt_solver : bool, default True
        If True, alternate between fsolve and trust-constr on each
        restart so a basin one solver cannot escape gets a chance from
        the other.
    random_seed : int or None, default None
        Seed for the Monte-Carlo restart RNG. ``None`` uses the global
        ``np.random`` state (non-deterministic). An integer seed makes
        solver outcomes reproducible across calls, which is required
        for regression testing and for diffing two runs.
    p_guess_max : float, default ``P_GUESS_MAX_BAR``
        Upper bound [bar] of the Monte-Carlo cold-start pressure draw. Sets
        where the cold start samples within the fixed ``P_CEILING_BAR`` solver
        box; it does not raise the maximum pressure the solver can accept, so a
        surface pressure above the box stays out of reach. Must be finite and
        >= ``P_GUESS_MIN_BAR``.

    Returns
    -------
    partial_pressures : dict
        Volatile partial pressures [bar] keyed ``<species>_bar``, plus
        per-species reservoir masses [kg], elemental totals, residuals,
        and atmospheric diagnostics. Two additions relative to
        ``equilibrium_atmosphere``:

        - ``fO2_shift_derived`` : float
            The IW-buffer offset the solver converged to. Equals
            ``fO2_hint`` only if the hint happened to be the
            self-consistent value.
        - ``O_res`` : float
            5th residual (O mass-balance), in kg. Pairs with the
            existing ``H_res``/``C_res``/``N_res``/``S_res`` keys.

    Raises
    ------
    KeyError
        If ``target_d`` is missing the ``'O'`` key.
    TypeError, ValueError
        If ``p_guess`` fails validation (same contract as
        ``equilibrium_atmosphere``).
    RuntimeError
        If the solver fails to converge after ``nguess`` Monte-Carlo
        restarts. The error message includes the final pressures and
        fO2_shift attempt for diagnosis.

    Notes
    -----
    Mathematical model. The four existing mass-balance equations for
    H/C/N/S are extended with a fifth for O. The four primary
    partial pressures (H2O, CO2, N2, S2) are joined by fO2_shift as a
    fifth unknown. All seven derived partial pressures (H2, CO, CH4,
    NH3, O2, SO2, H2S) and the two fO2-coupled solubility laws (N2
    Dasgupta, S2 Gaillard) consume fO2_shift through the same
    physics functions ``_get_partial_pressures``, ``_atmosphere_mass``,
    ``_dissolved_mass`` that ``equilibrium_atmosphere`` uses.

    Examples
    --------
    Reproducing an equilibrium_atmosphere result through the new mode:

    >>> # First, run the legacy mode at fO2_shift_IW = +4
    >>> ddict = {..., 'fO2_shift_IW': 4.0}
    >>> out_legacy = equilibrium_atmosphere(target_d_HCNS, ddict)
    >>> # Then, run the new mode with the implied O budget
    >>> target_d_HCNSO = dict(target_d_HCNS,
    ...                      O=out_legacy['O_kg_total'])
    >>> out_new = equilibrium_atmosphere_authoritative_O(
    ...     target_d_HCNSO, ddict, fO2_hint=4.0)
    >>> abs(out_new['fO2_shift_derived'] - 4.0) < 0.01  # round-trip
    True
    """

    # Active noble gases are appended after the O residual, so the residual
    # (and target) order is H, C, N, S, O, then each active noble gas.
    active = _active_noble(ddict)
    required_elements = ('H', 'C', 'N', 'S', 'O') + active

    # Contract check: every required element key must be present, finite,
    # and non-negative. A missing key raises KeyError (matching the legacy
    # "target_d must include 'O'" message). Non-finite or negative values
    # are user-error and raise ValueError before the solver wastes effort.
    missing = [e for e in required_elements if e not in target_d]
    if missing:
        raise KeyError(
            'target_d is missing required element keys: %s. '
            'Authoritative-O mode requires all of %s. Got keys: %s'
            % (missing, list(required_elements), sorted(target_d.keys()))
        )
    for e in required_elements:
        v = target_d[e]
        if not np.isfinite(v):
            raise ValueError('target_d[%r] must be a finite real number [kg], got %r.' % (e, v))
        if v < 0:
            raise ValueError('target_d[%r] must be non-negative [kg], got %r.' % (e, v))

    # Validate fO2_hint and the planet/state parameters consumed from
    # ddict by the residual chain. The solver evaluates the residual at
    # arbitrarily wild trial points, so it cannot recover from a bad
    # entry value; failing fast here gives a useful error rather than
    # a ZeroDivisionError from deep inside the chemistry path.
    if not np.isfinite(fO2_hint):
        raise ValueError(
            'fO2_hint must be a finite real number (log10 IW offset), got %r.' % fO2_hint
        )
    if not (FO2_HARD_MIN <= fO2_hint <= FO2_HARD_MAX):
        raise ValueError(
            'fO2_hint=%.3f is outside the solver bounds [%+g, %+g]. '
            'Pick a value in [%+g, %+g] for physically realistic mantle '
            'redox states.'
            % (fO2_hint, FO2_HARD_MIN, FO2_HARD_MAX, FO2_GUESS_MIN, FO2_GUESS_MAX)
        )

    for required_ddict_key in ('M_mantle', 'Phi_global', 'T_magma', 'gravity', 'radius'):
        if required_ddict_key not in ddict:
            raise KeyError(
                'ddict is missing required key %r. Authoritative-O mode '
                'requires M_mantle, Phi_global, T_magma, gravity, radius.' % required_ddict_key
            )

    M_mantle = ddict['M_mantle']
    if not (np.isfinite(M_mantle) and M_mantle > 0):
        raise ValueError("ddict['M_mantle']=%r must be a positive finite mass [kg]." % M_mantle)

    Phi_global = ddict['Phi_global']
    if not (np.isfinite(Phi_global) and 0.0 <= Phi_global <= 1.0):
        raise ValueError(
            "ddict['Phi_global']=%r must lie in [0, 1] (melt mass fraction)." % Phi_global
        )

    T_magma = ddict['T_magma']
    if not (np.isfinite(T_magma) and T_magma > 0):
        raise ValueError(
            "ddict['T_magma']=%r must be a positive finite temperature [K]." % T_magma
        )

    if nguess < 1:
        raise ValueError('nguess must be >= 1, got %d.' % nguess)
    if nsolve < 1:
        raise ValueError('nsolve must be >= 1, got %d.' % nsolve)

    # Seeded RNG for the Monte-Carlo restart draws. random_seed=None
    # falls back to the global np.random state to preserve historical
    # non-deterministic behaviour for callers that do not opt in to
    # reproducibility.
    rng = np.random.default_rng(random_seed) if random_seed is not None else np.random

    if print_result:
        log.info(
            'Solving for equilibrium partial pressures + fO2_shift '
            '(authoritative-O mode, fO2_hint=%.2f)',
            fO2_hint,
        )
    log.debug('    target masses: %s', target_d)

    # Bounds. Pressures: [0, P_CEILING_BAR] bar (same as equilibrium_atmosphere).
    # fO2_shift: [FO2_HARD_MIN, FO2_HARD_MAX] log10 units. The physically
    # meaningful range is roughly [FO2_GUESS_MIN, FO2_GUESS_MAX] (mantle
    # reducing to highly oxidized); the wider hard box gives trust-constr room
    # to explore on poor cold starts without escaping to non-physical territory.
    # Unknown vector: [pH2O, pCO2, pN2, pS2, fO2_shift, p_noble...]. Pressures
    # live at indices 0-3 and 5..5+len(active); fO2_shift sits at index 4 with
    # its own wider log10 bounds.
    noble_pressure_idx = list(range(5, 5 + len(active)))
    lb = [0.0, 0.0, 0.0, 0.0, FO2_HARD_MIN] + [0.0] * len(active)
    ub = [P_CEILING_BAR, P_CEILING_BAR, P_CEILING_BAR, P_CEILING_BAR, FO2_HARD_MAX] + [
        P_CEILING_BAR
    ] * len(active)

    # Physical pressure ceiling for the acceptance gate, captured before
    # the per-guess ub-collapse below mutates ub. The gate tests against
    # this documented 1e7 bar bound, not a collapsed conditioning value,
    # so a tiny-guess slot whose root legitimately lands above its
    # collapsed 1.0 bar bound is not falsely rejected.
    p_ceiling = ub[0]

    # Two-stage cold start when noble gases are active: solve the CHNOS + O +
    # fO2 core alone (five unknowns, robust) and warm-start the joint solve
    # from it plus analytic Henry seeds for the noble pressures. The random
    # draw across the full box rarely converges once the noble dimensions and
    # the differently-scaled fO2 dimension are added. Any failure of the
    # pre-solve falls back to the random draw.
    if p_guess is None and active:
        try:
            ddict_core = {**ddict, **{gas + '_included': 0 for gas in active}}
            target_core = {k: v for k, v in target_d.items() if k not in active}
            core = equilibrium_atmosphere_authoritative_O(
                target_core,
                ddict_core,
                fO2_hint=fO2_hint,
                hide_warnings=hide_warnings,
                rtol=rtol,
                atol=atol,
                xtol=xtol,
                p_guess=None,
                nsolve=nsolve,
                nguess=nguess,
                print_result=False,
                opt_solver=opt_solver,
                random_seed=random_seed,
                p_guess_max=p_guess_max,
            )
            p_guess = {s: core[s + '_bar'] for s in ('H2O', 'CO2', 'N2', 'S2')}
            p_guess['fO2_shift_IW'] = core['fO2_shift_derived']
            p_guess.update(_noble_henry_seed(core, active, target_d, ddict))
        except RuntimeError:
            log.warning(
                'Noble gas cold start: the CHNOS core pre-solve did not '
                'converge; falling back to a random high-dimensional draw, '
                'which is less likely to converge.'
            )
            p_guess = None

    if p_guess is None:
        x0 = get_initial_pressures_with_fO2(
            target_d, fO2_hint, rng=rng, p_guess_max=p_guess_max, n_extra=len(active)
        )
    else:
        if not isinstance(p_guess, dict):
            raise TypeError(f'p_guess must be a dict or None, got {type(p_guess).__name__}.')
        required = ('H2O', 'CO2', 'N2', 'S2') + active
        missing = [k for k in required if k not in p_guess]
        if missing:
            raise ValueError(
                f'p_guess is missing required keys: {missing}. '
                f'Expected all of {list(required)}.'
            )
        # fO2_shift_IW in p_guess overrides fO2_hint; absent means use fO2_hint.
        fO2_seed = p_guess.get('fO2_shift_IW', fO2_hint)
        x0 = (
            p_guess['H2O'],
            p_guess['CO2'],
            p_guess['N2'],
            p_guess['S2'],
            fO2_seed,
        ) + tuple(p_guess[gas] for gas in active)

        # Reject non-finite values.
        for k, v in zip(('H2O', 'CO2', 'N2', 'S2', 'fO2_shift_IW') + active, x0):
            if not np.isfinite(v):
                raise ValueError(f'p_guess[{k!r}] must be a finite real number, got {v!r}.')

        # Match the legacy ub-collapse for tiny pressure guesses so
        # trust-constr does not wander in a degenerate slot. fO2 bound
        # (index 4) stays at the wide range; the noble gas pressure slots
        # collapse like the CHNOS pressures.
        ub_collapsed = list(ub)
        for i in [0, 1, 2, 3] + noble_pressure_idx:
            ub_collapsed[i] = ub_collapsed[i] if (x0[i] > 1e-10) else 1.0
        ub = ub_collapsed

    bounds = opt.Bounds(lb=lb, ub=ub)

    # Per-element tolerance: each residual must satisfy
    # ``|res_i| <= max(target_i * rtol, TRUNC_MASS)``. The gate is
    # relative per element, so mass closure is judged against each
    # element's own budget rather than the largest. The absolute floor is
    # the small TRUNC_MASS noise level (10 kg) so a near-zero target does
    # not demand an exactly-zero residual. The floor is deliberately not
    # tied to ``atol`` (the planetary negligible-mass threshold, ~1e16
    # kg), which would dominate the relative gate on small-budget
    # elements such as N and let multi-percent closure errors pass there.
    target_vec = np.array([target_d[e] for e in required_elements])
    elem_tolerance = np.maximum(target_vec * rtol, TRUNC_MASS)
    log.debug('Per-element tolerance: %s kg', elem_tolerance.tolist())

    # `sol` initialised to a sentinel so the post-loop RuntimeError path
    # can format the final attempt even if every iteration crashed
    # before `sol` was assigned. `success` starts False so an early
    # break from a 0-iteration loop (already rejected by the nguess>=1
    # validator above, but a defensive belt) raises RuntimeError rather
    # than UnboundLocalError.
    sol = np.array(x0, dtype=float)
    success = False
    count = 0

    with warnings.catch_warnings():
        if hide_warnings:
            warnings.filterwarnings('ignore', category=RuntimeWarning)
            warnings.filterwarnings('ignore', category=UserWarning)

        solver: int = 0
        for count in range(nguess):
            # Wrap each solver call: fsolve and trust-constr can both
            # raise ZeroDivisionError or FloatingPointError if the
            # residual blows up in a numerically unrecoverable way at a
            # trial point. Catch and treat as a failed attempt so the
            # restart loop continues instead of propagating a crash to
            # the caller.
            try:
                if solver == 0:
                    sol, _, ier, _ = opt.fsolve(
                        func_authoritative_O,
                        x0,
                        args=(ddict, target_d),
                        maxfev=nsolve,
                        xtol=xtol,
                        full_output=True,
                    )
                    success = bool(ier == 1)
                else:
                    result = opt.minimize(
                        obj_authoritative_O,
                        x0,
                        args=(ddict, target_d),
                        method='trust-constr',
                        bounds=bounds,
                        options={'maxiter': nsolve, 'xtol': xtol},
                    )
                    success = result.success
                    sol = result.x
            except (ZeroDivisionError, FloatingPointError, ValueError) as exc:
                log.debug(
                    'Solver attempt %d (method=%s) raised %s: %s; restarting',
                    count,
                    'fsolve' if solver == 0 else 'trust-constr',
                    type(exc).__name__,
                    exc,
                )
                success = False

            # Per-element residual gate. Compute defensively so a
            # post-solver evaluation crash also routes to restart.
            if success:
                try:
                    this_resid = func_authoritative_O(sol, ddict, target_d)
                    resid_abs = np.abs(np.asarray(this_resid))
                    if np.any(resid_abs > elem_tolerance):
                        worst = int(np.argmax(resid_abs - elem_tolerance))
                        log.debug(
                            'Solution rejected by per-element residual: '
                            'element=%s, |res|=%.2e, tol=%.2e',
                            required_elements[worst],
                            resid_abs[worst],
                            elem_tolerance[worst],
                        )
                        success = False
                except (ZeroDivisionError, FloatingPointError, ValueError) as exc:
                    log.debug(
                        'Post-solver residual evaluation raised %s: %s; rejecting attempt %d',
                        type(exc).__name__,
                        exc,
                        count,
                    )
                    success = False

            # Reject a converged-but-non-physical root. The production
            # path runs only the unbounded fsolve (opt_solver=False), so a
            # root can satisfy mass balance yet sit outside the physical
            # box: a derived fO2_shift beyond [-12, +12] (the target O is
            # unreachable at this H/C/N/S/T_magma), a negative partial
            # pressure, or a partial pressure above the 1e7 bar ceiling.
            # trust-constr enforces `bounds`; fsolve does not, so the full
            # box is enforced here before the solution is accepted.
            if success:
                # All partial pressures: the four CHNOS primaries plus the
                # noble gas slots at indices 5+. fO2_shift (index 4) is
                # bounds-checked separately with its own log10 range.
                sol_p = np.asarray(
                    [sol[i] for i in [0, 1, 2, 3] + noble_pressure_idx], dtype=float
                )
                if not (lb[4] <= sol[4] <= ub[4]):
                    log.debug(
                        'Solution rejected: derived fO2_shift=%.3f outside [%.1f, %.1f]',
                        sol[4],
                        lb[4],
                        ub[4],
                    )
                    success = False
                elif np.any(sol_p < -1.0e-6):
                    log.debug('Solution rejected: negative partial pressure %s bar', sol_p)
                    success = False
                elif np.any(sol_p > p_ceiling * (1.0 + 1.0e-6)):
                    log.debug(
                        'Solution rejected: partial pressure above %.1e bar ceiling: %s bar',
                        p_ceiling,
                        sol_p,
                    )
                    success = False

            if success:
                break

            # Restart. Redraw pressures from log-uniform; redraw fO2
            # from Uniform(-6, +8) to give it a chance from a different
            # basin if the hint led to a non-converging region.
            x0 = get_initial_pressures_with_fO2(
                target_d,
                fO2_hint,
                restart=True,
                rng=rng,
                p_guess_max=p_guess_max,
                n_extra=len(active),
            )

            if opt_solver:
                solver = 1 - solver

    if not success:
        raise RuntimeError(
            'Could not find solution for volatile abundances + fO2 under '
            'authoritative-O mode (max attempts: %d). '
            'Final attempt: pH2O=%.3e bar, pCO2=%.3e bar, pN2=%.3e bar, '
            'pS2=%.3e bar, fO2_shift=%.3f. '
            'Either the target O budget is outside the physically '
            'reachable range at this (H, C, N, S, T_magma), or the '
            'chemistry has a non-monotonic region the solver could not '
            'escape. Consider adjusting fO2_hint or the target masses.'
            % (nguess, sol[0], sol[1], sol[2], sol[3], sol[4])
        )

    log.debug('    Initial guess attempt number = %d', count)

    res_l = func_authoritative_O(sol, ddict, target_d)
    log.debug('    Residuals: %s', res_l)
    log.debug('    Derived fO2_shift: %.4f', sol[4])

    sol_dict = {'H2O': sol[0], 'CO2': sol[1], 'N2': sol[2], 'S2': sol[3]}
    for i, gas in enumerate(active):
        sol_dict[gas] = sol[5 + i]
    fO2_derived = sol[4]
    p_d = _get_partial_pressures(sol_dict, fO2_derived, ddict)

    mass_atm_d = _atmosphere_mass(sol_dict, fO2_derived, ddict)
    mass_int_d = _dissolved_mass(sol_dict, fO2_derived, ddict)

    # Active noble gases join the reaction-network species and elements in the
    # output schema. The output structure otherwise matches
    # equilibrium_atmosphere plus the two authoritative-O keys
    # (fO2_shift_derived, O_res). The PROTEUS-side wrapper consumes the same
    # fields regardless of which solver mode ran.
    out_species = list(volatile_species) + list(active)
    out_elements = list(element_list_chnos) + list(active)

    outdict = {'M_atm': 0.0, 'P_surf': 0.0}
    for s in out_species:
        outdict[s + '_bar'] = 0.0
        outdict[s + '_kg_atm'] = 0.0
        outdict[s + '_kg_liquid'] = 0.0
        outdict[s + '_kg_solid'] = 0.0
        outdict[s + '_kg_total'] = 0.0

        if s in p_d.keys():
            outdict[s + '_bar'] = p_d[s]
            outdict['P_surf'] += outdict[s + '_bar']

    P_surf = outdict['P_surf']
    for s in out_species:
        outdict[s + '_vmr'] = (
            (outdict[s + '_bar'] / P_surf) if P_surf > P_SURF_FLOOR_BAR else 0.0
        )

        if print_result:
            log.info(
                '    %-6s : %-8.2f bar (%.2e VMR)',
                s,
                outdict[s + '_bar'],
                outdict[s + '_vmr'],
            )

    all_keys = list(out_species)
    all_keys.extend(['H', 'C', 'N', 'S', 'O'])
    for s in all_keys:
        tot_kg = 0.0

        if s in mass_atm_d.keys():
            outdict[s + '_kg_atm'] = mass_atm_d[s]
            tot_kg += mass_atm_d[s]

        if s in mass_int_d.keys():
            outdict[s + '_kg_liquid'] = mass_int_d[s]
            outdict[s + '_kg_solid'] = 0.0
            tot_kg += mass_int_d[s]

        outdict[s + '_kg_total'] = tot_kg

    for s in out_species:
        outdict['M_atm'] += outdict[s + '_kg_atm']

    outdict['atm_kg_per_mol'] = 0.0
    for s in out_species:
        outdict[s + '_mol_atm'] = outdict[s + '_kg_atm'] / molar_mass[s]
        outdict[s + '_mol_solid'] = outdict[s + '_kg_solid'] / molar_mass[s]
        outdict[s + '_mol_liquid'] = outdict[s + '_kg_liquid'] / molar_mass[s]
        outdict[s + '_mol_total'] = (
            outdict[s + '_mol_atm'] + outdict[s + '_mol_solid'] + outdict[s + '_mol_liquid']
        )

        outdict['atm_kg_per_mol'] += outdict[s + '_vmr'] * molar_mass[s]

    for e1 in out_elements:
        for e2 in out_elements:
            if e1 == e2:
                continue
            em1 = outdict[e1 + '_kg_atm']
            em2 = outdict[e2 + '_kg_atm']
            if em2 == 0:
                continue
            outdict['%s/%s_atm' % (e1, e2)] = em1 / em2

    outdict['H_res'] = res_l[0]
    outdict['C_res'] = res_l[1]
    outdict['N_res'] = res_l[2]
    outdict['S_res'] = res_l[3]
    outdict['O_res'] = res_l[4]
    for i, gas in enumerate(active):
        outdict[gas + '_res'] = res_l[5 + i]
    outdict['fO2_shift_derived'] = fO2_derived

    if print_result:
        log.info('    Derived fO2_shift = %.4f (hint was %.4f)', fO2_derived, fO2_hint)

    return outdict

func(pin_arr, ddict, mass_target_d)

Mass-balance residual [kg per element] for the primary partial pressures [bar].

The unknown vector is the four CHNOS primaries (H2O, CO2, N2, S2) followed by one partial pressure per active noble gas, in the _active_noble order. The residual carries one entry per element: H, C, N, S, then each active noble gas (whose element and species are the same entity).

Source code in src/calliope/solve.py
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
def func(pin_arr, ddict, mass_target_d):
    """Mass-balance residual [kg per element] for the primary partial pressures [bar].

    The unknown vector is the four CHNOS primaries (H2O, CO2, N2, S2)
    followed by one partial pressure per active noble gas, in the
    `_active_noble` order. The residual carries one entry per element:
    H, C, N, S, then each active noble gas (whose element and species are
    the same entity).
    """

    active = _active_noble(ddict)
    pin_dict = {'H2O': pin_arr[0], 'CO2': pin_arr[1], 'N2': pin_arr[2], 'S2': pin_arr[3]}
    for i, gas in enumerate(active):
        pin_dict[gas] = pin_arr[4 + i]

    mass_atm_d = atmosphere_mass(pin_dict, ddict)
    mass_int_d = dissolved_mass(pin_dict, ddict)

    elems = ['H', 'C', 'N', 'S'] + list(active)
    res_l = [0.0] * len(elems)
    for i, vol in enumerate(elems):
        res_l[i] = mass_atm_d[vol] + mass_int_d[vol] - mass_target_d[vol]

    return res_l

func_authoritative_O(x_arr, ddict, mass_target_d)

Mass-balance residual vector for the authoritative-O solver mode.

Mass-balance residual [kg per element] over the unknown vector [pH2O, pCO2, pN2, pS2, fO2_shift, p_noble...], where the noble gas partial pressures (one per active noble gas, in _active_noble order) follow fO2_shift. The first four equations are the usual H, C, N, S mass balances; the fifth is the O mass balance that was implicit in the chemistry (because fO2 was an input) and is now an explicit constraint (because fO2 is an unknown); each remaining equation closes one noble gas.

Parameters:

Name Type Description Default
x_arr array_like, length ``5 + len(active_noble)``

[pH2O_bar, pCO2_bar, pN2_bar, pS2_bar, fO2_shift_IW, p_noble...]. The pressures are in bar; fO2_shift_IW at index 4 is the IW-buffer offset in log10 units (typical range -6 to +8).

required
ddict dict

Coupler options dict. Reads everything except fO2_shift_IW, which is taken from x_arr[4] to expose it as an unknown.

required
mass_target_d dict

Target elemental mass inventories [kg]. MUST include 'H', 'C', 'N', 'S', 'O' and every active noble gas.

required

Returns:

Type Description
list of float, length ``5 + len(active_noble)``

Residuals (atm_kg + dissolved_kg) - target_kg for H, C, N, S, O then each active noble gas in that order.

Source code in src/calliope/solve.py
489
490
491
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
def func_authoritative_O(x_arr, ddict, mass_target_d):
    """Mass-balance residual vector for the authoritative-O solver mode.

    Mass-balance residual [kg per element] over the unknown vector
    ``[pH2O, pCO2, pN2, pS2, fO2_shift, p_noble...]``, where the noble gas
    partial pressures (one per active noble gas, in `_active_noble` order)
    follow fO2_shift. The first four equations are the usual H, C, N, S mass
    balances; the fifth is the O mass balance that was implicit in the
    chemistry (because fO2 was an input) and is now an explicit constraint
    (because fO2 is an unknown); each remaining equation closes one noble
    gas.

    Parameters
    ----------
    x_arr : array_like, length ``5 + len(active_noble)``
        ``[pH2O_bar, pCO2_bar, pN2_bar, pS2_bar, fO2_shift_IW, p_noble...]``.
        The pressures are in bar; ``fO2_shift_IW`` at index 4 is the
        IW-buffer offset in log10 units (typical range -6 to +8).
    ddict : dict
        Coupler options dict. Reads everything except ``fO2_shift_IW``,
        which is taken from ``x_arr[4]`` to expose it as an unknown.
    mass_target_d : dict
        Target elemental mass inventories [kg]. MUST include ``'H'``,
        ``'C'``, ``'N'``, ``'S'``, ``'O'`` and every active noble gas.

    Returns
    -------
    list of float, length ``5 + len(active_noble)``
        Residuals ``(atm_kg + dissolved_kg) - target_kg`` for H, C, N, S, O
        then each active noble gas in that order.
    """

    active = _active_noble(ddict)
    pin_dict = {'H2O': x_arr[0], 'CO2': x_arr[1], 'N2': x_arr[2], 'S2': x_arr[3]}
    fO2_shift = x_arr[4]
    for i, gas in enumerate(active):
        pin_dict[gas] = x_arr[5 + i]

    mass_atm_d = _atmosphere_mass(pin_dict, fO2_shift, ddict)
    mass_int_d = _dissolved_mass(pin_dict, fO2_shift, ddict)

    elems = ['H', 'C', 'N', 'S', 'O'] + list(active)
    res_l = [0.0] * len(elems)
    for i, elem in enumerate(elems):
        res_l[i] = mass_atm_d[elem] + mass_int_d[elem] - mass_target_d[elem]

    return res_l

get_initial_pressures(target_d, p_guess_max=P_GUESS_MAX_BAR, n_extra=0)

Cold-start guesses for the primary partial pressures [bar].

Log-uniform draw over [P_GUESS_MIN_BAR, p_guess_max] bar, covering ~17 orders of magnitude from trace-volatile undersaturation up to the default ~100 kbar. target_d is accepted for API stability but not consulted.

Parameters:

Name Type Description Default
target_d dict

Accepted for API parity; not consulted.

required
p_guess_max float

Upper bound [bar] of the log-uniform cold-start draw. Raise it so the guess can sample higher (e.g. for a sub-Neptune) within the solver box. It sets where the cold start samples, NOT the maximum pressure the solver can accept (that is the fixed P_CEILING_BAR box), so it does not raise the achievable surface pressure. Must be finite and

= P_GUESS_MIN_BAR.

``P_GUESS_MAX_BAR``
n_extra int

Number of additional noble gas partial pressures to draw after the four CHNOS primaries, appended in _active_noble order. Each uses the same log-uniform draw.

0

Returns:

Type Description
tuple of float

(pH2O, pCO2, pN2, pS2) followed by n_extra noble gas guesses.

Source code in src/calliope/solve.py
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
def get_initial_pressures(target_d, p_guess_max=P_GUESS_MAX_BAR, n_extra=0):
    """Cold-start guesses for the primary partial pressures [bar].

    Log-uniform draw over [P_GUESS_MIN_BAR, ``p_guess_max``] bar, covering ~17
    orders of magnitude from trace-volatile undersaturation up to the default
    ~100 kbar. `target_d` is accepted for API stability but not consulted.

    Parameters
    ----------
    target_d : dict
        Accepted for API parity; not consulted.
    p_guess_max : float, default ``P_GUESS_MAX_BAR``
        Upper bound [bar] of the log-uniform cold-start draw. Raise it so the
        guess can sample higher (e.g. for a sub-Neptune) within the solver box.
        It sets where the cold start samples, NOT the maximum pressure the
        solver can accept (that is the fixed ``P_CEILING_BAR`` box), so it does
        not raise the achievable surface pressure. Must be finite and
        >= ``P_GUESS_MIN_BAR``.
    n_extra : int, default 0
        Number of additional noble gas partial pressures to draw after the
        four CHNOS primaries, appended in `_active_noble` order. Each uses
        the same log-uniform draw.

    Returns
    -------
    tuple of float
        ``(pH2O, pCO2, pN2, pS2)`` followed by ``n_extra`` noble gas guesses.
    """
    _check_guess_ceiling(p_guess_max)
    hi = np.log10(p_guess_max)
    lo = np.log10(P_GUESS_MIN_BAR)
    pH2O = 10 ** np.random.uniform(low=lo, high=hi)
    pCO2 = 10 ** np.random.uniform(low=lo, high=hi)
    pN2 = 10 ** np.random.uniform(low=lo, high=hi)
    pS2 = 10 ** np.random.uniform(low=lo, high=hi)
    extra = tuple(10 ** np.random.uniform(low=lo, high=hi) for _ in range(n_extra))

    return (pH2O, pCO2, pN2, pS2) + extra

get_initial_pressures_with_fO2(target_d, fO2_hint, restart=False, rng=None, p_guess_max=P_GUESS_MAX_BAR, n_extra=0)

Cold-start guesses for the unknowns of the authoritative-O solver.

Returns [pH2O, pCO2, pN2, pS2, fO2_shift, p_noble...] with one extra log-uniform pressure guess per active noble gas (n_extra of them) after fO2_shift. The pressures use the same log-uniform draw as get_initial_pressures over [P_GUESS_MIN_BAR, p_guess_max] bar. The fO2_shift element is fO2_hint on the first attempt; on solver restart (restart=True) it is redrawn from a uniform distribution over [FO2_GUESS_MIN, FO2_GUESS_MAX], which covers the reducing-mantle to highly-oxidized regimes likely to be encountered.

Parameters:

Name Type Description Default
target_d dict

Target elemental mass inventories. Accepted for API parity with get_initial_pressures but not consulted.

required
fO2_hint float

Initial guess for the IW-buffer offset (log10 units). Typical PROTEUS user values lie in [-4, +6].

required
restart bool

When True, redraw fO2_shift from Uniform(FO2_GUESS_MIN, FO2_GUESS_MAX). When False, return fO2_hint unchanged.

False
rng Generator or None

Random number generator for the log-uniform pressure draw and (when restart=True) the fO2 redraw. When None, the global np.random state is used. The authoritative-O entry point threads a seeded generator through this argument to make solver outcomes reproducible across calls.

None
p_guess_max float

Upper bound [bar] of the log-uniform cold-start draw. Sets where the cold start samples, NOT the maximum pressure the solver can accept (the fixed P_CEILING_BAR box). Must be finite and

= P_GUESS_MIN_BAR.

``P_GUESS_MAX_BAR``

Returns:

Type Description
tuple of ``5 + n_extra`` floats

(pH2O, pCO2, pN2, pS2, fO2_shift, p_noble...).

Source code in src/calliope/solve.py
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
def get_initial_pressures_with_fO2(
    target_d, fO2_hint, restart=False, rng=None, p_guess_max=P_GUESS_MAX_BAR, n_extra=0
):
    """Cold-start guesses for the unknowns of the authoritative-O solver.

    Returns ``[pH2O, pCO2, pN2, pS2, fO2_shift, p_noble...]`` with one extra
    log-uniform pressure guess per active noble gas (``n_extra`` of them)
    after fO2_shift. The pressures use the same log-uniform draw as
    ``get_initial_pressures`` over ``[P_GUESS_MIN_BAR, p_guess_max]`` bar.
    The fO2_shift element is ``fO2_hint`` on the first attempt; on solver
    restart (``restart=True``) it is redrawn from a uniform distribution over
    ``[FO2_GUESS_MIN, FO2_GUESS_MAX]``, which covers the reducing-mantle to
    highly-oxidized regimes likely to be encountered.

    Parameters
    ----------
    target_d : dict
        Target elemental mass inventories. Accepted for API parity with
        ``get_initial_pressures`` but not consulted.
    fO2_hint : float
        Initial guess for the IW-buffer offset (log10 units). Typical
        PROTEUS user values lie in ``[-4, +6]``.
    restart : bool, default False
        When True, redraw fO2_shift from ``Uniform(FO2_GUESS_MIN,
        FO2_GUESS_MAX)``. When False, return ``fO2_hint`` unchanged.
    rng : np.random.Generator or None, default None
        Random number generator for the log-uniform pressure draw and
        (when ``restart=True``) the fO2 redraw. When None, the global
        ``np.random`` state is used. The authoritative-O entry point
        threads a seeded generator through this argument to make solver
        outcomes reproducible across calls.
    p_guess_max : float, default ``P_GUESS_MAX_BAR``
        Upper bound [bar] of the log-uniform cold-start draw. Sets where the
        cold start samples, NOT the maximum pressure the solver can accept
        (the fixed ``P_CEILING_BAR`` box). Must be finite and
        >= ``P_GUESS_MIN_BAR``.

    Returns
    -------
    tuple of ``5 + n_extra`` floats
        ``(pH2O, pCO2, pN2, pS2, fO2_shift, p_noble...)``.
    """
    if rng is None:
        rng = np.random

    _check_guess_ceiling(p_guess_max)
    hi = np.log10(p_guess_max)
    lo = np.log10(P_GUESS_MIN_BAR)
    pH2O = 10 ** rng.uniform(low=lo, high=hi)
    pCO2 = 10 ** rng.uniform(low=lo, high=hi)
    pN2 = 10 ** rng.uniform(low=lo, high=hi)
    pS2 = 10 ** rng.uniform(low=lo, high=hi)

    if restart:
        fO2 = rng.uniform(low=FO2_GUESS_MIN, high=FO2_GUESS_MAX)
    else:
        fO2 = float(fO2_hint)

    # Noble gas pressure guesses follow fO2 in the unknown vector, matching
    # the `[pH2O, pCO2, pN2, pS2, fO2_shift, p_noble...]` order that
    # func_authoritative_O unpacks.
    extra = tuple(10 ** rng.uniform(low=lo, high=hi) for _ in range(n_extra))

    return (pH2O, pCO2, pN2, pS2, fO2) + extra

get_partial_pressures(pin, ddict)

Partial pressures [bar] of all 11 species from the 4 primaries.

Thin wrapper around _get_partial_pressures that reads fO2_shift from ddict['fO2_shift_IW']. Preserved bit-for-bit identical to the pre-refactor function for all callers.

Source code in src/calliope/solve.py
235
236
237
238
239
240
241
242
def get_partial_pressures(pin, ddict):
    """Partial pressures [bar] of all 11 species from the 4 primaries.

    Thin wrapper around ``_get_partial_pressures`` that reads fO2_shift
    from ``ddict['fO2_shift_IW']``. Preserved bit-for-bit identical to
    the pre-refactor function for all callers.
    """
    return _get_partial_pressures(pin, ddict['fO2_shift_IW'], ddict)

get_total_pressure(p_d)

Sum partial pressures to get total pressure

Source code in src/calliope/solve.py
245
246
247
def get_total_pressure(p_d):
    """Sum partial pressures to get total pressure"""
    return sum(p_d.values())

obj(pin_arr, ddict, mass_target_d)

Function to compute the residual of the mass balance given the partial pressures [bar]

Source code in src/calliope/solve.py
482
483
484
485
486
def obj(pin_arr, ddict, mass_target_d):
    """Function to compute the residual of the mass balance given the partial pressures [bar]"""

    res_l = func(pin_arr, ddict, mass_target_d)
    return np.dot(res_l, res_l) ** 0.5

obj_authoritative_O(x_arr, ddict, mass_target_d)

Scalar objective for trust-constr fallback in the authoritative-O solver.

Source code in src/calliope/solve.py
538
539
540
541
def obj_authoritative_O(x_arr, ddict, mass_target_d):
    """Scalar objective for trust-constr fallback in the authoritative-O solver."""
    res_l = func_authoritative_O(x_arr, ddict, mass_target_d)
    return np.dot(res_l, res_l) ** 0.5