Skip to content

proteus.interior_energetics.wrapper

The orchestration wrapper for the interior energetics submodule (Aragog or SPIDER). The most relevant function for Zalmoxis coupling is equilibrate_initial_state(), which iterates CALLIOPE + Zalmoxis to a converged state before the main coupling loop starts.

For the conceptual overview, see the Zalmoxis coupling explainer.

equilibrate_initial_state(dirs, config, hf_row, outdir)

Iterate CALLIOPE + Zalmoxis until structure and composition converge.

Runs volatile partitioning (CALLIOPE with optional binodal H2) followed by structure re-computation (Zalmoxis) in a loop. No SPIDER call. Convergence is checked on relative changes in R_int and P_surf.

Called from proteus.py after the initial structure solve and volatile inventory setup. The converged state provides a self-consistent starting point for SPIDER's first time step.

Parameters:

Name Type Description Default
dirs dict

Directory paths (including spider_mesh, spider_eos_dir).

required
config Config

PROTEUS configuration.

required
hf_row dict

Current helpfile row with volatile masses, R_int, P_surf, T_magma, etc.

required
outdir str

Output directory for Zalmoxis files.

required
Source code in src/proteus/interior_energetics/wrapper.py
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
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
def equilibrate_initial_state(dirs: dict, config: Config, hf_row: dict, outdir: str):
    """Iterate CALLIOPE + Zalmoxis until structure and composition converge.

    Runs volatile partitioning (CALLIOPE with optional binodal H2) followed
    by structure re-computation (Zalmoxis) in a loop. No SPIDER call.
    Convergence is checked on relative changes in R_int and P_surf.

    Called from proteus.py after the initial structure solve and volatile
    inventory setup. The converged state provides a self-consistent starting
    point for SPIDER's first time step.

    Parameters
    ----------
    dirs : dict
        Directory paths (including spider_mesh, spider_eos_dir).
    config : Config
        PROTEUS configuration.
    hf_row : dict
        Current helpfile row with volatile masses, R_int, P_surf, T_magma, etc.
    outdir : str
        Output directory for Zalmoxis files.
    """
    from proteus.interior_struct.zalmoxis import generate_spider_tables, zalmoxis_solver
    from proteus.outgas.wrapper import calc_target_elemental_inventories, run_outgassing

    max_iter = config.interior_struct.zalmoxis.equilibrate_max_iter
    tol = config.interior_struct.zalmoxis.equilibrate_tol
    nlev_b = get_nlevb(config)
    num_spider_nodes = nlev_b if config.interior_energetics.module == 'spider' else 0

    log.info(
        'Starting init equilibration loop (max %d iter, tol %.1f%%)',
        max_iter,
        tol * 100,
    )

    # Initialize convergence metrics (used in the warning if max_iter reached)
    delta_R = 1.0
    delta_P = 1.0

    # liquidus_super: re-solve the structure against the true super-liquidus
    # adiabat T(P), the same hand-off determine_interior_radius_with_zalmoxis
    # uses, so equilibration does not overwrite the isentropic, hydrostatically
    # consistent IC with the colder linear-in-r guess. The molten adiabat is set
    # by the surface superheat, not by the volatile composition this loop
    # iterates, so it is built once from the structure's current P_cmb. None =>
    # the linear guess (unchanged behaviour for SPIDER and non-liquidus_super
    # runs). P_cmb_grid records the upper pressure the grid was sized for so a
    # later iteration whose converged P_cmb exceeds it triggers a rebuild rather
    # than clipping the deep mantle to a flat T_cmb.
    adiabat_tfunc = None
    P_cmb_grid = 0.0
    use_adiabat = _use_superliquidus_adiabat_ic(config)
    if use_adiabat:
        P_cmb_seed = float(hf_row.get('P_cmb', 0.0) or 0.0)
        if P_cmb_seed > 0.0:
            P_cmb_grid = P_cmb_seed * _ADIABAT_IC_PCMB_MARGIN
            built = _build_superliquidus_adiabat_tp(config, hf_row, P_cmb_grid)
            if built is not None:
                adiabat_tfunc = built[0]

    for i in range(max_iter):
        R_old = float(hf_row.get('R_int', 0.0))
        P_old = float(hf_row.get('P_surf', 0.0))

        # 1. Volatile partitioning: recompute elemental targets and
        #    run CALLIOPE to get atmosphere/melt distribution
        calc_target_elemental_inventories(dirs, config, hf_row)
        run_outgassing(dirs, config, hf_row)

        # 2. Re-compute structure with updated composition (volatile_profile is
        #    built inside zalmoxis_solver from hf_row). When the super-liquidus
        #    adiabat is available, the structure is solved against it with a
        #    mass-anchor guard and rollback; on failure the iteration degrades
        #    to the linear-guess solve so init never aborts where the linear
        #    path would have converged. SPIDER and non-liquidus_super runs take
        #    the linear-guess solve unchanged.
        if adiabat_tfunc is not None:
            spider_mesh_file, ok = _solve_structure_with_adiabat_or_rollback(
                config,
                outdir,
                hf_row,
                num_spider_nodes,
                adiabat_tfunc,
            )
            if not ok:
                log.warning(
                    'Equilibration iter %d/%d: adiabat re-solve failed; '
                    'falling back to the linear-guess solve for this iteration.',
                    i + 1,
                    max_iter,
                )
                _cmb_radius, spider_mesh_file = zalmoxis_solver(
                    config,
                    outdir,
                    hf_row,
                    num_spider_nodes=num_spider_nodes,
                    temperature_function=None,
                )
        else:
            _cmb_radius, spider_mesh_file = zalmoxis_solver(
                config,
                outdir,
                hf_row,
                num_spider_nodes=num_spider_nodes,
                temperature_function=None,
            )

        # Update M_mantle from Zalmoxis results (M_int and M_core are set
        # by zalmoxis_solver, but M_mantle is not). run_outgassing needs
        # an up-to-date M_mantle for dissolved fraction calculations.
        hf_row['M_mantle'] = float(hf_row.get('M_int', 0.0)) - float(hf_row.get('M_core', 0.0))

        # If this iteration's converged P_cmb now exceeds the pressure span the
        # adiabat grid was tabulated over, rebuild the adiabat so the deepest
        # mantle is not clipped to a flat T_cmb on the next iteration.
        if adiabat_tfunc is not None:
            P_cmb_now = float(hf_row.get('P_cmb', 0.0) or 0.0)
            if P_cmb_now > P_cmb_grid > 0.0:
                P_cmb_grid_new = P_cmb_now * _ADIABAT_IC_PCMB_MARGIN
                log.info(
                    'Equilibration iter %d/%d: converged P_cmb=%.1f GPa exceeds '
                    'the adiabat grid ceiling %.1f GPa; rebuilding the adiabat '
                    'to %.1f GPa.',
                    i + 1,
                    max_iter,
                    P_cmb_now / 1e9,
                    P_cmb_grid / 1e9,
                    P_cmb_grid_new / 1e9,
                )
                rebuilt = _build_superliquidus_adiabat_tp(config, hf_row, P_cmb_grid_new)
                if rebuilt is not None:
                    adiabat_tfunc = rebuilt[0]
                    P_cmb_grid = P_cmb_grid_new

        # Update mesh path if written
        if spider_mesh_file:
            dirs['spider_mesh'] = spider_mesh_file
            prev_path = spider_mesh_file + '.prev'
            shutil.copy2(spider_mesh_file, prev_path)
            dirs['spider_mesh_prev'] = prev_path

        # 3. Check convergence
        R_new = float(hf_row.get('R_int', 0.0))
        P_new = float(hf_row.get('P_surf', 0.0))

        delta_R = abs(R_new - R_old) / R_old if R_old > 0 else 1.0
        delta_P = abs(P_new - P_old) / P_old if P_old > 0 else 1.0

        log.info(
            'Equilibration iter %d/%d: dR/R=%.4f, dP/P=%.4f (R=%.3e m, P=%.2f bar)',
            i + 1,
            max_iter,
            delta_R,
            delta_P,
            R_new,
            P_new,
        )

        if delta_R < tol and delta_P < tol:
            log.info('Equilibration converged after %d iterations', i + 1)
            break
    else:
        log.warning(
            'Equilibration did not converge after %d iterations (dR/R=%.4f, dP/P=%.4f)',
            max_iter,
            delta_R,
            delta_P,
        )

    # 4. Regenerate SPIDER EOS tables with final composition
    if config.interior_energetics.module in ('spider', 'aragog'):
        spider_tables = generate_spider_tables(config, outdir)
        if spider_tables is not None:
            dirs['spider_eos_dir'] = spider_tables['eos_dir']
            dirs['spider_solidus_ps'] = spider_tables['solidus_path']
            dirs['spider_liquidus_ps'] = spider_tables['liquidus_path']

solve_structure(dirs, config, hf_all, hf_row, outdir)

Solve for the planet structure based on the method set in the configuration file.

If the structure is set by the radius, then this is trivial because the radius is used as an input to the interior modules anyway. If the structure is set by mass, then it is solved as an inverse problem for now.

Source code in src/proteus/interior_energetics/wrapper.py
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
def solve_structure(
    dirs: dict, config: Config, hf_all: pd.DataFrame, hf_row: dict, outdir: str
):
    """
    Solve for the planet structure based on the method set in the configuration file.

    If the structure is set by the radius, then this is trivial because the radius is used
    as an input to the interior modules anyway. If the structure is set by mass, then it is
    solved as an inverse problem for now.
    """

    # Set by total mass (mantle + core + volatiles)
    if config.planet.mass_tot is not None:
        # Choose the method to determine the interior radius
        match config.interior_struct.module:
            case 'dummy':
                return determine_interior_radius_with_dummy(
                    dirs, config, hf_all, hf_row, outdir
                )
            case 'spider':
                return determine_interior_radius(dirs, config, hf_all, hf_row, outdir)
            case 'zalmoxis':
                # Zalmoxis computes its own radius; temporarily disable orbital
                # feedback during structure solve (restored after return).
                _orig_orbit_module = config.orbit.module
                config.orbit.module = 'dummy'
                try:
                    if config.params.stop.solid.phi_crit < 0.01:
                        log.warning(
                            'phi_crit=%.4f is below 0.01. Zalmoxis cases may plateau '
                            'at ~0.9%% melt fraction, so phi_crit < 0.01 can prevent '
                            'the simulation from terminating. Consider phi_crit >= 0.01.',
                            config.params.stop.solid.phi_crit,
                        )
                    return determine_interior_radius_with_zalmoxis(
                        dirs, config, hf_all, hf_row, outdir
                    )
                finally:
                    config.orbit.module = _orig_orbit_module
        raise ValueError(
            f"Invalid structure interior module selected '{config.interior_struct.module}'"
        )

    else:
        raise ValueError('planet.mass_tot must be set to solve for the interior structure')