From 750658521c05b8261a3e2f496a3049c0bf5b55ce Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 22 Jul 2026 14:37:47 +0200 Subject: [PATCH 01/71] Add configuration for giant-impact accretion Giant impacts during accretion grow the planet, deliver volatiles, re-melt the mantle, strip part of the atmosphere, and move the orbit. This fills in the previously empty accretion section of the config so a run can describe that history. Two backends are selectable. "morrigan" runs the giant-impact model of Kimura et al. (2025) for a system of embryos and follows one survivor; "dummy" replays a timeline written earlier, which is how the impact consequences get tested against a known event sequence. Impactor volatile content is set per element in ppmw of impactor mass and defaults to zero, so impactors are dry unless delivery is asked for. The section stays off by default, so existing configs are unaffected. The backend parameter blocks are only validated once their backend is selected. --- input/all_options.toml | 31 ++- src/proteus/config/_accretion.py | 181 ++++++++++++++- tests/config/test_accretion.py | 232 ++++++++++++++++++++ tests/tools/test_migrate_config_v2_to_v3.py | 24 ++ 4 files changed, 462 insertions(+), 6 deletions(-) create mode 100644 tests/config/test_accretion.py diff --git a/input/all_options.toml b/input/all_options.toml index 732a41019..e19c50148 100644 --- a/input/all_options.toml +++ b/input/all_options.toml @@ -576,9 +576,36 @@ config_version = "3.0" [escape.dummy] rate = 0.0 # bulk escape rate [kg s-1] -# Late accretion +# Giant-impact accretion and delivery [accretion] - module = "none" # not yet implemented + module = "none" # none | dummy | morrigan + + # Volatile content of each impactor [ppmw of impactor mass]. + # Zero means impactors add silicate and iron mass only. + impactor_H_ppmw = 0.0 # hydrogen delivered per impact + impactor_C_ppmw = 0.0 # carbon delivered per impact + impactor_N_ppmw = 0.0 # nitrogen delivered per impact + impactor_S_ppmw = 0.0 # sulfur delivered per impact + impactor_O_ppmw = 0.0 # oxygen delivered per impact + + [accretion.morrigan] + seed = 1 # Monte Carlo seed + num_planets = 10 # number of embryos at disk dispersal + masses = [] # embryo masses [M_earth]; empty = all mass_equal + mass_equal = 0.5 # embryo mass when masses is empty [M_earth] + eccentricity_init = 0.01 # initial eccentricity of every embryo + inner_edge = 0.1 # orbit of the innermost embryo [AU] + spacing = 10.0 # initial embryo separation [mutual Hill radii] + density = 5500.0 # bulk density for mass to radius [kg m-3] + impact_angle = 45.0 # impact angle [deg] + evolution_time = 1.0 # duration of the dynamical evolution [Gyr] + inner_cutoff = 0.005 # perihelion counting as lost to the star [AU] + selector = "match_config" # match_config | mass | semimajoraxis | id + selector_value = "none" # target orbit [AU] or embryo id, per selector + time_offset = 0.0 # shift of Morrigan event times onto PROTEUS time [yr] + + [accretion.dummy] + timeline_path = "none" # impact timeline file to replay # Atmospheric chemistry (post-processing) [atmos_chem] diff --git a/src/proteus/config/_accretion.py b/src/proteus/config/_accretion.py index ca9585709..02223fb37 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -1,19 +1,192 @@ from __future__ import annotations -from attr.validators import in_ +from attr.validators import ge, gt, in_ from attrs import define, field from ._converters import none_if_none +SELECTORS = ('match_config', 'mass', 'semimajoraxis', 'id') + + +def valid_morrigan(instance, attribute, value): + if instance.module != 'morrigan': + return + + mor = instance.morrigan + + if mor.masses and len(mor.masses) != mor.num_planets: + raise ValueError( + f'`accretion.morrigan.masses` has {len(mor.masses)} entries but ' + f'num_planets = {mor.num_planets}; they must match' + ) + + if any(m <= 0 for m in mor.masses): + raise ValueError('All `accretion.morrigan.masses` entries must be > 0') + + if mor.selector == 'semimajoraxis' and mor.selector_value is None: + raise ValueError( + '`accretion.morrigan.selector_value` must be set (target orbit in AU) ' + "when selector = 'semimajoraxis'" + ) + + if mor.selector == 'id' and mor.selector_value is None: + raise ValueError( + "`accretion.morrigan.selector_value` must be set (planet id) when selector = 'id'" + ) + + +@define +class Morrigan: + """Parameters for the Morrigan giant-impact module. + + Morrigan evolves a system of embryos after disk dispersal, following + Kimura et al. (2025), and reports the impacts experienced by one + selected survivor. The stellar mass is taken from ``star.mass`` rather + than repeated here, so the dynamical model and the rest of PROTEUS + cannot disagree about the host star. + + Attributes + ---------- + seed: int + Random seed for the Monte Carlo. Fixing it makes an impact + history reproducible; sweeping it samples the outcome distribution. + num_planets: int + Number of embryos the system starts with. + masses: list of float + Initial embryo masses [M_earth], one per embryo. An empty list + starts every embryo at ``mass_equal``. + mass_equal: float + Initial mass of every embryo [M_earth], used when ``masses`` is empty. + eccentricity_init: float + Initial eccentricity shared by all embryos. + inner_edge: float + Semi-major axis of the innermost embryo [AU]. + spacing: float + Initial separation between adjacent embryos, in mutual Hill radii. + density: float + Uniform bulk density used to convert embryo mass to radius [kg m-3]. + impact_angle: float + Impact angle [deg]. The impact parameter is its sine. + evolution_time: float + Duration of the dynamical evolution [Gyr]. + inner_cutoff: float + Perihelion inside which an embryo counts as lost to the star [AU]. + selector: str + Which survivor's impact history PROTEUS follows. 'match_config' + picks the survivor whose initial mass and orbit are closest to the + PROTEUS configuration, 'mass' the most massive survivor, + 'semimajoraxis' the survivor whose final orbit is nearest + ``selector_value`` [AU], and 'id' the embryo with index + ``selector_value``. + selector_value: float or None + Target value for the 'semimajoraxis' and 'id' selectors. Ignored + otherwise. + time_offset: float + Offset applied to Morrigan event times when mapping them onto the + PROTEUS time axis [yr]. Morrigan measures time from disk + dispersal; PROTEUS measures it from the start of its own + evolution. Events that land before the start of the PROTEUS run + are folded into the initial condition. + """ + + seed: int = field(default=1, validator=ge(0)) + + num_planets: int = field(default=10, validator=ge(2)) + masses: list[float] = field(factory=list) + mass_equal: float = field(default=0.5, validator=gt(0)) + eccentricity_init: float = field(default=0.01, validator=ge(0)) + + inner_edge: float = field(default=0.1, validator=gt(0)) + spacing: float = field(default=10.0, validator=gt(0)) + density: float = field(default=5500.0, validator=gt(0)) + impact_angle: float = field(default=45.0, validator=ge(0)) + + evolution_time: float = field(default=1.0, validator=gt(0)) + inner_cutoff: float = field(default=0.005, validator=gt(0)) + + selector: str = field(default='match_config', validator=in_(SELECTORS)) + selector_value: float | str | None = field(default=None, converter=none_if_none) + + time_offset: float = field(default=0.0) + + +def valid_accretiondummy(instance, attribute, value): + if instance.module != 'dummy': + return + + if instance.dummy.timeline_path is None: + raise ValueError( + '`accretion.dummy.timeline_path` must point at an impact timeline file ' + "when accretion.module = 'dummy'" + ) + + +@define +class AccretionDummy: + """Dummy accretion module, driven by a pre-written impact timeline. + + Reads a timeline file instead of running a dynamical model, so impact + consequences can be exercised against a known event sequence. + + Attributes + ---------- + timeline_path: str or None + Path to the impact timeline file. Environment variables and ``~`` + are expanded. + """ + + timeline_path: str | None = field(default=None, converter=none_if_none) + @define class Accretion: - """Late accretion / delivery model selection. + """Giant-impact accretion, delivery, and module selection. + + An impact grows the planet, delivers volatiles, re-melts the mantle, + strips part of the atmosphere, and moves the orbit. The impactor + composition below sets how much volatile mass each impactor carries; + it defaults to zero, so impactors are dry unless delivery is + requested. Attributes ---------- module: str or None - Accretion module to use. Currently only None is supported. + Accretion module to use. Choices: None, "dummy", "morrigan". + morrigan: Morrigan + Parameters for the Morrigan giant-impact module. + dummy: AccretionDummy + Parameters for the timeline-driven dummy module. + impactor_H_ppmw: float + Hydrogen carried by each impactor [ppmw of impactor mass]. + impactor_C_ppmw: float + Carbon carried by each impactor [ppmw of impactor mass]. + impactor_N_ppmw: float + Nitrogen carried by each impactor [ppmw of impactor mass]. + impactor_S_ppmw: float + Sulfur carried by each impactor [ppmw of impactor mass]. + impactor_O_ppmw: float + Oxygen carried by each impactor [ppmw of impactor mass]. """ - module: str | None = field(default='none', validator=in_((None,)), converter=none_if_none) + module: str | None = field( + default='none', + validator=in_((None, 'dummy', 'morrigan')), + converter=none_if_none, + ) + + morrigan: Morrigan = field(factory=Morrigan, validator=valid_morrigan) + dummy: AccretionDummy = field(factory=AccretionDummy, validator=valid_accretiondummy) + + # Impactor volatile content, applied to every impact. Zero means the + # impactor adds silicate and iron mass only, so the planet's bulk + # volatile concentration falls by dilution as it grows. + impactor_H_ppmw: float = field(default=0.0, validator=ge(0)) + impactor_C_ppmw: float = field(default=0.0, validator=ge(0)) + impactor_N_ppmw: float = field(default=0.0, validator=ge(0)) + impactor_S_ppmw: float = field(default=0.0, validator=ge(0)) + impactor_O_ppmw: float = field(default=0.0, validator=ge(0)) + + @property + def delivers_volatiles(self) -> bool: + """Does any impactor volatile budget exceed zero?""" + return any(getattr(self, f'impactor_{e}_ppmw') > 0.0 for e in ('H', 'C', 'N', 'S', 'O')) diff --git a/tests/config/test_accretion.py b/tests/config/test_accretion.py new file mode 100644 index 000000000..8cdd396d2 --- /dev/null +++ b/tests/config/test_accretion.py @@ -0,0 +1,232 @@ +"""Tests for the giant-impact accretion config section. + +This file targets _accretion.py (Accretion, Morrigan, AccretionDummy +parameters) and the ``[accretion]`` block of the reference configuration. +It exercises the module-selection contract, the conditional validators +that only bind when their backend is selected, the embryo-mass and +selector guards, and the impactor delivery budgets. + +See testing standards in docs/How-to/testing.md and +docs/Explanations/test_framework.md for required structure, speed, and +physics validity. +""" + +from __future__ import annotations + +import pytest + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + + +@pytest.mark.unit +def test_accretion_defaults_leave_the_module_disabled(): + """An unconfigured accretion section is inert and delivers nothing. + + The default must be off, because every existing config predates this + section and must keep running unchanged. The discriminating check is + that module resolves to the None singleton rather than the string + 'none': the main-loop dispatch tests identity against None, so a + surviving 'none' string would silently select a non-existent backend. + """ + from proteus.config._accretion import Accretion + + a = Accretion() + + assert a.module is None + assert a.module != 'none' + assert a.delivers_volatiles is False + + # Every impactor budget starts empty, so a bare accretion section + # cannot move the volatile inventory even once impacts are enabled. + for element in ('H', 'C', 'N', 'S', 'O'): + assert getattr(a, f'impactor_{element}_ppmw') == pytest.approx(0.0) + + # Sub-configs exist even when unused, so downstream attribute access + # never needs a None check before reading a backend parameter. + assert a.dummy.timeline_path is None + assert a.morrigan.selector == 'match_config' + + +@pytest.mark.unit +def test_module_validator_admits_only_registered_backends(): + """Module selection accepts the two backends and rejects anything else. + + 'kimura' and 'formation_model' are the names this model was known by + before, so they are the realistic typo cases and must fail loudly + rather than fall through to a silent no-op. + """ + from proteus.config._accretion import Accretion, AccretionDummy + + assert Accretion(module='morrigan').module == 'morrigan' + assert ( + Accretion(module='dummy', dummy=AccretionDummy(timeline_path='x.csv')).module == 'dummy' + ) + assert Accretion(module='none').module is None + + for bad in ('kimura', 'formation_model', 'Morrigan', ''): + with pytest.raises(ValueError): + Accretion(module=bad) + + +@pytest.mark.unit +def test_dummy_backend_requires_a_timeline_path(): + """The timeline-replay backend cannot run without a timeline to replay. + + Selecting the dummy backend with no path is a configuration error, not + a quiet no-op, because the user asked for impacts and would otherwise + get a run with none. The edge case in the other direction matters just + as much: the same missing path must be accepted while the module is + off, or every existing config would start failing validation. + """ + from proteus.config._accretion import Accretion, AccretionDummy + + with pytest.raises(ValueError, match='timeline_path'): + Accretion(module='dummy') + + supplied = Accretion(module='dummy', dummy=AccretionDummy(timeline_path='/tmp/t.csv')) + assert supplied.dummy.timeline_path == '/tmp/t.csv' + + # Inert while the backend is unselected, including the explicit + # 'none' sentinel that the reference TOML ships. + assert Accretion(module='none').dummy.timeline_path is None + assert Accretion().dummy.timeline_path is None + + +@pytest.mark.unit +def test_embryo_mass_list_must_match_the_embryo_count(): + """A per-embryo mass list is only meaningful at the declared length. + + A short or long list would silently truncate or pad the system, which + changes the dynamics without any diagnostic. Lengths are checked + against num_planets, an empty list is the documented "all equal" + case, and non-positive masses are rejected because an embryo of zero + or negative mass has no physical radius. + """ + from proteus.config._accretion import Accretion, Morrigan + + # Empty list is the equal-mass case and stays legal. + assert Accretion(module='morrigan', morrigan=Morrigan(num_planets=4)).morrigan.masses == [] + + # Exact-length list is accepted and preserved in order. + exact = Morrigan(num_planets=3, masses=[0.4, 1.2, 0.8]) + assert Accretion(module='morrigan', morrigan=exact).morrigan.masses == [0.4, 1.2, 0.8] + + # Too short and too long both fail, so the guard is not one-sided. + for bad_masses in ([1.0, 2.0], [1.0, 2.0, 3.0, 4.0]): + with pytest.raises(ValueError, match='num_planets'): + Accretion(module='morrigan', morrigan=Morrigan(num_planets=3, masses=bad_masses)) + + # Zero and negative embryo masses are unphysical. + for bad_mass in (0.0, -1.0): + with pytest.raises(ValueError, match='must be > 0'): + Accretion( + module='morrigan', morrigan=Morrigan(num_planets=2, masses=[1.0, bad_mass]) + ) + + # The whole check is inert while the backend is unselected. + off = Accretion(module='none', morrigan=Morrigan(num_planets=3, masses=[1.0])) + assert off.morrigan.masses == [1.0] + + +@pytest.mark.unit +def test_targeted_selectors_require_a_selector_value(): + """Selectors that aim at a target need that target supplied. + + 'semimajoraxis' and 'id' are meaningless without a value, so they must + raise. 'mass' and 'match_config' derive their target from the run + itself and must not, which is the discriminating half: a validator + that demanded a value unconditionally would break the default + configuration. + """ + from proteus.config._accretion import Accretion, Morrigan + + for targeted in ('semimajoraxis', 'id'): + with pytest.raises(ValueError, match='selector_value'): + Accretion(module='morrigan', morrigan=Morrigan(selector=targeted)) + + for targeted, value in (('semimajoraxis', 1.0), ('id', 3)): + cfg = Accretion( + module='morrigan', morrigan=Morrigan(selector=targeted, selector_value=value) + ) + assert cfg.morrigan.selector_value == value + + for untargeted in ('mass', 'match_config'): + cfg = Accretion(module='morrigan', morrigan=Morrigan(selector=untargeted)) + assert cfg.morrigan.selector == untargeted + assert cfg.morrigan.selector_value is None + + with pytest.raises(ValueError): + Morrigan(selector='instellation') + + +@pytest.mark.unit +def test_impactor_composition_drives_the_delivery_flag(): + """Delivery is on when any single element carries a positive budget. + + The flag decides whether the impact handler touches the element + inventory at all, so it must respond to each element independently. A + flag wired to only one element would look correct in any test that set + hydrogen, which is why every element is checked in isolation here. + """ + from proteus.config._accretion import Accretion + + assert Accretion().delivers_volatiles is False + + for element in ('H', 'C', 'N', 'S', 'O'): + cfg = Accretion(**{f'impactor_{element}_ppmw': 250.0}) + assert cfg.delivers_volatiles is True, f'{element} budget ignored' + assert getattr(cfg, f'impactor_{element}_ppmw') == pytest.approx(250.0) + + # A budget of exactly zero is the documented dry-impactor case and + # must not switch delivery on. + assert Accretion(impactor_H_ppmw=0.0).delivers_volatiles is False + + # Negative budgets would remove volatiles at an impact, which is the + # escape module's job, not delivery's. + with pytest.raises(ValueError): + Accretion(impactor_C_ppmw=-1.0) + + +@pytest.mark.unit +def test_reference_config_declares_the_accretion_section(): + """The shipped reference config parses and agrees with the schema. + + A key present in the TOML but absent from the schema is a silent + orphan: the user sets it, nothing happens, nothing warns. This checks + the section is orphan-free and that the documented values in the file + match the attrs defaults, so the two layers cannot drift apart. + """ + import tomllib + + from helpers import PROTEUS_ROOT + + from proteus.config import read_config_object + from proteus.config._accretion import Accretion, Morrigan + from proteus.config.orphans import check_config_orphan_free + + all_options = PROTEUS_ROOT / 'input' / 'all_options.toml' + with open(all_options, 'rb') as f: + raw = tomllib.load(f) + + assert 'accretion' in raw + assert check_config_orphan_free(raw) is True + + # The reference file ships the section disabled. + cfg = read_config_object(all_options) + assert cfg.accretion.module is None + assert cfg.accretion.delivers_volatiles is False + + # Documented values match the schema defaults, in both directions: + # editing one layer without the other now fails here. + defaults = Accretion() + for element in ('H', 'C', 'N', 'S', 'O'): + key = f'impactor_{element}_ppmw' + assert raw['accretion'][key] == pytest.approx(getattr(defaults, key)) + + morrigan_defaults = Morrigan() + for key in ('seed', 'num_planets', 'selector'): + assert raw['accretion']['morrigan'][key] == getattr(morrigan_defaults, key) + for key in ('inner_edge', 'spacing', 'density', 'impact_angle', 'evolution_time'): + assert raw['accretion']['morrigan'][key] == pytest.approx( + getattr(morrigan_defaults, key) + ) diff --git a/tests/tools/test_migrate_config_v2_to_v3.py b/tests/tools/test_migrate_config_v2_to_v3.py index 0976c3896..24af8e65e 100644 --- a/tests/tools/test_migrate_config_v2_to_v3.py +++ b/tests/tools/test_migrate_config_v2_to_v3.py @@ -78,6 +78,30 @@ def _v3(): # decision: pin it (OVERRIDES) or certify it neutral (add it here). _REVIEWED_NEUTRAL = frozenset( { + # Accretion is off by default and every impactor budget starts at + # zero, so a migrated config experiences no impacts and no delivery. + # The morrigan and dummy sub-blocks are only read once their backend + # is selected, which migration never does. + 'accretion.dummy.timeline_path', + 'accretion.impactor_C_ppmw', + 'accretion.impactor_H_ppmw', + 'accretion.impactor_N_ppmw', + 'accretion.impactor_O_ppmw', + 'accretion.impactor_S_ppmw', + 'accretion.morrigan.density', + 'accretion.morrigan.eccentricity_init', + 'accretion.morrigan.evolution_time', + 'accretion.morrigan.impact_angle', + 'accretion.morrigan.inner_cutoff', + 'accretion.morrigan.inner_edge', + 'accretion.morrigan.mass_equal', + 'accretion.morrigan.masses', + 'accretion.morrigan.num_planets', + 'accretion.morrigan.seed', + 'accretion.morrigan.selector', + 'accretion.morrigan.selector_value', + 'accretion.morrigan.spacing', + 'accretion.morrigan.time_offset', 'atmos_clim.aerosols_enabled', 'atmos_clim.agni.grey_opacity_lw', 'atmos_clim.agni.grey_opacity_sw', From 721a99dd4864e283f6848b1b4a031bd0c4d4151b Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 22 Jul 2026 14:51:10 +0200 Subject: [PATCH 02/71] Add the accretion module and its impact timeline Builds the module that turns a giant-impact history into something the main loop can act on. An impact timeline is prepared once at start-up, in the same way the stellar evolution track is, and lists the impacts the planet will experience with the masses, velocities, geometry, and post-impact orbit of each one. Two ways to obtain the timeline. The morrigan backend runs the giant-impact model of Kimura et al. (2025) for a system of embryos and follows one survivor, chosen by mass, by target orbit, by name, or by resemblance to the configured planet. The dummy backend replays a timeline written earlier. Either way the result is validated before the run starts: masses must close across each merger, the collision velocity cannot fall below the mutual escape velocity, the impact geometry and eccentricity must be in range, and the target mass must chain from one impact to the next, so an inconsistent history is rejected up front instead of surfacing halfway through a run. Impacts that land before the run begins are reported with the mass they would have added, since the configured planet mass and orbit define the initial state and those impacts cannot be applied without contradicting it. Nothing is applied at an impact yet; that follows. With no accretion module selected the schedule is empty and the run is unchanged. --- input/all_options.toml | 2 +- src/proteus/accretion/__init__.py | 3 + src/proteus/accretion/common.py | 360 ++++++++++++++ src/proteus/accretion/dummy.py | 36 ++ src/proteus/accretion/morrigan.py | 223 +++++++++ src/proteus/accretion/wrapper.py | 98 ++++ src/proteus/config/_accretion.py | 16 +- src/proteus/config/_config.py | 7 + src/proteus/proteus.py | 9 + src/proteus/utils/coupler.py | 4 +- tests/accretion/__init__.py | 0 tests/accretion/test_common.py | 446 ++++++++++++++++++ tests/accretion/test_dummy.py | 152 ++++++ tests/accretion/test_morrigan.py | 315 +++++++++++++ tests/accretion/test_wrapper.py | 166 +++++++ tests/config/test_config_schema_invariants.py | 1 + tests/tools/test_migrate_config_v2_to_v3.py | 2 +- 17 files changed, 1829 insertions(+), 11 deletions(-) create mode 100644 src/proteus/accretion/__init__.py create mode 100644 src/proteus/accretion/common.py create mode 100644 src/proteus/accretion/dummy.py create mode 100644 src/proteus/accretion/morrigan.py create mode 100644 src/proteus/accretion/wrapper.py create mode 100644 tests/accretion/__init__.py create mode 100644 tests/accretion/test_common.py create mode 100644 tests/accretion/test_dummy.py create mode 100644 tests/accretion/test_morrigan.py create mode 100644 tests/accretion/test_wrapper.py diff --git a/input/all_options.toml b/input/all_options.toml index e19c50148..6c4f8a1ba 100644 --- a/input/all_options.toml +++ b/input/all_options.toml @@ -579,6 +579,7 @@ config_version = "3.0" # Giant-impact accretion and delivery [accretion] module = "none" # none | dummy | morrigan + time_offset = 0.0 # shift of impact times onto the PROTEUS time axis [yr] # Volatile content of each impactor [ppmw of impactor mass]. # Zero means impactors add silicate and iron mass only. @@ -602,7 +603,6 @@ config_version = "3.0" inner_cutoff = 0.005 # perihelion counting as lost to the star [AU] selector = "match_config" # match_config | mass | semimajoraxis | id selector_value = "none" # target orbit [AU] or embryo id, per selector - time_offset = 0.0 # shift of Morrigan event times onto PROTEUS time [yr] [accretion.dummy] timeline_path = "none" # impact timeline file to replay diff --git a/src/proteus/accretion/__init__.py b/src/proteus/accretion/__init__.py new file mode 100644 index 000000000..4d21ee850 --- /dev/null +++ b/src/proteus/accretion/__init__.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +__all__ = [] diff --git a/src/proteus/accretion/common.py b/src/proteus/accretion/common.py new file mode 100644 index 000000000..c4f91ba32 --- /dev/null +++ b/src/proteus/accretion/common.py @@ -0,0 +1,360 @@ +# Shared data structures for giant-impact accretion +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +import numpy as np +import pandas as pd +from attrs import define, field + +if TYPE_CHECKING: + from collections.abc import Sequence + +log = logging.getLogger('fwl.' + __name__) + +# Columns an impact timeline must carry, in the order they are documented. +# Every consequence PROTEUS applies at an impact is derived from these, so a +# timeline missing any of them is rejected rather than partially applied. +TIMELINE_COLUMNS = ( + 'time', + 'M_target_before', + 'M_impactor', + 'M_merged_after', + 'v_impact', + 'v_esc', + 'impact_parameter', + 'R_target_before', + 'R_impactor', + 'rho_target', + 'rho_impactor', + 'a_before', + 'a_after', + 'e_after', + 'id_target', + 'id_impactor', +) + +# Mass closure of a perfect merger. Tight, because the merged mass is a plain +# sum in the dynamical model, so anything looser would hide a real error. +MASS_CLOSURE_RTOL = 1e-6 + +# Collision velocity cannot fall below the mutual escape velocity, since it is +# sqrt(v_inf^2 + v_esc^2). The tolerance absorbs round-trip formatting only. +VELOCITY_FLOOR_RTOL = 1e-6 + + +@define(frozen=True) +class ImpactEvent: + """One giant impact on the planet PROTEUS is following. + + Times are on the PROTEUS time axis, so any offset between the + dynamical model's zero point and the start of the PROTEUS run has + already been applied. Everything else is SI. + + Attributes + ---------- + time: float + Time of the impact [yr]. + M_target_before: float + Mass of the target immediately before the impact [kg]. + M_impactor: float + Mass of the impactor [kg]. + M_merged_after: float + Mass of the merged body [kg], before any atmospheric loss. + v_impact: float + Collision velocity [m s-1]. + v_esc: float + Mutual escape velocity of the pair [m s-1]. + impact_parameter: float + Impact parameter, the sine of the impact angle [1]. Zero is a + head-on collision, one is a grazing collision. + R_target_before: float + Radius of the target immediately before the impact [m]. + R_impactor: float + Radius of the impactor [m]. + rho_target: float + Bulk density of the target [kg m-3]. + rho_impactor: float + Bulk density of the impactor [kg m-3]. + a_before: float + Semi-major axis of the target before the impact [m]. + a_after: float + Semi-major axis of the merged body [m]. + e_after: float + Eccentricity of the merged body [1]. + id_target: int + Identifier of the target body. + id_impactor: int + Identifier of the impactor. + """ + + time: float = field() + M_target_before: float = field() + M_impactor: float = field() + M_merged_after: float = field() + v_impact: float = field() + v_esc: float = field() + impact_parameter: float = field() + R_target_before: float = field() + R_impactor: float = field() + rho_target: float = field() + rho_impactor: float = field() + a_before: float = field() + a_after: float = field() + e_after: float = field() + id_target: int = field(default=-1) + id_impactor: int = field(default=-1) + + @property + def mass_delta(self) -> float: + """Mass added to the planet by this impact [kg].""" + return self.M_merged_after - self.M_target_before + + @property + def semimajoraxis_ratio(self) -> float: + """Factor by which this impact scales the semi-major axis [1]. + + The orbit is applied as a ratio rather than an absolute value + because the PROTEUS configuration owns the planet's orbit; a + borrowed impact history moves it proportionally instead of + replacing it. + """ + return self.a_after / self.a_before + + +def _check_event_physics(event: ImpactEvent, index: int) -> None: + """Raise if an impact record is not physically self-consistent. + + Parameters + ---------- + event : ImpactEvent + Record to check. + index : int + Position in the timeline, used in error messages. + + Raises + ------ + ValueError + If any mass, radius, density, or velocity is non-positive, if the + merged mass does not close, if the collision velocity is below the + mutual escape velocity, or if the impact parameter or eccentricity + falls outside its range. + """ + where = f'impact {index} at t = {event.time:.4e} yr' + + for name in ( + 'M_target_before', + 'M_impactor', + 'M_merged_after', + 'R_target_before', + 'R_impactor', + 'rho_target', + 'rho_impactor', + 'a_before', + 'a_after', + 'v_impact', + 'v_esc', + ): + value = getattr(event, name) + if not np.isfinite(value) or value <= 0.0: + raise ValueError(f'{where}: {name} must be finite and > 0, got {value!r}') + + # Perfect merging: the merged body carries the mass of both bodies. + expected = event.M_target_before + event.M_impactor + if abs(event.M_merged_after - expected) > MASS_CLOSURE_RTOL * expected: + raise ValueError( + f'{where}: merged mass {event.M_merged_after:.6e} kg does not close ' + f'against {event.M_target_before:.6e} + {event.M_impactor:.6e} = ' + f'{expected:.6e} kg' + ) + + # A collision velocity below the mutual escape velocity is unreachable: + # v_impact = sqrt(v_inf^2 + v_esc^2) >= v_esc for any approach velocity. + if event.v_impact < event.v_esc * (1.0 - VELOCITY_FLOOR_RTOL): + raise ValueError( + f'{where}: collision velocity {event.v_impact:.6e} m/s is below the ' + f'mutual escape velocity {event.v_esc:.6e} m/s' + ) + + if not 0.0 <= event.impact_parameter <= 1.0: + raise ValueError( + f'{where}: impact parameter must be in [0, 1], got {event.impact_parameter!r}' + ) + + if not 0.0 <= event.e_after < 1.0: + raise ValueError( + f'{where}: post-impact eccentricity must be in [0, 1), got {event.e_after!r}' + ) + + +def validate_timeline(events: Sequence[ImpactEvent]) -> None: + """Check a whole timeline for self-consistency. + + Every record must be physically valid on its own, times must increase + strictly so each impact can be scheduled unambiguously, and the mass + handed from one impact to the next must be continuous. + + Parameters + ---------- + events : sequence of ImpactEvent + Timeline to check, in time order. + + Raises + ------ + ValueError + If any record is invalid, if two impacts share a time or run + backwards, or if the target mass jumps between consecutive impacts. + """ + previous: ImpactEvent | None = None + + for index, event in enumerate(events): + _check_event_physics(event, index) + + if previous is not None: + if event.time <= previous.time: + raise ValueError( + f'impact {index} at t = {event.time:.4e} yr does not follow ' + f'impact {index - 1} at t = {previous.time:.4e} yr; times must ' + 'increase strictly' + ) + + # The body that emerges from one impact is the target of the + # next, so a mass discontinuity means the rows describe + # different planets. + if abs(event.M_target_before - previous.M_merged_after) > ( + MASS_CLOSURE_RTOL * previous.M_merged_after + ): + raise ValueError( + f'impact {index}: target mass {event.M_target_before:.6e} kg does ' + f'not continue from the previous merged mass ' + f'{previous.M_merged_after:.6e} kg; the rows describe different bodies' + ) + + previous = event + + +def read_timeline(path: str, time_offset: float = 0.0) -> list[ImpactEvent]: + """Read an impact timeline from file. + + Accepts comma- or whitespace-separated columns with a header row; + lines beginning with ``#`` are ignored. Environment variables and + ``~`` in the path are expanded. + + Parameters + ---------- + path : str + Path to the timeline file. + time_offset : float + Added to every time in the file [yr], mapping the dynamical + model's zero point onto the PROTEUS time axis. + + Returns + ------- + events : list of ImpactEvent + Timeline in time order. + + Raises + ------ + FileNotFoundError + If the timeline file does not exist. + ValueError + If required columns are missing, if the file holds no impacts, or + if the timeline fails validation. + """ + resolved = os.path.expandvars(os.path.expanduser(path)) + if not os.path.exists(resolved): + raise FileNotFoundError(f'Impact timeline file does not exist: {resolved}') + + table = pd.read_csv(resolved, sep=None, engine='python', comment='#') + table.columns = [str(c).strip() for c in table.columns] + + missing = [c for c in TIMELINE_COLUMNS if c not in table.columns] + if missing: + raise ValueError( + f'Impact timeline {resolved} is missing required columns: {missing}. ' + f'Expected all of: {list(TIMELINE_COLUMNS)}' + ) + + if len(table) == 0: + raise ValueError( + f'Impact timeline {resolved} contains no impacts. Disable the accretion ' + 'module instead of supplying an empty timeline.' + ) + + table = table.sort_values('time', kind='stable') + + events = [ + ImpactEvent( + time=float(row['time']) + time_offset, + M_target_before=float(row['M_target_before']), + M_impactor=float(row['M_impactor']), + M_merged_after=float(row['M_merged_after']), + v_impact=float(row['v_impact']), + v_esc=float(row['v_esc']), + impact_parameter=float(row['impact_parameter']), + R_target_before=float(row['R_target_before']), + R_impactor=float(row['R_impactor']), + rho_target=float(row['rho_target']), + rho_impactor=float(row['rho_impactor']), + a_before=float(row['a_before']), + a_after=float(row['a_after']), + e_after=float(row['e_after']), + id_target=int(row['id_target']), + id_impactor=int(row['id_impactor']), + ) + for _, row in table.iterrows() + ] + + validate_timeline(events) + + log.info('Read %d impacts from %s', len(events), resolved) + return events + + +def next_event(events: Sequence[ImpactEvent], time: float) -> ImpactEvent | None: + """Return the first impact strictly after the given time. + + Parameters + ---------- + events : sequence of ImpactEvent + Timeline in time order. + time : float + Current simulation time [yr]. + + Returns + ------- + event : ImpactEvent or None + The next scheduled impact, or None once the timeline is exhausted. + """ + for event in events: + if event.time > time: + return event + return None + + +def due_events( + events: Sequence[ImpactEvent], time_previous: float, time_now: float +) -> list[ImpactEvent]: + """Return the impacts falling in a time interval. + + The interval is half-open, excluding ``time_previous`` and including + ``time_now``, so an impact is applied exactly once no matter how the + timestep lands on it. + + Parameters + ---------- + events : sequence of ImpactEvent + Timeline in time order. + time_previous : float + Simulation time at the start of the step [yr]. + time_now : float + Simulation time at the end of the step [yr]. + + Returns + ------- + due : list of ImpactEvent + Impacts to apply for this step, in time order. + """ + return [e for e in events if time_previous < e.time <= time_now] diff --git a/src/proteus/accretion/dummy.py b/src/proteus/accretion/dummy.py new file mode 100644 index 000000000..3b61e3e0c --- /dev/null +++ b/src/proteus/accretion/dummy.py @@ -0,0 +1,36 @@ +# Timeline-replay accretion module +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from proteus.accretion.common import read_timeline + +if TYPE_CHECKING: + from proteus.accretion.common import ImpactEvent + from proteus.config import Config + +log = logging.getLogger('fwl.' + __name__) + + +def get_timeline(config: Config) -> list[ImpactEvent]: + """Read a pre-written impact timeline. + + Replays a timeline produced earlier instead of running a dynamical + model, so impact consequences can be driven from a known event + sequence. + + Parameters + ---------- + config : Config + Model configuration. + + Returns + ------- + events : list of ImpactEvent + Impacts to apply during the run, in time order. + """ + path = config.accretion.dummy.timeline_path + log.info('Reading impact timeline from file') + + return read_timeline(path, time_offset=config.accretion.time_offset) diff --git a/src/proteus/accretion/morrigan.py b/src/proteus/accretion/morrigan.py new file mode 100644 index 000000000..c8e1c10cf --- /dev/null +++ b/src/proteus/accretion/morrigan.py @@ -0,0 +1,223 @@ +# Functions used to run the Morrigan giant-impact module +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import numpy as np + +from proteus.accretion.common import ImpactEvent, validate_timeline +from proteus.utils.constants import AU, M_earth + +if TYPE_CHECKING: + from collections.abc import Sequence + + from proteus.config import Config + +log = logging.getLogger('fwl.' + __name__) + +try: + import morrigan # type: ignore +except ModuleNotFoundError: # optional dependency + morrigan = None + +# Entry point Morrigan must expose for PROTEUS to drive it. +MORRIGAN_ENTRY_POINT = 'run_system' + +INSTALL_HINT = ( + "accretion.module = 'morrigan' requires the morrigan package. " + 'Install it with: git clone git@github.com:FormingWorlds/Morrigan && ' + 'pip install -e Morrigan/.' +) + + +def require_morrigan(): + """Return the Morrigan package, or explain how to install it. + + Returns + ------- + module + The imported ``morrigan`` package. + + Raises + ------ + ImportError + If the package is not installed, or is installed but does not + expose the entry point PROTEUS drives it through. + """ + if morrigan is None: + raise ImportError(INSTALL_HINT) + + if not hasattr(morrigan, MORRIGAN_ENTRY_POINT): + raise ImportError( + f'The installed morrigan package does not expose ' + f'{MORRIGAN_ENTRY_POINT}(), which PROTEUS uses to run a system. ' + 'Update morrigan to a version that provides it.' + ) + + return morrigan + + +def select_planet(survivors: Sequence[dict], config: Config) -> dict: + """Choose which surviving body's impact history PROTEUS follows. + + A dynamical run leaves several survivors; PROTEUS simulates one. Each + survivor record carries ``id``, ``mass_initial`` and ``a_initial`` + (its state at the start of the dynamical run), and ``mass_final`` and + ``a_final`` (its state at the end), in SI units. + + The selectors are: ``match_config``, which picks the survivor whose + starting mass and orbit are closest to the configured planet, so a + borrowed history belongs to a body resembling the one being + simulated; ``mass``, the most massive survivor; ``semimajoraxis``, + the survivor whose final orbit is nearest a target in AU; and ``id``, + an explicitly named body. + + Parameters + ---------- + survivors : sequence of dict + Surviving bodies from the dynamical run. + config : Config + Model configuration. + + Returns + ------- + survivor : dict + The selected record. + + Raises + ------ + ValueError + If there are no survivors, or if the 'id' selector names a body + that did not survive. + """ + if not survivors: + raise ValueError( + 'The dynamical run left no surviving bodies, so there is no impact ' + 'history to follow. Check the accretion.morrigan settings.' + ) + + mor = config.accretion.morrigan + + match mor.selector: + case 'mass': + chosen = max(survivors, key=lambda s: s['mass_final']) + + case 'semimajoraxis': + target = float(mor.selector_value) * AU + chosen = min(survivors, key=lambda s: abs(s['a_final'] - target)) + + case 'id': + wanted = int(mor.selector_value) + matches = [s for s in survivors if int(s['id']) == wanted] + if not matches: + available = sorted(int(s['id']) for s in survivors) + raise ValueError( + f'accretion.morrigan.selector_value = {wanted} names a body that ' + f'did not survive. Surviving ids: {available}' + ) + chosen = matches[0] + + case _: # 'match_config' + # Compare in relative terms so mass and orbit contribute + # comparably; an absolute distance in SI would be dominated by + # whichever quantity happens to carry the larger exponent. + target_mass = config.planet.mass_tot * M_earth + target_a = config.orbit.semimajoraxis * AU + chosen = min( + survivors, + key=lambda s: np.hypot( + (s['mass_initial'] - target_mass) / target_mass, + (s['a_initial'] - target_a) / target_a, + ), + ) + + log.info( + "Following body %s (selector '%s'): %.3f -> %.3f M_earth, %.4f -> %.4f AU", + chosen['id'], + mor.selector, + chosen['mass_initial'] / M_earth, + chosen['mass_final'] / M_earth, + chosen['a_initial'] / AU, + chosen['a_final'] / AU, + ) + + return chosen + + +def build_parameters(config: Config) -> dict: + """Translate the PROTEUS configuration into Morrigan run parameters. + + The stellar mass is taken from ``star.mass`` rather than from the + accretion section, so the dynamical model and the rest of the run + cannot disagree about the host star. + + Parameters + ---------- + config : Config + Model configuration. + + Returns + ------- + params : dict + Keyword arguments for the Morrigan entry point. Masses are in kg + and lengths in m. + """ + mor = config.accretion.morrigan + + masses = list(mor.masses) if mor.masses else [mor.mass_equal] * mor.num_planets + + return { + 'seed': mor.seed, + 'masses': [m * M_earth for m in masses], + 'eccentricity': mor.eccentricity_init, + 'inner_edge': mor.inner_edge * AU, + 'spacing': mor.spacing, + 'density': mor.density, + 'impact_angle': mor.impact_angle, + 'evolution_time': mor.evolution_time, + 'inner_cutoff': mor.inner_cutoff * AU, + 'stellar_mass': config.star.mass, + } + + +def get_timeline(config: Config) -> list[ImpactEvent]: + """Run a system and return the selected body's impact history. + + Parameters + ---------- + config : Config + Model configuration. + + Returns + ------- + events : list of ImpactEvent + Impacts on the selected body, in time order. + + Raises + ------ + ImportError + If the morrigan package is unavailable. + KeyError + If the run reports no impact history for the selected body. + """ + package = require_morrigan() + + params = build_parameters(config) + log.info('Running giant-impact model for %d embryos', len(params['masses'])) + + outcome = getattr(package, MORRIGAN_ENTRY_POINT)(**params) + + chosen = select_planet(outcome['survivors'], config) + records = outcome['impacts'][chosen['id']] + + offset = config.accretion.time_offset + events = [ + ImpactEvent(**{**record, 'time': float(record['time']) + offset}) for record in records + ] + events.sort(key=lambda e: e.time) + + validate_timeline(events) + + log.info('Body %s experienced %d impacts', chosen['id'], len(events)) + return events diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py new file mode 100644 index 000000000..0f7deb00a --- /dev/null +++ b/src/proteus/accretion/wrapper.py @@ -0,0 +1,98 @@ +# Generic accretion wrapper +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from proteus.utils.constants import M_earth + +if TYPE_CHECKING: + from proteus.accretion.common import ImpactEvent + from proteus.proteus import Proteus + +log = logging.getLogger('fwl.' + __name__) + + +def init_accretion(handler: Proteus) -> list[ImpactEvent]: + """Prepare the impact timeline for a run. + + Builds the list of giant impacts the planet will experience, either by + running a dynamical model or by replaying a timeline written earlier. + The list is fixed at initialisation and consulted on every step, in + the same way the stellar evolution track is. + + Parameters + ---------- + handler : Proteus + Proteus object instance. + + Returns + ------- + events : list of ImpactEvent + Impacts to apply during the run, in time order. Empty when no + accretion module is selected. + """ + config = handler.config + module = config.accretion.module + + if module is None: + return [] + + log.info('Preparing accretion model') + log.info('') + + match module: + case 'dummy': + from proteus.accretion.dummy import get_timeline + case 'morrigan': + from proteus.accretion.morrigan import get_timeline + case _: + raise ValueError(f"Invalid accretion module: '{module}'") + + events = get_timeline(config) + + return _drop_events_before_start(events, handler.hf_row.get('Time', 0.0)) + + +def _drop_events_before_start( + events: list[ImpactEvent], time_start: float +) -> list[ImpactEvent]: + """Remove impacts that precede the start of the simulation. + + The configuration owns the planet's initial mass and orbit, so an + impact that lands before the run begins cannot be applied without + contradicting it. Such impacts are reported rather than dropped in + silence, since they usually mean the time offset needs adjusting. + + Parameters + ---------- + events : list of ImpactEvent + Timeline, in time order. + time_start : float + Simulation time at the start of the run [yr]. + + Returns + ------- + kept : list of ImpactEvent + Impacts at or after the start of the run. + """ + kept = [e for e in events if e.time > time_start] + dropped = len(events) - len(kept) + + if dropped: + missed_mass = sum(e.mass_delta for e in events if e.time <= time_start) + log.warning( + '%d impact(s) fall at or before the start of the run (t = %.4e yr) and ' + 'will not be applied, because the configured planet mass and orbit define ' + 'the initial state. They would have added %.4f M_earth. Adjust ' + 'accretion.time_offset to bring them into the simulated interval.', + dropped, + time_start, + missed_mass / M_earth, + ) + + log.info('Scheduled %d impact(s)', len(kept)) + if kept: + log.info(' first at %.4e yr, last at %.4e yr', kept[0].time, kept[-1].time) + + return kept diff --git a/src/proteus/config/_accretion.py b/src/proteus/config/_accretion.py index 02223fb37..92b22a379 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -81,12 +81,6 @@ class Morrigan: selector_value: float or None Target value for the 'semimajoraxis' and 'id' selectors. Ignored otherwise. - time_offset: float - Offset applied to Morrigan event times when mapping them onto the - PROTEUS time axis [yr]. Morrigan measures time from disk - dispersal; PROTEUS measures it from the start of its own - evolution. Events that land before the start of the PROTEUS run - are folded into the initial condition. """ seed: int = field(default=1, validator=ge(0)) @@ -107,8 +101,6 @@ class Morrigan: selector: str = field(default='match_config', validator=in_(SELECTORS)) selector_value: float | str | None = field(default=None, converter=none_if_none) - time_offset: float = field(default=0.0) - def valid_accretiondummy(instance, attribute, value): if instance.module != 'dummy': @@ -156,6 +148,12 @@ class Accretion: Parameters for the Morrigan giant-impact module. dummy: AccretionDummy Parameters for the timeline-driven dummy module. + time_offset: float + Offset applied to every impact time when mapping the timeline onto + the PROTEUS time axis [yr]. A dynamical model measures time from + disk dispersal, while PROTEUS measures it from the start of its + own evolution. Impacts landing before the start of the run are + folded into the initial condition. impactor_H_ppmw: float Hydrogen carried by each impactor [ppmw of impactor mass]. impactor_C_ppmw: float @@ -177,6 +175,8 @@ class Accretion: morrigan: Morrigan = field(factory=Morrigan, validator=valid_morrigan) dummy: AccretionDummy = field(factory=AccretionDummy, validator=valid_accretiondummy) + time_offset: float = field(default=0.0) + # Impactor volatile content, applied to every impact. Zero means the # impactor adds silicate and iron mass only, so the planet's bulk # volatile concentration falls by dilution as it grows. diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index 02460ef66..df2d818df 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -106,6 +106,13 @@ def check_module_dependencies(instance, attribute, value): 'escape.module = "boreas" requires the optional boreas package. ' 'Install it with: bash tools/get_boreas.sh', ), + 'morrigan': ( + instance.accretion.module == 'morrigan', + 'morrigan', + 'accretion.module = "morrigan" requires the morrigan package, which ' + 'runs the giant-impact model. Install it with: ' + 'git clone git@github.com:FormingWorlds/Morrigan && pip install -e Morrigan/.', + ), } for name, (needed, pkg, msg) in checks.items(): diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index c77073e74..6393017e9 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -98,6 +98,9 @@ def __init__(self, *, config_path: Path | str) -> None: self.star_wl = None self.star_fl = None + # Giant impacts scheduled for this run, empty when accretion is off + self.impact_events: list = [] + # Time at which star was last updated self.sspec_prev = -np.inf # spectrum self.sinst_prev = -np.inf # instellation and radius @@ -269,6 +272,8 @@ def start(self, *, resume: bool = False, offline: bool = False): # Import things needed to run PROTEUS # atmospheric chemistry + # giant-impact accretion + from proteus.accretion.wrapper import init_accretion from proteus.atmos_chem.wrapper import run_chemistry # atmosphere solver @@ -705,6 +710,10 @@ def start(self, *, resume: bool = False, offline: bool = False): # Prepare orbit stuff init_orbit(self) + # Prepare the giant-impact timeline. Fixed at initialisation and + # consulted on every step, like the stellar evolution track. + self.impact_events = init_accretion(self) + # Track the last simulation time at which data was written to disk, # so that dt_write_rel can suppress high-frequency writes during # rapid early evolution. Initialised to -inf so the first eligible diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index 42a908a7a..25e260b52 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -454,8 +454,10 @@ def _cite(key: str, url: str): case _: pass - # Delivery module + # Accretion module match config.accretion.module: + case 'morrigan': + _cite('Kimura et al. (2025)', 'https://doi.org/10.3847/1538-4357/ade992') case _: pass diff --git a/tests/accretion/__init__.py b/tests/accretion/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/accretion/test_common.py b/tests/accretion/test_common.py new file mode 100644 index 000000000..2e77003ac --- /dev/null +++ b/tests/accretion/test_common.py @@ -0,0 +1,446 @@ +"""Tests for the impact-timeline data structures and their validation. + +This file targets accretion/common.py (ImpactEvent, validate_timeline, +read_timeline, next_event, due_events). The timeline is the interface +between a dynamical model and every consequence PROTEUS applies at an +impact, so the invariants exercised here are mass closure of a perfect +merger, the escape-velocity floor on the collision velocity, boundedness +of the impact geometry, and continuity of the target mass along the +chain. + +See testing standards in docs/How-to/testing.md and +docs/Explanations/test_framework.md for required structure, speed, and +physics validity. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from proteus.accretion.common import ( + TIMELINE_COLUMNS, + ImpactEvent, + due_events, + next_event, + read_timeline, + validate_timeline, +) +from proteus.utils.constants import const_G + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + + +def _event(**overrides) -> ImpactEvent: + """Build a physically self-consistent impact record. + + Roughly a Mars-mass impactor onto a proto-Earth at 1 au: the masses + close, the collision velocity sits above the mutual escape velocity, + and the geometry is in range. Individual fields are overridden by + tests that want one quantity broken at a time. + """ + base = dict( + time=1.0e5, + M_target_before=6.0e24, + M_impactor=6.4e23, + M_merged_after=6.64e24, + v_impact=1.30e4, + v_esc=1.15e4, + impact_parameter=0.7, + R_target_before=6.371e6, + R_impactor=3.390e6, + rho_target=5510.0, + rho_impactor=3930.0, + a_before=1.496e11, + a_after=1.400e11, + e_after=0.05, + id_target=1, + id_impactor=4, + ) + base.update(overrides) + return ImpactEvent(**base) + + +def _write_timeline(path, rows, sep=',', header_extra=''): + """Write rows to a timeline file using the documented column order.""" + lines = [header_extra] if header_extra else [] + lines.append(sep.join(TIMELINE_COLUMNS)) + for row in rows: + lines.append(sep.join(repr(row[c]) for c in TIMELINE_COLUMNS)) + path.write_text('\n'.join(lines) + '\n') + return path + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_event_deltas_report_the_added_mass_and_orbit_change(): + """The derived deltas are what the impact handler applies to the planet. + + Mass is applied additively and the orbit multiplicatively, because the + configuration owns the planet's initial state and a borrowed history + moves it rather than replacing it. The mass delta must therefore equal + the impactor mass exactly, not the merged mass, which is the plausible + wrong reading and differs here by a factor of ten. + """ + event = _event() + + assert event.mass_delta == pytest.approx(6.4e23, rel=1e-12) + # Discrimination: the merged mass is an order of magnitude larger, so + # a handler that added it instead could not pass this tolerance. + assert abs(event.M_merged_after - event.mass_delta) > 0.5 * event.M_merged_after + + assert event.semimajoraxis_ratio == pytest.approx(1.400e11 / 1.496e11, rel=1e-12) + # An inward scattering must shrink the orbit, never grow it. + assert event.semimajoraxis_ratio < 1.0 + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_merged_mass_must_close_against_the_colliding_pair(): + """Perfect merging conserves mass, so the timeline must too. + + A merged mass that does not equal the sum of the two bodies would let + the planet gain or lose mass that no process accounts for, breaking + the run's mass budget. The tolerance is checked from both sides: a + round-off perturbation is accepted, a per-mille error is not. + """ + validate_timeline([_event()]) + + # Round-off scale perturbation stays inside the closure tolerance. + validate_timeline([_event(M_merged_after=6.64e24 * (1.0 + 1.0e-9))]) + + # A per-mille discrepancy is a real error and must be rejected. + for factor in (1.0 + 1.0e-3, 1.0 - 1.0e-3): + with pytest.raises(ValueError, match='does not close'): + validate_timeline([_event(M_merged_after=6.64e24 * factor)]) + + # Dropping the impactor entirely is the classic wrong formula. + with pytest.raises(ValueError, match='does not close'): + validate_timeline([_event(M_merged_after=6.0e24)]) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_collision_velocity_cannot_fall_below_mutual_escape_velocity(): + """A collision velocity below the escape velocity is kinematically impossible. + + Two bodies falling together from rest already arrive at the mutual + escape velocity, since v_impact = sqrt(v_inf^2 + v_esc^2). Anything + slower means the velocities were mismatched or swapped, which would + feed a nonsensical impact energy to the loss law downstream. + """ + # Parabolic limit, v_inf = 0: the two velocities coincide and are legal. + validate_timeline([_event(v_impact=1.15e4, v_esc=1.15e4)]) + + # Hyperbolic approach: strictly faster, also legal. + validate_timeline([_event(v_impact=2.00e4, v_esc=1.15e4)]) + + # Swapped fields, the realistic mistake, must be caught. + with pytest.raises(ValueError, match='below the mutual escape velocity'): + validate_timeline([_event(v_impact=1.15e4, v_esc=1.30e4)]) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_impact_geometry_and_eccentricity_stay_in_range(): + """The impact parameter and post-impact eccentricity are bounded. + + The impact parameter is the sine of the impact angle, so it lives in + [0, 1]; head-on and grazing are both legal endpoints. Eccentricity + must stay below unity, because a body on an unbound orbit has left + the system and cannot be the planet PROTEUS is following. + """ + for b in (0.0, 0.5, 1.0): + validate_timeline([_event(impact_parameter=b)]) + for bad_b in (-0.01, 1.01): + with pytest.raises(ValueError, match='impact parameter'): + validate_timeline([_event(impact_parameter=bad_b)]) + + validate_timeline([_event(e_after=0.0)]) + validate_timeline([_event(e_after=0.999)]) + # e = 1 is the parabolic escape boundary and is already unbound. + for bad_e in (1.0, 1.5, -0.01): + with pytest.raises(ValueError, match='eccentricity'): + validate_timeline([_event(e_after=bad_e)]) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_masses_radii_and_orbits_must_be_finite_and_positive(): + """Every extensive quantity in a record must be a positive real number. + + A zero radius makes the density and escape velocity diverge, a + negative semi-major axis is an unbound orbit, and a NaN propagates + silently into the impact energy. All three must fail at load time + rather than mid-run. + """ + for field in ('M_target_before', 'M_impactor', 'R_target_before', 'a_before'): + for bad in (0.0, -1.0, np.nan, np.inf): + with pytest.raises(ValueError, match='finite and > 0'): + validate_timeline([_event(**{field: bad})]) + + # Densities are used for the impactor-to-target ratio in the loss law. + with pytest.raises(ValueError, match='finite and > 0'): + validate_timeline([_event(rho_impactor=0.0)]) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_timeline_must_advance_in_time_and_carry_mass_forward(): + """Consecutive impacts describe one body growing, in order. + + Each impact's target is the body the previous impact produced, so the + masses must chain. Times must increase strictly, otherwise two impacts + could land in the same timestep window ambiguously. A timeline whose + masses do not chain is describing two different planets, which is the + likely outcome of selecting the wrong survivor. + """ + first = _event( + time=1.0e5, M_target_before=6.0e24, M_impactor=6.4e23, M_merged_after=6.64e24 + ) + second = _event( + time=5.0e5, M_target_before=6.64e24, M_impactor=1.0e23, M_merged_after=6.74e24 + ) + validate_timeline([first, second]) + + # Time running backwards, and two impacts at the same instant. + for bad_time in (1.0e5, 5.0e4): + with pytest.raises(ValueError, match='increase strictly'): + validate_timeline( + [ + first, + _event( + time=bad_time, + M_target_before=6.64e24, + M_impactor=1.0e23, + M_merged_after=6.74e24, + ), + ] + ) + + # Second impact starts from a mass the first one did not produce. + with pytest.raises(ValueError, match='different bodies'): + validate_timeline( + [ + first, + _event( + time=5.0e5, + M_target_before=3.0e24, + M_impactor=1.0e23, + M_merged_after=3.10e24, + ), + ] + ) + + +@pytest.mark.unit +def test_read_timeline_parses_both_delimiters_and_applies_the_offset(tmp_path): + """Timeline files are read tolerantly and shifted onto the PROTEUS clock. + + A dynamical model measures time from disk dispersal while PROTEUS + measures it from the start of its own evolution, so the offset is + applied on load rather than at every use. Rows arriving out of order + are sorted, and comment lines are ignored, so a hand-written file + behaves like a generated one. + """ + rows = [ + dict( + zip( + TIMELINE_COLUMNS, + ( + 5.0e5, + 6.64e24, + 1.0e23, + 6.74e24, + 1.2e4, + 1.1e4, + 0.3, + 6.4e6, + 2.0e6, + 5510.0, + 3930.0, + 1.4e11, + 1.35e11, + 0.02, + 1, + 7, + ), + ) + ), + dict( + zip( + TIMELINE_COLUMNS, + ( + 1.0e5, + 6.0e24, + 6.4e23, + 6.64e24, + 1.3e4, + 1.15e4, + 0.7, + 6.371e6, + 3.39e6, + 5510.0, + 3930.0, + 1.496e11, + 1.4e11, + 0.05, + 1, + 4, + ), + ) + ), + ] + + # Written newest-first and with a comment header, both of which the + # reader must cope with. + comma = _write_timeline(tmp_path / 'c.csv', rows, sep=',', header_extra='# impacts') + events = read_timeline(str(comma)) + + assert len(events) == 2 + assert events[0].time < events[1].time + assert events[0].time == pytest.approx(1.0e5) + assert events[0].id_impactor == 4 + + # Whitespace separation gives the identical parse. + space = _write_timeline(tmp_path / 's.txt', rows, sep=' ') + assert [e.time for e in read_timeline(str(space))] == [e.time for e in events] + + # The offset shifts every row by the same amount, preserving spacing. + shifted = read_timeline(str(comma), time_offset=2.0e6) + assert shifted[0].time == pytest.approx(1.0e5 + 2.0e6) + assert shifted[1].time - shifted[0].time == pytest.approx(events[1].time - events[0].time) + + +@pytest.mark.unit +def test_read_timeline_rejects_unusable_files(tmp_path): + """A malformed timeline fails at load, not part-way through a run. + + A missing column would silently disable one impact consequence, an + empty file would make an enabled accretion module a no-op, and a + missing file usually means an unexpanded path. All three are reported + with the offending detail so the config can be fixed. + """ + with pytest.raises(FileNotFoundError, match='does not exist'): + read_timeline(str(tmp_path / 'absent.csv')) + + full = dict( + zip( + TIMELINE_COLUMNS, + ( + 1.0e5, + 6.0e24, + 6.4e23, + 6.64e24, + 1.3e4, + 1.15e4, + 0.7, + 6.371e6, + 3.39e6, + 5510.0, + 3930.0, + 1.496e11, + 1.4e11, + 0.05, + 1, + 4, + ), + ) + ) + + # Empty: header present, no impacts. + empty = _write_timeline(tmp_path / 'empty.csv', []) + with pytest.raises(ValueError, match='contains no impacts'): + read_timeline(str(empty)) + + # Missing a column the impact handler needs. + trimmed = tmp_path / 'partial.csv' + keep = [c for c in TIMELINE_COLUMNS if c != 'v_esc'] + trimmed.write_text(','.join(keep) + '\n' + ','.join(repr(full[c]) for c in keep) + '\n') + with pytest.raises(ValueError, match='missing required columns'): + read_timeline(str(trimmed)) + + # Physically invalid rows are rejected on load as well as in memory. + broken = _write_timeline(tmp_path / 'broken.csv', [{**full, 'M_merged_after': 9.9e24}]) + with pytest.raises(ValueError, match='does not close'): + read_timeline(str(broken)) + + +@pytest.mark.unit +def test_scheduling_helpers_apply_each_impact_exactly_once(): + """The step window is half-open, so no impact is skipped or repeated. + + next_event drives the timestep clamp and must look strictly ahead, or + the loop would clamp to the impact it has just applied and stall. + due_events excludes the window's start and includes its end, so an + impact landing exactly on a step boundary is applied by that step and + not again by the next one. + """ + first = _event( + time=1.0e5, M_target_before=6.0e24, M_impactor=6.4e23, M_merged_after=6.64e24 + ) + second = _event( + time=5.0e5, M_target_before=6.64e24, M_impactor=1.0e23, M_merged_after=6.74e24 + ) + events = [first, second] + + assert next_event(events, 0.0) is first + # Strictly ahead: standing exactly on an impact returns the following one. + assert next_event(events, 1.0e5) is second + assert next_event(events, 5.0e5) is None + + # A step landing exactly on the impact time applies it. + assert due_events(events, 0.0, 1.0e5) == [first] + # The next step must not apply it again. + assert due_events(events, 1.0e5, 3.0e5) == [] + # A long step sweeps up everything it spans, in order. + assert due_events(events, 0.0, 1.0e6) == [first, second] + assert due_events(events, 6.0e5, 1.0e6) == [] + + +@pytest.mark.unit +@pytest.mark.physics_invariant +@pytest.mark.reference_pinned +def test_validator_accepts_the_analytic_two_body_collision(): + """A record built from the two-body relations passes validation. + + Cross-checks the validator against the analytical limit rather than + against itself: the mutual escape velocity is computed here from + v_esc = sqrt(2 G (M1 + M2) / (R1 + R2)) and the collision velocity + from v = sqrt(v_inf^2 + v_esc^2), the same closed forms the dynamical + model uses. A validator with the velocity comparison inverted, or with + the escape velocity built from one body instead of the pair, would + reject this physically legal record. + """ + M_t, M_i = 6.0e24, 6.4e23 + R_t, R_i = 6.371e6, 3.390e6 + + v_esc = np.sqrt(2.0 * const_G * (M_t + M_i) / (R_t + R_i)) + v_inf = 5.0e3 + v_impact = np.sqrt(v_inf**2 + v_esc**2) + + # Pin the analytic escape velocity itself, so a change in the + # constants or the pair convention shows up here. Hand value: + # 2 G (M_t + M_i) = 8.8635e14, over R_t + R_i = 9.761e6 m, gives + # 9.0805e7 m2/s2 and a root of 9.5292e3 m/s. + assert v_esc == pytest.approx(9.5292e3, rel=1e-4) + # The single-body escape velocity is 1.1212e4 m/s, 18% higher and far + # outside the tolerance, so the pair convention is discriminated + # rather than merely assumed. + v_esc_single = np.sqrt(2.0 * const_G * M_t / R_t) + assert v_esc_single == pytest.approx(1.1212e4, rel=1e-3) + assert abs(v_esc_single - v_esc) > 0.1 * v_esc + + event = _event( + M_target_before=M_t, + M_impactor=M_i, + M_merged_after=M_t + M_i, + R_target_before=R_t, + R_impactor=R_i, + v_esc=v_esc, + v_impact=v_impact, + ) + validate_timeline([event]) + + assert event.v_impact > event.v_esc + assert event.mass_delta == pytest.approx(M_i, rel=1e-12) diff --git a/tests/accretion/test_dummy.py b/tests/accretion/test_dummy.py new file mode 100644 index 000000000..62fe48790 --- /dev/null +++ b/tests/accretion/test_dummy.py @@ -0,0 +1,152 @@ +"""Tests for the timeline-replay accretion module. + +This file targets accretion/dummy.py (get_timeline). The dummy module +replays a timeline written earlier, which is how impact consequences are +driven from a known event sequence, so what it must guarantee is that the +configured path is honoured, that path expansion happens, and that the +configured time offset reaches the loaded events. + +See testing standards in docs/How-to/testing.md and +docs/Explanations/test_framework.md for required structure, speed, and +physics validity. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from proteus.accretion.common import TIMELINE_COLUMNS +from proteus.accretion.dummy import get_timeline + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + +_ROWS = ( + ( + 1.0e5, + 6.0e24, + 6.4e23, + 6.64e24, + 1.3e4, + 1.15e4, + 0.7, + 6.371e6, + 3.39e6, + 5510.0, + 3930.0, + 1.496e11, + 1.4e11, + 0.05, + 1, + 4, + ), + ( + 5.0e5, + 6.64e24, + 1.0e23, + 6.74e24, + 1.2e4, + 1.1e4, + 0.3, + 6.4e6, + 2.0e6, + 5510.0, + 3930.0, + 1.4e11, + 1.35e11, + 0.02, + 1, + 7, + ), +) + + +def _timeline_file(path): + """Write a two-impact timeline and return its path.""" + lines = [','.join(TIMELINE_COLUMNS)] + lines += [','.join(repr(v) for v in row) for row in _ROWS] + path.write_text('\n'.join(lines) + '\n') + return path + + +def _config(timeline_path, time_offset=0.0): + """Build the minimal config shape get_timeline reads.""" + return SimpleNamespace( + accretion=SimpleNamespace( + module='dummy', + time_offset=time_offset, + dummy=SimpleNamespace(timeline_path=str(timeline_path)), + ) + ) + + +@pytest.mark.unit +def test_get_timeline_reads_the_configured_file(tmp_path): + """The replayed impacts come from the configured path, in time order. + + This is the path the whole impact-consequence chain is tested through, + so it has to deliver every row, ordered, with the physical content + intact rather than a truncated or reordered subset. + """ + config = _config(_timeline_file(tmp_path / 'impacts.csv')) + + events = get_timeline(config) + + assert len(events) == 2 + assert [e.time for e in events] == [1.0e5, 5.0e5] + + # Content survives the round trip: the second impact is the smaller + # one, so a reader that silently reused the first row would fail here. + assert events[0].M_impactor == pytest.approx(6.4e23) + assert events[1].M_impactor == pytest.approx(1.0e23) + assert events[1].M_target_before == pytest.approx(events[0].M_merged_after) + + +@pytest.mark.unit +def test_get_timeline_applies_the_configured_offset(tmp_path): + """The accretion time offset reaches the loaded events. + + The offset maps a dynamical model's zero point onto the PROTEUS clock. + Reading it from the wrong config level, which is the plausible wiring + mistake, would silently place every impact at the wrong epoch. A + non-zero offset here shifts both impacts by exactly that amount while + leaving their spacing untouched. + """ + path = _timeline_file(tmp_path / 'impacts.csv') + + baseline = get_timeline(_config(path)) + shifted = get_timeline(_config(path, time_offset=3.0e6)) + + assert shifted[0].time == pytest.approx(1.0e5 + 3.0e6) + assert shifted[1].time == pytest.approx(5.0e5 + 3.0e6) + + # Spacing is preserved, so the offset is a shift and not a rescale. + assert (shifted[1].time - shifted[0].time) == pytest.approx( + baseline[1].time - baseline[0].time + ) + + # A negative offset is legal and moves impacts earlier, which is how a + # run starting after disk dispersal is expressed. + earlier = get_timeline(_config(path, time_offset=-5.0e4)) + assert earlier[0].time == pytest.approx(5.0e4) + + +@pytest.mark.unit +def test_get_timeline_expands_and_validates_the_path(tmp_path, monkeypatch): + """User-supplied paths are expanded, and a bad one fails immediately. + + Config paths routinely carry ``~`` or ``$FWL_DATA``; an unexpanded + path would fail with a confusing not-found error naming a literal + tilde. A genuinely missing file must still raise, so the expansion + does not mask a typo. + """ + _timeline_file(tmp_path / 'impacts.csv') + monkeypatch.setenv('TEST_TIMELINE_DIR', str(tmp_path)) + + events = get_timeline(_config('$TEST_TIMELINE_DIR/impacts.csv')) + assert len(events) == 2 + assert events[0].id_impactor == 4 + + with pytest.raises(FileNotFoundError, match='does not exist'): + get_timeline(_config(tmp_path / 'absent.csv')) diff --git a/tests/accretion/test_morrigan.py b/tests/accretion/test_morrigan.py new file mode 100644 index 000000000..7b4cad892 --- /dev/null +++ b/tests/accretion/test_morrigan.py @@ -0,0 +1,315 @@ +"""Tests for the Morrigan giant-impact module wrapper. + +This file targets accretion/morrigan.py (require_morrigan, select_planet, +build_parameters, get_timeline). The wrapper turns a PROTEUS +configuration into a dynamical-model run and reduces the resulting system +to one body's impact history, so what it must guarantee is that the unit +conversions into the model are right, that the survivor selection picks +the body the configuration asks for, and that a missing package is +reported rather than crashed through. + +See testing standards in docs/How-to/testing.md and +docs/Explanations/test_framework.md for required structure, speed, and +physics validity. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from proteus.accretion import morrigan as backend +from proteus.utils.constants import AU, M_earth + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + + +def _survivor(ident, mass_initial, a_initial, mass_final, a_final): + """Build a survivor record in the shape the dynamical model returns.""" + return { + 'id': ident, + 'mass_initial': mass_initial * M_earth, + 'a_initial': a_initial * AU, + 'mass_final': mass_final * M_earth, + 'a_final': a_final * AU, + } + + +# A system whose survivors differ in mass and orbit, so each selector has +# a distinct correct answer and no two selectors can be confused. +_SURVIVORS = [ + _survivor(0, 0.3, 0.10, 0.3, 0.10), + _survivor(1, 0.8, 0.50, 2.4, 0.62), + _survivor(2, 1.0, 1.00, 1.1, 0.95), + _survivor(3, 0.5, 2.00, 1.6, 2.20), +] + + +def _config(selector='match_config', selector_value=None, mass_tot=1.0, semimajoraxis=1.0): + """Build the minimal config shape the Morrigan wrapper reads.""" + return SimpleNamespace( + accretion=SimpleNamespace( + module='morrigan', + time_offset=0.0, + morrigan=SimpleNamespace( + selector=selector, + selector_value=selector_value, + seed=7, + num_planets=4, + masses=[], + mass_equal=0.5, + eccentricity_init=0.01, + inner_edge=0.1, + spacing=10.0, + density=5500.0, + impact_angle=45.0, + evolution_time=1.0, + inner_cutoff=0.005, + ), + ), + planet=SimpleNamespace(mass_tot=mass_tot), + orbit=SimpleNamespace(semimajoraxis=semimajoraxis), + star=SimpleNamespace(mass=1.0), + ) + + +@pytest.mark.unit +def test_missing_package_is_reported_with_an_install_hint(monkeypatch): + """An unavailable dynamical model explains itself instead of crashing. + + The package is an optional dependency, so selecting the backend + without it must produce an actionable message rather than a bare + ModuleNotFoundError from an import deep in the call stack. A package + that is present but too old to expose the entry point is the other + realistic failure and must be distinguished from absence. + """ + monkeypatch.setattr(backend, 'morrigan', None, raising=False) + with pytest.raises(ImportError, match='requires the morrigan package'): + backend.require_morrigan() + + # Installed but without the entry point: a different, specific message. + monkeypatch.setattr(backend, 'morrigan', SimpleNamespace(), raising=False) + with pytest.raises(ImportError, match='does not expose'): + backend.require_morrigan() + + # Installed and complete: returned for use. + complete = SimpleNamespace(run_system=lambda **kw: None) + monkeypatch.setattr(backend, 'morrigan', complete, raising=False) + assert backend.require_morrigan() is complete + + +@pytest.mark.unit +def test_each_selector_picks_its_own_body(): + """The four selectors resolve to four different survivors here. + + Selection decides whose impact history the whole run follows, so a + selector wired to the wrong field would silently simulate a different + planet. The system is built so the most massive body, the body nearest + a target orbit, the body matching the configuration, and an explicitly + named body are all distinct; any two selectors returning the same + body would mean one of them is not reading what it claims to. + """ + # Most massive at the end of the run: body 1 at 2.4 M_earth. + chosen = backend.select_planet(_SURVIVORS, _config(selector='mass')) + assert chosen['id'] == 1 + + # Nearest a 2.2 AU target orbit: body 3, not the most massive one. + chosen = backend.select_planet( + _SURVIVORS, _config(selector='semimajoraxis', selector_value=2.2) + ) + assert chosen['id'] == 3 + + # Explicitly named body wins regardless of mass or orbit. + chosen = backend.select_planet(_SURVIVORS, _config(selector='id', selector_value=0)) + assert chosen['id'] == 0 + + # Closest to a 1 M_earth, 1 AU configured planet at the start of the + # run: body 2, which matches both quantities exactly. + chosen = backend.select_planet( + _SURVIVORS, _config(selector='match_config', mass_tot=1.0, semimajoraxis=1.0) + ) + assert chosen['id'] == 2 + + +@pytest.mark.unit +def test_match_config_weighs_mass_and_orbit_comparably(): + """Matching compares relative offsets, so neither quantity dominates. + + Masses are around 1e24 kg and orbits around 1e11 m, so an absolute + distance in SI would be decided by mass alone and the orbit would + never matter. Holding the mass target fixed and moving only the orbit + target must still change the answer; that is what discriminates a + relative metric from an absolute one. + """ + # Same 0.5 M_earth mass target, two different orbit targets. + near = backend.select_planet( + _SURVIVORS, _config(selector='match_config', mass_tot=0.5, semimajoraxis=2.0) + ) + far = backend.select_planet( + _SURVIVORS, _config(selector='match_config', mass_tot=0.5, semimajoraxis=0.1) + ) + + assert near['id'] == 3 + assert far['id'] == 0 + assert near['id'] != far['id'] + + +@pytest.mark.unit +def test_selection_fails_loudly_on_an_impossible_request(): + """Selecting a body that is not there is an error, not an empty run. + + A run that left no survivors, or a named body that was consumed, + would otherwise produce an empty impact history that looks exactly + like a successful run with no impacts. + """ + with pytest.raises(ValueError, match='no surviving bodies'): + backend.select_planet([], _config(selector='mass')) + + with pytest.raises(ValueError, match='did not survive'): + backend.select_planet(_SURVIVORS, _config(selector='id', selector_value=99)) + + # The error names what is available, so the config can be fixed. + with pytest.raises(ValueError, match=r'\[0, 1, 2, 3\]'): + backend.select_planet(_SURVIVORS, _config(selector='id', selector_value=99)) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_parameters_are_converted_into_model_units(): + """Configuration units are converted once, on the way into the model. + + The configuration states masses in Earth masses and orbits in AU + because that is what a user reasons in, while the dynamical model + works in SI. Getting a conversion wrong would shift the whole system + by 24 orders of magnitude in mass or 11 in length, so each converted + quantity is pinned against its hand-computed SI value. + """ + config = _config() + config.accretion.morrigan.masses = [0.5, 1.5, 2.0] + + params = backend.build_parameters(config) + + assert params['masses'] == pytest.approx([0.5 * M_earth, 1.5 * M_earth, 2.0 * M_earth]) + assert params['inner_edge'] == pytest.approx(0.1 * AU) + assert params['inner_cutoff'] == pytest.approx(0.005 * AU) + + # Masses must be far above the Earth-mass number they came from, which + # is what an omitted conversion would leave behind. + assert min(params['masses']) > 1.0e23 + + # Dimensionless and already-SI quantities pass through untouched. + assert params['spacing'] == pytest.approx(10.0) + assert params['density'] == pytest.approx(5500.0) + assert params['seed'] == 7 + + # The host star comes from the star section, not the accretion one, so + # the dynamical model and the rest of the run cannot disagree. + config.star.mass = 0.4 + assert backend.build_parameters(config)['stellar_mass'] == pytest.approx(0.4) + + +@pytest.mark.unit +def test_equal_mass_system_expands_to_one_entry_per_embryo(): + """An empty mass list is the documented equal-mass initial condition. + + The alternative reading, passing an empty list straight through, would + start a system with no bodies at all. The expansion must produce + exactly num_planets entries, all at the configured value. + """ + config = _config() + config.accretion.morrigan.masses = [] + config.accretion.morrigan.num_planets = 6 + config.accretion.morrigan.mass_equal = 0.75 + + params = backend.build_parameters(config) + + assert len(params['masses']) == 6 + assert params['masses'] == pytest.approx([0.75 * M_earth] * 6) + + # An explicit list is used verbatim and is not overwritten by + # mass_equal, which is the opposite failure. + config.accretion.morrigan.masses = [0.2, 0.4] + config.accretion.morrigan.num_planets = 2 + assert backend.build_parameters(config)['masses'] == pytest.approx( + [0.2 * M_earth, 0.4 * M_earth] + ) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_generated_timeline_is_selected_ordered_and_validated(monkeypatch): + """A model run is reduced to one body's validated, ordered history. + + Three things must hold together: only the selected body's impacts are + kept, they are sorted in time even if the model reports them + otherwise, and the same physical validation applied to a file-loaded + timeline is applied here. Skipping validation on the generated path + would let an inconsistent model result reach the main loop through a + side door. + """ + impacts = { + 1: [ + { + 'time': 5.0e5, + 'M_target_before': 6.64e24, + 'M_impactor': 1.0e23, + 'M_merged_after': 6.74e24, + 'v_impact': 1.2e4, + 'v_esc': 1.1e4, + 'impact_parameter': 0.3, + 'R_target_before': 6.4e6, + 'R_impactor': 2.0e6, + 'rho_target': 5510.0, + 'rho_impactor': 3930.0, + 'a_before': 1.4e11, + 'a_after': 1.35e11, + 'e_after': 0.02, + 'id_target': 1, + 'id_impactor': 7, + }, + { + 'time': 1.0e5, + 'M_target_before': 6.0e24, + 'M_impactor': 6.4e23, + 'M_merged_after': 6.64e24, + 'v_impact': 1.3e4, + 'v_esc': 1.15e4, + 'impact_parameter': 0.7, + 'R_target_before': 6.371e6, + 'R_impactor': 3.39e6, + 'rho_target': 5510.0, + 'rho_impactor': 3930.0, + 'a_before': 1.496e11, + 'a_after': 1.4e11, + 'e_after': 0.05, + 'id_target': 1, + 'id_impactor': 4, + }, + ], + 2: [], + } + fake = SimpleNamespace( + run_system=lambda **kw: {'survivors': _SURVIVORS, 'impacts': impacts} + ) + monkeypatch.setattr(backend, 'morrigan', fake, raising=False) + + config = _config(selector='mass') # resolves to body 1 + events = backend.get_timeline(config) + + # Reported out of order, returned in order. + assert [e.time for e in events] == [1.0e5, 5.0e5] + assert events[0].id_impactor == 4 + + # The chain is continuous, which is what validation enforces. + assert events[1].M_target_before == pytest.approx(events[0].M_merged_after) + + # The offset is applied on this path too. + config.accretion.time_offset = 1.0e6 + assert backend.get_timeline(config)[0].time == pytest.approx(1.0e5 + 1.0e6) + + # A physically inconsistent model result is rejected, not passed on. + impacts[1][1]['M_merged_after'] = 9.9e24 + config.accretion.time_offset = 0.0 + with pytest.raises(ValueError, match='does not close'): + backend.get_timeline(config) diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py new file mode 100644 index 000000000..fc4bacec4 --- /dev/null +++ b/tests/accretion/test_wrapper.py @@ -0,0 +1,166 @@ +"""Tests for the accretion wrapper and its initialisation contract. + +This file targets accretion/wrapper.py (init_accretion). The wrapper is +what the main loop calls once at start-up, so what it must guarantee is +that a run with accretion disabled is untouched, that the configured +backend is the one consulted, and that impacts falling outside the +simulated interval are reported rather than dropped in silence. + +See testing standards in docs/How-to/testing.md and +docs/Explanations/test_framework.md for required structure, speed, and +physics validity. +""" + +from __future__ import annotations + +import logging +from types import SimpleNamespace + +import pytest + +from proteus.accretion.common import TIMELINE_COLUMNS +from proteus.accretion.wrapper import init_accretion + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + +_ROWS = ( + ( + 1.0e5, + 6.0e24, + 6.4e23, + 6.64e24, + 1.3e4, + 1.15e4, + 0.7, + 6.371e6, + 3.39e6, + 5510.0, + 3930.0, + 1.496e11, + 1.4e11, + 0.05, + 1, + 4, + ), + ( + 5.0e5, + 6.64e24, + 1.0e23, + 6.74e24, + 1.2e4, + 1.1e4, + 0.3, + 6.4e6, + 2.0e6, + 5510.0, + 3930.0, + 1.4e11, + 1.35e11, + 0.02, + 1, + 7, + ), +) + + +def _timeline_file(path): + """Write a two-impact timeline at 1e5 and 5e5 yr.""" + lines = [','.join(TIMELINE_COLUMNS)] + lines += [','.join(repr(v) for v in row) for row in _ROWS] + path.write_text('\n'.join(lines) + '\n') + return path + + +def _handler(module=None, timeline_path=None, time_offset=0.0, time_start=0.0): + """Build the minimal Proteus handler shape init_accretion reads.""" + return SimpleNamespace( + config=SimpleNamespace( + accretion=SimpleNamespace( + module=module, + time_offset=time_offset, + dummy=SimpleNamespace( + timeline_path=None if timeline_path is None else str(timeline_path) + ), + ) + ), + hf_row={'Time': time_start}, + ) + + +@pytest.mark.unit +def test_disabled_accretion_returns_no_impacts(tmp_path): + """A run without accretion gets an empty schedule and reads no files. + + Every existing configuration has accretion off, so this path must stay + a pure no-op: an empty list, and no attempt to touch a timeline. The + file check matters because a stray read would make the disabled path + fail on configs that never mention a timeline at all. + """ + handler = _handler(module=None, timeline_path=tmp_path / 'never_written.csv') + + events = init_accretion(handler) + + assert events == [] + assert not (tmp_path / 'never_written.csv').exists() + + # The handler is not mutated on the disabled path. + assert handler.hf_row == {'Time': 0.0} + + +@pytest.mark.unit +def test_enabled_backend_returns_the_scheduled_impacts(tmp_path): + """The configured backend supplies the schedule the main loop consults. + + The returned list is what the timestep clamp and the impact handler + read on every step, so it has to arrive complete and in time order, + with the physical content of each record preserved. + """ + handler = _handler(module='dummy', timeline_path=_timeline_file(tmp_path / 't.csv')) + + events = init_accretion(handler) + + assert len(events) == 2 + assert [e.time for e in events] == [1.0e5, 5.0e5] + assert events[0].mass_delta == pytest.approx(6.4e23) + + # Chain continuity survives the wrapper, so the schedule describes one + # growing body rather than a set of unrelated impacts. + assert events[1].M_target_before == pytest.approx(events[0].M_merged_after) + + +@pytest.mark.unit +def test_impacts_before_the_run_starts_are_reported_and_excluded(tmp_path, caplog): + """Impacts outside the simulated interval are announced, not swallowed. + + The configuration owns the planet's initial mass and orbit, so an + impact landing before the run begins cannot be applied without + contradicting it. Dropping it silently would understate the planet's + accretion history with no trace in the log, so the count and the + missing mass are reported and the offset is named as the fix. + """ + path = _timeline_file(tmp_path / 't.csv') + + # Start the run after the first impact but before the second. + handler = _handler(module='dummy', timeline_path=path, time_start=2.0e5) + + with caplog.at_level(logging.WARNING, logger='fwl.proteus.accretion.wrapper'): + events = init_accretion(handler) + + assert [e.time for e in events] == [5.0e5] + + warning = '\n'.join(r.getMessage() for r in caplog.records) + assert '1 impact' in warning + assert 'time_offset' in warning + # The mass that will not be accreted is quantified, so the size of the + # omission is visible rather than merely its existence. + assert '0.107' in warning # 6.4e23 kg expressed in Earth masses + + # An impact landing exactly on the start time is already accounted for + # by the initial condition and is excluded too. + boundary = _handler(module='dummy', timeline_path=path, time_start=1.0e5) + assert [e.time for e in init_accretion(boundary)] == [5.0e5] + + # Shifting the timeline forward brings both impacts back into range, + # which is the documented remedy. + shifted = _handler(module='dummy', timeline_path=path, time_offset=3.0e5, time_start=2.0e5) + assert len(init_accretion(shifted)) == 2 diff --git a/tests/config/test_config_schema_invariants.py b/tests/config/test_config_schema_invariants.py index 2a1ee0b79..883ba6f4e 100644 --- a/tests/config/test_config_schema_invariants.py +++ b/tests/config/test_config_schema_invariants.py @@ -212,6 +212,7 @@ def _make_config_instance(**overrides): that is the cross-product test's job. """ base = SimpleNamespace( + accretion=SimpleNamespace(module=None), outgas=SimpleNamespace(module='calliope', fO2_shift_IW=0.0), escape=SimpleNamespace(module='zephyrus'), atmos_chem=SimpleNamespace(module=None), diff --git a/tests/tools/test_migrate_config_v2_to_v3.py b/tests/tools/test_migrate_config_v2_to_v3.py index 24af8e65e..4436e792e 100644 --- a/tests/tools/test_migrate_config_v2_to_v3.py +++ b/tests/tools/test_migrate_config_v2_to_v3.py @@ -101,7 +101,7 @@ def _v3(): 'accretion.morrigan.selector', 'accretion.morrigan.selector_value', 'accretion.morrigan.spacing', - 'accretion.morrigan.time_offset', + 'accretion.time_offset', 'atmos_clim.aerosols_enabled', 'atmos_clim.agni.grey_opacity_lw', 'atmos_clim.agni.grey_opacity_sw', From fb7eab1aef0cd6853628b51d6cf9f2d1be47f440 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 22 Jul 2026 15:18:17 +0200 Subject: [PATCH 03/71] Ignore a local Morrigan checkout Morrigan is developed in its own repository and cloned into the PROTEUS tree alongside the other modules, so a local checkout should not show up as untracked here. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 4410a93cf..5ebbb54a7 100644 --- a/.gitignore +++ b/.gitignore @@ -85,6 +85,9 @@ Love.jl/ BOREAS boreas Boreas +morrigan +Morrigan +MORRIGAN # misc ###### From 7e766c388b86815e018d1f6f5796d14d6c5516a3 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 23 Jul 2026 10:47:29 +0200 Subject: [PATCH 04/71] Allow a body to shed atmosphere between impacts The impact timeline reports the perfect-merger mass, the plain sum of target and impactor, so that the atmosphere a collision strips is accounted for once, by the escape module, rather than twice. The dynamical model meanwhile hands the next collision a body that has already lost that atmosphere, so the target mass of one impact legitimately sits below the merged mass of the one before it. Requiring the two to match exactly would reject every real chain in which any atmosphere is lost. The chain check is now one-way. A body may lose up to ten percent of its mass between impacts, which is generous against the percent-level envelopes these embryos carry, and may not gain any: nothing feeds it in between, so a gain is a bookkeeping error. A larger drop still fails, since that means rows from two different planets have been spliced together. The ceiling is a keyword argument for models whose bodies carry heavier envelopes. --- src/proteus/accretion/common.py | 56 ++++++++++++++++++++++++------ tests/accretion/test_common.py | 61 +++++++++++++++++++++++++++++++-- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/src/proteus/accretion/common.py b/src/proteus/accretion/common.py index c4f91ba32..1cf216b3f 100644 --- a/src/proteus/accretion/common.py +++ b/src/proteus/accretion/common.py @@ -44,6 +44,15 @@ # sqrt(v_inf^2 + v_esc^2). The tolerance absorbs round-trip formatting only. VELOCITY_FLOOR_RTOL = 1e-6 +# Largest fraction of its mass a body may shed between two consecutive impacts. +# The timeline reports the perfect-merger mass M_target + M_impactor, while the +# dynamical model may hand the next impact a lighter body because the collision +# stripped atmosphere. Only the atmosphere is available to lose, so the drop is +# bounded by the envelope mass fraction, of order a percent for the embryos +# these models follow. Ten percent leaves room for envelope-rich bodies while +# still rejecting the discontinuity that means the rows describe two planets. +MAX_INTERIMPACT_MASS_LOSS_FRAC = 0.1 + @define(frozen=True) class ImpactEvent: @@ -189,23 +198,38 @@ def _check_event_physics(event: ImpactEvent, index: int) -> None: ) -def validate_timeline(events: Sequence[ImpactEvent]) -> None: +def validate_timeline( + events: Sequence[ImpactEvent], + max_mass_loss_frac: float = MAX_INTERIMPACT_MASS_LOSS_FRAC, +) -> None: """Check a whole timeline for self-consistency. Every record must be physically valid on its own, times must increase strictly so each impact can be scheduled unambiguously, and the mass - handed from one impact to the next must be continuous. + handed from one impact to the next must follow from the body the + previous impact produced. + + That last check is one-way. The timeline reports the perfect-merger + mass, so between two impacts a body may shed the atmosphere the + collision stripped, but it has nothing to accrete from: the next + target mass may sit below the previous merged mass by up to + ``max_mass_loss_frac``, and may not sit above it at all. Parameters ---------- events : sequence of ImpactEvent Timeline to check, in time order. + max_mass_loss_frac : float + Largest fraction of its mass a body may shed between consecutive + impacts [1]. Raise it for a model whose bodies carry envelopes + heavier than the default ceiling. Raises ------ ValueError If any record is invalid, if two impacts share a time or run - backwards, or if the target mass jumps between consecutive impacts. + backwards, or if the target mass gains on, or falls too far + below, the previous merged mass. """ previous: ImpactEvent | None = None @@ -221,15 +245,25 @@ def validate_timeline(events: Sequence[ImpactEvent]) -> None: ) # The body that emerges from one impact is the target of the - # next, so a mass discontinuity means the rows describe - # different planets. - if abs(event.M_target_before - previous.M_merged_after) > ( - MASS_CLOSURE_RTOL * previous.M_merged_after - ): + # next. Nothing feeds it in between, so any gain is a + # bookkeeping error rather than physics. + merged = previous.M_merged_after + drift = event.M_target_before - merged + if drift > MASS_CLOSURE_RTOL * merged: + raise ValueError( + f'impact {index}: target mass {event.M_target_before:.6e} kg exceeds ' + f'the previous merged mass {merged:.6e} kg; a body cannot gain mass ' + 'between impacts' + ) + + # A drop larger than any atmosphere the body could carry means + # the rows describe different planets. + if -drift > max_mass_loss_frac * merged: raise ValueError( - f'impact {index}: target mass {event.M_target_before:.6e} kg does ' - f'not continue from the previous merged mass ' - f'{previous.M_merged_after:.6e} kg; the rows describe different bodies' + f'impact {index}: target mass {event.M_target_before:.6e} kg falls ' + f'{-drift / merged:.1%} below the previous merged mass ' + f'{merged:.6e} kg, more than the {max_mass_loss_frac:.1%} a stripped ' + 'atmosphere can account for; the rows describe different bodies' ) previous = event diff --git a/tests/accretion/test_common.py b/tests/accretion/test_common.py index 2e77003ac..8965f1b55 100644 --- a/tests/accretion/test_common.py +++ b/tests/accretion/test_common.py @@ -5,8 +5,8 @@ between a dynamical model and every consequence PROTEUS applies at an impact, so the invariants exercised here are mass closure of a perfect merger, the escape-velocity floor on the collision velocity, boundedness -of the impact geometry, and continuity of the target mass along the -chain. +of the impact geometry, and the one-way handover of mass from one impact +to the next. See testing standards in docs/How-to/testing.md and docs/Explanations/test_framework.md for required structure, speed, and @@ -19,6 +19,8 @@ import pytest from proteus.accretion.common import ( + MASS_CLOSURE_RTOL, + MAX_INTERIMPACT_MASS_LOSS_FRAC, TIMELINE_COLUMNS, ImpactEvent, due_events, @@ -233,6 +235,61 @@ def test_timeline_must_advance_in_time_and_carry_mass_forward(): ) +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_mass_may_only_drop_between_impacts_and_only_by_an_atmosphere(): + """Between two impacts a body can lose atmosphere but cannot accrete. + + The timeline reports the perfect-merger mass, whereas the dynamical + model hands the next collision a body that has already shed whatever + atmosphere the impact stripped. That gap is legitimate, so the chain + check is one-way: a drop within the envelope-mass ceiling passes, a + gain of any size fails, and a drop too large to be atmosphere fails + because it means a different planet's rows were spliced in. + """ + first = _event( + time=1.0e5, M_target_before=6.0e24, M_impactor=6.4e23, M_merged_after=6.64e24 + ) + + def _second(M_target_before, **kwargs): + """Second impact starting from a stated target mass.""" + return _event( + time=5.0e5, + M_target_before=M_target_before, + M_impactor=1.0e23, + M_merged_after=M_target_before + 1.0e23, + **kwargs, + ) + + # A one percent atmosphere, the Morrigan default, stripped entirely. + validate_timeline([first, _second(6.64e24 * 0.99)]) + + # Discrimination: that same one percent is four orders of magnitude + # above the closure tolerance, so the previous line would fail under a + # strict-equality chain check. + assert 0.01 > 1.0e3 * MASS_CLOSURE_RTOL + + # Just inside and just outside the ceiling, from both sides. + validate_timeline( + [first, _second(6.64e24 * (1.0 - 0.999 * MAX_INTERIMPACT_MASS_LOSS_FRAC))] + ) + with pytest.raises(ValueError, match='different bodies'): + validate_timeline( + [first, _second(6.64e24 * (1.0 - 1.001 * MAX_INTERIMPACT_MASS_LOSS_FRAC))] + ) + + # Raising the ceiling admits an envelope-rich body. + validate_timeline([first, _second(6.64e24 * 0.80)], max_mass_loss_frac=0.25) + + # Nothing feeds the body between impacts, so even a per-mille gain is + # a bookkeeping error, well short of the loss the same size is given. + with pytest.raises(ValueError, match='cannot gain mass'): + validate_timeline([first, _second(6.64e24 * 1.001)]) + + # Round-off on the handover is still accepted from the upper side. + validate_timeline([first, _second(6.64e24 * (1.0 + 1.0e-9))]) + + @pytest.mark.unit def test_read_timeline_parses_both_delimiters_and_applies_the_offset(tmp_path): """Timeline files are read tolerantly and shifted onto the PROTEUS clock. From 2b1879e7e03e05d2a06f4abe36fe3f5637e6f2e1 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 23 Jul 2026 10:52:00 +0200 Subject: [PATCH 05/71] Land the timestep exactly on each scheduled impact A giant impact grows the planet and re-melts its mantle, so it has to be applied at the state the impact timeline places it at. Left to itself the adaptive controller picks a step from stiffness and flux history alone, and will happily jump over an impact and apply it to whatever state it lands on instead. The main loop now hands the time-stepper the time of the next scheduled impact, and the stepper shortens the step to end on it. The clamp only ever shortens: a distant impact leaves the controller in charge. It is floored at the minimum step, so an impact a few years away cannot drive the step towards zero and stall the run; the event window is half-open, so an impact inside a floored step is still applied exactly once. With no accretion module selected there are no scheduled impacts and the step is untouched, which is every run today. --- src/proteus/interior_energetics/common.py | 8 ++ src/proteus/interior_energetics/timestep.py | 20 ++++ src/proteus/proteus.py | 6 + tests/interior_energetics/test_timestep.py | 123 +++++++++++++++++++- 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index 5c6abd3c2..49a34638a 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -593,6 +593,14 @@ def __init__(self, nlev_b: int, spider_dir=None, eos_dir=None): # escaped from. self.dt_hysteresis_remaining = 0 + # Time of the next scheduled giant impact [yr], refreshed by the + # main loop from the accretion timeline. The time-stepper clamps + # dt so the loop lands on it, because an impact resets the mantle + # and changes the planet's mass: stepping over one would apply it + # at the wrong state. Infinite when no impact is pending, which + # is every run with accretion switched off. + self.t_next_impact = float('inf') + # Lookup data for SPIDER (P-S tables, used by E_th and # melt-volume bookkeeping). Each is a (nS, nP, 3) array, the # third channel being the SI value of the quantity. diff --git a/src/proteus/interior_energetics/timestep.py b/src/proteus/interior_energetics/timestep.py index 4acd102f3..26ff5695f 100644 --- a/src/proteus/interior_energetics/timestep.py +++ b/src/proteus/interior_energetics/timestep.py @@ -358,5 +358,25 @@ def next_step( ) dtswitch = dt_capped + # Land exactly on the next scheduled giant impact. An impact re-melts + # the mantle and grows the planet, so it has to be applied at the + # state the timeline says it happens at, not at whatever state a step + # that jumped over it produced. The clamp only ever shortens dt, and + # it is floored at the minimum step so a nearby impact cannot collapse + # dt to zero; the event handler uses a half-open time window, so an + # impact inside a floored step is still applied exactly once. + if interior_o is not None and np.isfinite(interior_o.t_next_impact): + dt_to_impact = interior_o.t_next_impact - hf_row['Time'] + dtfloor = config.params.dt.minimum + config.params.dt.minimum_rel * hf_row['Time'] + dt_to_impact = max(dt_to_impact, dtfloor) + if dtswitch > dt_to_impact: + log.info( + 'Time-stepping: impact at %.4e yr, capping dt at %.2e yr (was %.2e yr)', + interior_o.t_next_impact, + dt_to_impact, + dtswitch, + ) + dtswitch = dt_to_impact + log.info('New time-step target is %.2e years' % dtswitch) return dtswitch diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index 6393017e9..537163b9c 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -273,6 +273,7 @@ def start(self, *, resume: bool = False, offline: bool = False): # Import things needed to run PROTEUS # atmospheric chemistry # giant-impact accretion + from proteus.accretion.common import next_event from proteus.accretion.wrapper import init_accretion from proteus.atmos_chem.wrapper import run_chemistry @@ -775,6 +776,11 @@ def start(self, *, resume: bool = False, offline: bool = False): ############### INTERIOR PrintHalfSeparator() + # Tell the time-stepper when the next giant impact is due, so + # it can shorten the step to land on it. + pending = next_event(self.impact_events, self.hf_row['Time']) + self.interior_o.t_next_impact = float('inf') if pending is None else pending.time + # Evolve interior _t0 = time.perf_counter() if _IT_TIMING_ENABLED else 0.0 run_interior( diff --git a/tests/interior_energetics/test_timestep.py b/tests/interior_energetics/test_timestep.py index 73436b41b..dad2954d3 100644 --- a/tests/interior_energetics/test_timestep.py +++ b/tests/interior_energetics/test_timestep.py @@ -94,10 +94,11 @@ def _make_hf_all(n_rows: int = 10, dt_prev: float = 1.0e3, phi: float = 1.0): ) -def _make_interior_o(): +def _make_interior_o(t_next_impact=float('inf')): """Minimal stand-in for Interior_t that exposes the fields the - controller reads/writes.""" - return SimpleNamespace(dt_hysteresis_remaining=0) + controller reads/writes. The default impact time is infinite, which + is what a run with accretion switched off carries.""" + return SimpleNamespace(dt_hysteresis_remaining=0, t_next_impact=t_next_impact) # --------------------------------------------------------------------------- @@ -628,3 +629,119 @@ def test_next_step_maximum_rel_default_widens_cap_proportional_to_Time(): # maximum_rel would land at 10 here; the gap of 40 is well above # any reasonable rounding error. assert dt > 10.0 + + +# --------------------------------------------------------------------------- +# Landing on scheduled giant impacts +# --------------------------------------------------------------------------- + + +class TestImpactClamp: + """Verify dt is shortened to land on the next scheduled impact. + + An impact grows the planet and re-melts its mantle, so it has to be + applied at the state the timeline places it at. The clamp is one-way: + it may only shorten the step, and it is floored at the minimum step so + that an imminent impact cannot drive dt to zero. + """ + + @pytest.mark.physics_invariant + def test_no_pending_impact_leaves_the_step_untouched(self): + """A run with accretion off carries an infinite impact time. + + This is the path every existing run takes, so it must return the + controller's own choice unchanged. + """ + from proteus.interior_energetics.timestep import next_step + + config = _make_config() + hf_all = _make_hf_all(n_rows=12, dt_prev=5.0e3, phi=1.0) + hf_row = {'Time': 1.0e5, 'F_atm': 1.0e4, 'Phi_global': 1.0} + + dt = next_step(config, {}, hf_row, hf_all, 1.0, interior_o=_make_interior_o()) + + # SFINC * dt_prev = 1.6 * 5e3 = 8e3, the uncapped controller step. + assert dt == pytest.approx(8.0e3, rel=1e-6), f'Expected 8e3, got {dt}' + assert dt > 0.0 + + @pytest.mark.physics_invariant + def test_step_lands_exactly_on_an_impact_inside_the_step(self): + """An impact closer than the controller's step pulls dt back to it.""" + from proteus.interior_energetics.timestep import next_step + + config = _make_config() + hf_all = _make_hf_all(n_rows=12, dt_prev=5.0e3, phi=1.0) + time_now = 1.0e5 + hf_row = {'Time': time_now, 'F_atm': 1.0e4, 'Phi_global': 1.0} + t_impact = time_now + 3.0e3 + + dt = next_step( + config, + {}, + hf_row, + hf_all, + 1.0, + interior_o=_make_interior_o(t_next_impact=t_impact), + ) + + # The step ends on the impact, to the precision of the time axis. + assert hf_row['Time'] + dt == pytest.approx(t_impact, rel=1e-12) + # Discrimination: the uncapped controller would have chosen 8e3 + # and stepped 5e3 past the impact, so an inactive clamp cannot + # pass the equality above. + assert dt < 8.0e3 + + def test_a_distant_impact_does_not_lengthen_the_step(self): + """The clamp is one-way; it must never grow dt. + + A timeline whose next impact is far away has to leave the + stiffness-aware controller in charge. + """ + from proteus.interior_energetics.timestep import next_step + + config = _make_config() + hf_all = _make_hf_all(n_rows=12, dt_prev=5.0e3, phi=1.0) + hf_row = {'Time': 1.0e5, 'F_atm': 1.0e4, 'Phi_global': 1.0} + + dt = next_step( + config, + {}, + hf_row, + hf_all, + 1.0, + interior_o=_make_interior_o(t_next_impact=1.0e5 + 2.0e4), + ) + + assert dt == pytest.approx(8.0e3, rel=1e-6), f'Expected 8e3, got {dt}' + + @pytest.mark.physics_invariant + def test_an_imminent_impact_is_floored_at_the_minimum_step(self): + """An impact inside the minimum step must not collapse dt. + + The event handler applies impacts on a half-open time window, so + overshooting an impact by less than one minimum step still fires + it exactly once. Shrinking dt towards zero to reach it, on the + other hand, would stall the run. + """ + from proteus.interior_energetics.timestep import next_step + + config = _make_config() + hf_all = _make_hf_all(n_rows=12, dt_prev=5.0e3, phi=1.0) + time_now = 1.0e5 + hf_row = {'Time': time_now, 'F_atm': 1.0e4, 'Phi_global': 1.0} + t_impact = time_now + 10.0 + + dt = next_step( + config, + {}, + hf_row, + hf_all, + 1.0, + interior_o=_make_interior_o(t_next_impact=t_impact), + ) + + # dt.minimum + dt.minimum_rel * Time = 100 + 0.005 * 1e5 = 600. + assert dt == pytest.approx(600.0, rel=1e-6), f'Expected the 600 yr floor, got {dt}' + # Positivity, and the deliberate overshoot that the floor implies. + assert dt > 0.0 + assert hf_row['Time'] + dt > t_impact From 1a0b7ba8fe1018e913e42b3921c60294c01b0923 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 23 Jul 2026 22:21:41 +0200 Subject: [PATCH 06/71] Apply a giant impact's mass and orbit change during the run The accretion timeline scheduled the impacts and the time-stepper landed each step on one, but nothing happened when a step reached an impact. The main loop now applies each impact as the step that contains it advances the time, once per impact, since the step window is half-open. An impact grows the planet by the impactor mass and re-solves the interior structure, so the radius, gravity and the core and mantle masses follow the new total at the configured core fraction rather than staying at the old mass. It moves the orbit by the impact's proportional change in semi-major axis and its post-impact eccentricity, writing both the configuration, which sets the orbit when tides are off, and the running row, which the tidal evolution carries forward when tides are on, so the change holds under either. Applied after the time advance, this step's orbit and structure already use the grown planet and the next interior solve evolves it. A run with no accretion module is untouched. The mantle re-melt and the optional volatile delivery are the remaining consequences and follow separately. --- src/proteus/accretion/wrapper.py | 57 +++++++++++++ src/proteus/proteus.py | 15 ++++ tests/accretion/test_wrapper.py | 142 +++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index 0f7deb00a..6c5418906 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -54,6 +54,63 @@ def init_accretion(handler: Proteus) -> list[ImpactEvent]: return _drop_events_before_start(events, handler.hf_row.get('Time', 0.0)) +def apply_impact(handler: Proteus, event: ImpactEvent) -> None: + """Apply one giant impact's consequences to the running planet. + + Called once for each impact, at the end of the timestep that lands on + its time, so the orbit and structure of that step already use the grown + planet and the next interior solve evolves it from there. + + The impactor mass is added to the planet's total mass and the interior + structure is re-solved, so the radius, gravity and the core/mantle split + follow the new mass at the configured core fraction. The orbit change is + applied as a discrete jump to both the configuration, which pins the + orbit when tides are off, and the running row, which the tidal evolution + carries forward when tides are on, so the jump persists under either. + + Parameters + ---------- + handler : Proteus + Proteus object instance, mutated in place. + event : ImpactEvent + The impact to apply. + """ + from proteus.interior_energetics.wrapper import solve_structure + + config = handler.config + hf_row = handler.hf_row + + log.info( + 'Giant impact at t = %.4e yr: target %d struck by %d, adding %.4f M_earth', + event.time, + event.id_target, + event.id_impactor, + event.mass_delta / M_earth, + ) + + # Grow the planet by the impactor mass and re-solve the structure. mass_tot + # is in Earth masses; the event's mass delta is the impactor mass in kg. + config.planet.mass_tot += event.mass_delta / M_earth + solve_structure( + handler.directories, config, handler.hf_all, hf_row, handler.directories['output'] + ) + + # Move the orbit by the impact's proportional change in semi-major axis and + # its post-impact eccentricity, writing both the configuration and the row. + ratio = event.semimajoraxis_ratio + config.orbit.semimajoraxis *= ratio + config.orbit.eccentricity = event.e_after + hf_row['semimajorax'] *= ratio + hf_row['eccentricity'] = event.e_after + + log.info( + ' planet is now %.4f M_earth at %.5f AU, e = %.4f', + config.planet.mass_tot, + config.orbit.semimajoraxis, + config.orbit.eccentricity, + ) + + def _drop_events_before_start( events: list[ImpactEvent], time_start: float ) -> list[ImpactEvent]: diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index 537163b9c..172ce3d40 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -841,6 +841,21 @@ def start(self, *, resume: bool = False, offline: bool = False): self.hf_row['Time'] += self.interior_o.dt # in years self.hf_row['age_star'] += self.interior_o.dt # in years + # Apply any giant impacts falling in this step. The time-stepper + # lands the step on the next impact time, and the window is + # half-open, so each impact fires exactly once no matter how the + # step straddles it. Applied after the time advance, so this step's + # orbit and structure use the grown planet and the next interior + # solve evolves it. Empty when no accretion module is selected. + if self.impact_events: + from proteus.accretion.common import due_events + from proteus.accretion.wrapper import apply_impact + + time_now = self.hf_row['Time'] + time_previous = time_now - self.interior_o.dt + for event in due_events(self.impact_events, time_previous, time_now): + apply_impact(self, event) + # One-time structure baseline in the interior-fed callable # representation (dynamic and static runs share an identical start). # Static runs perform no further structure solves; dynamic runs diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index fc4bacec4..fd83fb4d8 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -164,3 +164,145 @@ def test_impacts_before_the_run_starts_are_reported_and_excluded(tmp_path, caplo # which is the documented remedy. shifted = _handler(module='dummy', timeline_path=path, time_offset=3.0e5, time_start=2.0e5) assert len(init_accretion(shifted)) == 2 + + +def _impact_event(**overrides): + """Build one physically self-consistent impact record for the handler.""" + from proteus.accretion.common import ImpactEvent + + base = dict( + time=1.0e5, + M_target_before=6.0e24, + M_impactor=6.4e23, + M_merged_after=6.64e24, + v_impact=1.30e4, + v_esc=1.15e4, + impact_parameter=0.7, + R_target_before=6.371e6, + R_impactor=3.39e6, + rho_target=5510.0, + rho_impactor=3930.0, + a_before=1.496e11, + a_after=1.4e11, + e_after=0.05, + id_target=1, + id_impactor=4, + ) + base.update(overrides) + return ImpactEvent(**base) + + +def _impact_handler(mass_tot=1.0, semimajoraxis=0.5, eccentricity=0.1): + """Build the minimal handler shape apply_impact reads and mutates.""" + from proteus.utils.constants import AU + + return SimpleNamespace( + config=SimpleNamespace( + planet=SimpleNamespace(mass_tot=mass_tot), + orbit=SimpleNamespace(semimajoraxis=semimajoraxis, eccentricity=eccentricity), + ), + hf_row={'semimajorax': semimajoraxis * AU, 'eccentricity': eccentricity}, + hf_all=None, + directories={'output': '/tmp/unused'}, + ) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_impact_grows_the_planet_by_the_impactor_mass_and_re_solves(monkeypatch): + """An impact adds the impactor mass and rebuilds the interior structure. + + The mass the planet gains is the impactor mass, the difference between + the merged and target masses, not the merged mass itself, which is an + order of magnitude larger here and is the plausible wrong reading. The + structure is re-solved once against the new total mass so the radius and + the core/mantle split follow it rather than staying frozen at the old + mass. + """ + from proteus.accretion.wrapper import apply_impact + from proteus.utils.constants import M_earth + + calls = [] + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', + lambda *a, **k: calls.append(a), + ) + + handler = _impact_handler(mass_tot=1.0) + # Impactor is 0.5 Earth masses; merged mass is 6.5 (ten times larger). + event = _impact_event( + M_target_before=6.0 * M_earth, + M_impactor=0.5 * M_earth, + M_merged_after=6.5 * M_earth, + ) + apply_impact(handler, event) + + assert handler.config.planet.mass_tot == pytest.approx(1.5, rel=1e-12) + # Discrimination: adding the merged mass instead would land near 7.5, + # five Earth masses away, far outside any tolerance. + assert abs(handler.config.planet.mass_tot - (1.0 + 6.5)) > 1.0 + + # The structure was re-solved exactly once, against the grown planet. + assert len(calls) == 1 + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_impact_moves_the_orbit_in_both_the_config_and_the_row(monkeypatch): + """The orbit change is applied as a jump to both the config and the row. + + The semi-major axis moves by the impact's proportional change and the + eccentricity takes its post-impact value. Both the configuration, which + pins the orbit when tides are off, and the running row, which the tidal + evolution carries forward when tides are on, must be written, or the + jump would be lost under one of the two orbit modes. + """ + from proteus.accretion.wrapper import apply_impact + from proteus.utils.constants import AU + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler(semimajoraxis=0.5, eccentricity=0.1) + # a_after / a_before = 1.4e11 / 1.4e11 scaled: choose a clean 1.2 ratio. + event = _impact_event(a_before=1.0e11, a_after=1.2e11, e_after=0.03) + ratio = 1.2 + + apply_impact(handler, event) + + assert handler.config.orbit.semimajoraxis == pytest.approx(0.5 * ratio, rel=1e-12) + assert handler.hf_row['semimajorax'] == pytest.approx(0.5 * AU * ratio, rel=1e-12) + # Config (AU) and row (metres) describe the same orbit after the jump. + assert handler.hf_row['semimajorax'] / AU == pytest.approx( + handler.config.orbit.semimajoraxis, rel=1e-12 + ) + # Eccentricity takes the post-impact value in both places. + assert handler.config.orbit.eccentricity == pytest.approx(0.03, rel=1e-12) + assert handler.hf_row['eccentricity'] == pytest.approx(0.03, rel=1e-12) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_a_grazing_head_on_impact_leaves_the_orbit_circular(monkeypatch): + """A zero post-impact eccentricity is a valid boundary and is applied. + + The eccentricity is written directly, so the circular limit must come + through as exactly zero rather than being clamped away, and the + semi-major axis still moves by its ratio independently of it. + """ + from proteus.accretion.wrapper import apply_impact + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler(semimajoraxis=1.0, eccentricity=0.2) + event = _impact_event(a_before=1.0e11, a_after=1.0e11, e_after=0.0) + apply_impact(handler, event) + + assert handler.config.orbit.eccentricity == 0.0 + assert handler.hf_row['eccentricity'] == 0.0 + # Equal before/after semi-major axis is a unit ratio, so the orbit size + # is unchanged while the eccentricity is reset. + assert handler.config.orbit.semimajoraxis == pytest.approx(1.0, rel=1e-12) From d2498745f75f7824b58df1adda1eaa53f0597c53 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 23 Jul 2026 23:18:55 +0200 Subject: [PATCH 07/71] Re-melt the mantle to a molten state at each impact Each giant impact returns the mantle to a molten initial condition recomputed for the grown planet, so the interior evolves from a magma ocean after the impact. The reset is applied to the running state at the end of the impact step and the interior stepper is told to expect the temperature jump so it does not clip the deliberate reset away. The backends carry their state differently. The dummy and boundary backends cool a surface temperature and are reset to the configured initial value together with the melt fraction and reservoir masses derived from it, so the impact row is internally consistent rather than carrying a hot temperature beside a cooled melt fraction; the boundary backend also resets its surface temperature. Aragog re-applies its entropy initial condition and, crucially, carries the molten profile through the restore the coupling performs on the following step, so the reset is not silently overwritten by the previous cooled solution; the stale trajectory and cached boundary gradient are cleared so they are re-derived from the molten profile. A re-melted mantle is a magma ocean again, so the impact clears the one-way solidification latch; without that, outgassing would stay shut off and the volatiles would be treated as locked in a solid for the rest of the run. The reset temperature is checked against the liquidus, so a value too low to fully melt warns instead of passing silently. SPIDER holds its state in a restart file written by the external binary and has no validated re-melt path, so an accretion run on SPIDER is refused at configuration load; an Aragog run whose temperature mode does not guarantee a molten start warns at load. The impact kinetic energy is logged next to the re-melt for context. --- src/proteus/accretion/wrapper.py | 13 +- src/proteus/config/_config.py | 45 ++++- src/proteus/interior_energetics/common.py | 5 + src/proteus/interior_energetics/dummy.py | 81 +++++++-- src/proteus/interior_energetics/wrapper.py | 161 ++++++++++++++++- tests/accretion/test_wrapper.py | 60 ++++++- tests/config/test_accretion.py | 39 ++++ tests/interior_energetics/test_wrapper.py | 196 +++++++++++++++++++++ 8 files changed, 579 insertions(+), 21 deletions(-) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index 6c5418906..be86275d1 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -75,7 +75,7 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: event : ImpactEvent The impact to apply. """ - from proteus.interior_energetics.wrapper import solve_structure + from proteus.interior_energetics.wrapper import remelt_mantle, solve_structure config = handler.config hf_row = handler.hf_row @@ -95,6 +95,17 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: handler.directories, config, handler.hf_all, hf_row, handler.directories['output'] ) + # Re-melt the mantle to its molten initial condition, so the interior + # evolves from a fully molten state after the impact. + remelt_mantle(handler.directories, config, hf_row, handler.interior_o, event) + + # A mantle that had crystallised is now a magma ocean again, so lift the + # one-way solidification latch; otherwise outgassing would stay frozen and + # the volatiles would be treated as locked in a solid mantle for good. + if getattr(handler, 'crystallized', False): + handler.crystallized = False + log.info(' solidification latch cleared: the mantle is molten again') + # Move the orbit by the impact's proportional change in semi-major axis and # its post-impact eccentricity, writing both the configuration and the row. ratio = event.semimajoraxis_ratio diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index df2d818df..b5604e6cf 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -123,6 +123,45 @@ def check_module_dependencies(instance, attribute, value): raise ImportError(f'{msg}\n Original error: {e}') from e +def check_accretion_interior_compatibility(instance, attribute, value): + """Reject accretion runs on an interior that cannot re-melt after an impact. + + A giant impact fully re-melts the mantle, and the SPIDER interior keeps its + state in a restart file written by the external binary with no validated + re-melt path, so the combination is refused here at configuration load + rather than at the first impact, which can be many hours into a run. + """ + if ( + instance.accretion.module is not None + and instance.interior_energetics.module == 'spider' + ): + raise ValueError( + "accretion.module = '" + + str(instance.accretion.module) + + "' cannot run with interior_energetics.module = 'spider': a giant " + 'impact re-melts the mantle and SPIDER has no supported re-melt path. ' + "Use interior_energetics.module = 'aragog' (or 'dummy' for a test)." + ) + + # The Aragog re-melt re-applies the run's entropy initial condition. Only + # the liquidus_super temperature mode guarantees that condition is fully + # molten; with the others the re-melt is only as molten as the user's + # temperature or entropy value, so warn rather than silently under-melt. + if ( + instance.accretion.module is not None + and instance.interior_energetics.module == 'aragog' + and instance.planet.temperature_mode != 'liquidus_super' + ): + log.warning( + "accretion with interior_energetics.module = 'aragog' and " + "temperature_mode = '%s': a giant-impact re-melt re-applies this " + 'initial condition, which is only guaranteed fully molten for ' + "temperature_mode = 'liquidus_super'. Check the initial melt fraction " + 'is what you intend.', + instance.planet.temperature_mode, + ) + + def boreas_requires_atmosphere(instance, attribute, value): """BOREAS escape requires a radiative atmosphere (not dummy).""" if (instance.escape.module == 'boreas') and (instance.atmos_clim.module == 'dummy'): @@ -354,7 +393,11 @@ class Config: config_version: str = field( default='3.0', - validator=(valid_config_version, check_module_dependencies), + validator=( + valid_config_version, + check_module_dependencies, + check_accretion_interior_compatibility, + ), ) def write(self, out: str): diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index 49a34638a..c0fe03dd9 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -601,6 +601,11 @@ def __init__(self, nlev_b: int, spider_dir=None, eos_dir=None): # is every run with accretion switched off. self.t_next_impact = float('inf') + # Raised by a giant-impact re-melt so the next interior solve does + # not clip the deliberate temperature jump back out as if it were a + # solver anomaly. Consumed and cleared on that one step. + self.impact_reset = False + # Lookup data for SPIDER (P-S tables, used by E_th and # melt-volume bookkeeping). Each is a (nS, nP, 3) array, the # third channel being the SI value of the quantity. diff --git a/src/proteus/interior_energetics/dummy.py b/src/proteus/interior_energetics/dummy.py index b4108f8ec..a7113f468 100644 --- a/src/proteus/interior_energetics/dummy.py +++ b/src/proteus/interior_energetics/dummy.py @@ -18,6 +18,72 @@ log = logging.getLogger('fwl.' + __name__) +def melt_fraction(config: Config, temperature: float) -> float: + """Global melt fraction of the dummy mantle at a surface magma temperature. + + Linear between the configured solidus and liquidus, saturating at fully + solid below the solidus and fully molten above the liquidus. + + Parameters + ---------- + config : Config + Model configuration. + temperature : float + Surface magma temperature [K]. + + Returns + ------- + float + Melt fraction in [0, 1]. + """ + tliq = config.interior_energetics.dummy.mantle_tliq + tsol = config.interior_energetics.dummy.mantle_tsol + if temperature >= tliq: + return 1.0 + if temperature <= tsol: + return 0.0 + return (temperature - tsol) / (tliq - tsol) + + +def melt_state_from_temperature(config: Config, hf_row: dict, temperature: float) -> dict: + """Mantle melt quantities implied by a surface magma temperature. + + Derives every temperature-dependent mantle quantity the dummy backend + exposes, so a caller that changes the magma temperature outside the + normal solve (a giant-impact re-melt) can rewrite a fully consistent + state rather than leaving the melt fraction and reservoir masses stale. + + Parameters + ---------- + config : Config + Model configuration. + hf_row : dict + Current helpfile row, read for the structure (``M_int``, ``M_core``, + ``R_int``, ``R_core``). + temperature : float + Surface magma temperature [K]. + + Returns + ------- + dict + ``T_magma``, ``T_pot``, ``Phi_global``, ``Phi_global_vol``, + ``M_mantle_liquid``, ``M_mantle_solid`` and ``RF_depth``. + """ + phi = melt_fraction(config, temperature) + m_mantle = hf_row['M_int'] - hf_row['M_core'] + r_core = hf_row.get('R_core', config.interior_struct.core_frac * hf_row['R_int']) + core_radius_frac = r_core / hf_row['R_int'] + return { + 'T_magma': float(temperature), + 'T_pot': float(temperature), + 'Phi_global': phi, + 'Phi_global_vol': phi, + 'M_mantle_liquid': m_mantle * phi, + 'M_mantle_solid': m_mantle * (1.0 - phi), + 'RF_depth': phi * (1.0 - core_radius_frac), + } + + def calculate_simple_mantle_mass(radius: float, core_frac: float, density: float) -> float: """ A very simple interior structure model. @@ -69,22 +135,9 @@ def run_dummy_int( ) # Physical parameters - tmp_liq = config.interior_energetics.dummy.mantle_tliq # Liquidus - tmp_sol = config.interior_energetics.dummy.mantle_tsol # Solidus tmp_init = config.planet.tsurf_init # Initial magma temperature area = 4 * np.pi * hf_row['R_int'] ** 2 - # Get mantle melt fraction as a function of temperature - def _calc_phi(tmp: float): - # Too hot - if tmp >= tmp_liq: - return 1.0 - # Too cold - elif tmp <= tmp_sol: - return 0.0 - # Just right - return (tmp - tmp_sol) / (tmp_liq - tmp_sol) - # Interior heat capacity [J K-1] cp_int = ( config.interior_energetics.dummy.mantle_cp * output['M_mantle'] @@ -129,7 +182,7 @@ def _calc_phi(tmp: float): # Store scalars output['T_pot'] = float(output['T_magma']) - output['Phi_global'] = _calc_phi(output['T_magma']) + output['Phi_global'] = melt_fraction(config, output['T_magma']) output['Phi_global_vol'] = output['Phi_global'] output['M_mantle_liquid'] = output['M_mantle'] * output['Phi_global'] output['M_mantle_solid'] = output['M_mantle'] - output['M_mantle_liquid'] diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 00775bf33..18b5c7941 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -1795,6 +1795,155 @@ def equilibrate_initial_state(dirs: dict, config: Config, hf_row: dict, outdir: dirs['spider_liquidus_ps'] = spider_tables['liquidus_path'] +def _remelt_scalar_backend(config: Config, hf_row: dict) -> None: + """Re-melt a temperature-state backend (dummy or boundary) in place. + + These backends carry the mantle thermal state as a surface magma + temperature they cool from the configured initial value. Resetting that + temperature, and every melt quantity derived from it, returns the mantle + to its molten start. Writing the derived quantities as well keeps the + impact iteration self-consistent: the melt fraction and reservoir masses + match the reset temperature instead of lagging a step behind it. + """ + from proteus.interior_energetics.dummy import melt_state_from_temperature + + t_reset = config.planet.tsurf_init + state = melt_state_from_temperature(config, hf_row, t_reset) + hf_row.update(state) + # The boundary backend also cools a surface temperature that the atmosphere + # reads, so keep it in step with the magma temperature. + if config.interior_energetics.module == 'boundary': + hf_row['T_surf'] = t_reset + + if state['Phi_global'] < 1.0: + log.warning( + ' mantle re-melt left it only %.0f%% molten: tsurf_init=%.0f K is below ' + 'the liquidus. Raise planet.tsurf_init for a full re-melt.', + 100.0 * state['Phi_global'], + t_reset, + ) + log.info( + ' mantle re-melted: T_magma reset to %.0f K (melt fraction %.2f)', + t_reset, + state['Phi_global'], + ) + + +def _remelt_aragog(config: Config, dirs: dict, hf_row: dict, interior_o) -> None: + """Re-melt the Aragog mantle so the reset survives to the next solve. + + ``_set_entropy_ic`` alone only rewrites the solver's initial-state vector, + which the next coupling step overwrites when it restores the entropy from + the previous (cooled) solution. To make the re-melt stick, the restored + profile carrier ``interior_o._last_entropy`` is set to the molten profile, + the stale trajectory is cleared so the restore path cannot resurrect it, + and the cached CMB-gradient state is cleared so it is re-derived from the + molten profile rather than inherited from the cooled one. + """ + from proteus.interior_energetics.aragog import AragogRunner + + if interior_o.aragog_solver is None: + raise RuntimeError( + 'Cannot re-melt the mantle: the Aragog solver is not yet initialised. ' + 'An impact cannot precede the first interior solve.' + ) + + AragogRunner._set_entropy_ic(config, interior_o, dirs['output'], hf_row) + + solver = interior_o.aragog_solver + # Carry the molten profile onto the carrier the next step restores from, + # and drop the cooled trajectory so update_solver cannot re-derive the old + # field over it. + S_block = solver.entropy_staggered + S_molten = S_block[:, -1] if getattr(S_block, 'ndim', 1) > 1 else S_block + interior_o._last_entropy = np.asarray(S_molten, dtype=float).ravel().copy() + solver._solution = None + if hasattr(solver, '_dSdr_cmb_init'): + solver._dSdr_cmb_init = None + + log.info(' mantle re-melted: Aragog entropy reset to the molten initial condition') + + +def remelt_mantle(dirs: dict, config: Config, hf_row: dict, interior_o, event=None) -> None: + """Reset the mantle to its molten initial condition after a giant impact. + + A giant impact re-melts the mantle in full (no energy threshold), so the + interior is returned to a molten initial condition recomputed for the + current, grown planet. The reset is applied to the running interior state, + and an ``impact_reset`` flag is raised on ``interior_o`` so the next + interior solve does not clip the resulting temperature jump as if it were + a solver glitch. + + The backends carry their state differently, so each is reset in its own + terms: the dummy and boundary backends cool a surface temperature and are + reset to the configured initial value together with every quantity derived + from it; Aragog re-applies its entropy initial condition and carries the + molten profile through the reset the coupling performs on the next step. + SPIDER keeps its state in a restart file written by the external binary and + has no validated re-melt path; an accretion run on SPIDER is refused at + configuration load, and this backstop refuses it at the first impact. + + Parameters + ---------- + dirs : dict + Directories dictionary. + config : Config + Model configuration. + hf_row : dict + Current helpfile row, mutated in place for the scalar backends. + interior_o : Interior_t + Interior state, reset in place; its ``impact_reset`` flag is raised. + event : ImpactEvent, optional + The impact being applied, used only to log the impact energy against + the enthalpy the re-melt injects. + + Raises + ------ + NotImplementedError + If the interior module has no supported re-melt path (SPIDER). + ValueError + If the interior module is unrecognised. + RuntimeError + If the Aragog solver has not been initialised. + """ + module = config.interior_energetics.module + + match module: + case 'dummy' | 'boundary': + _remelt_scalar_backend(config, hf_row) + case 'aragog': + _remelt_aragog(config, dirs, hf_row, interior_o) + case 'spider': + UpdateStatusfile(dirs, 20) + raise NotImplementedError( + 'Giant-impact mantle re-melt is not supported with the SPIDER ' + 'interior. SPIDER holds its state in a restart file written by the ' + 'external binary, and no validated re-melt path exists yet. Use ' + "interior_energetics.module = 'aragog' for accretion runs." + ) + case _: + UpdateStatusfile(dirs, 20) + raise ValueError(f'Cannot re-melt the mantle: unknown interior module {module!r}') + + # Tell the time-stepper's limiter the coming temperature jump is a + # deliberate impact re-melt, not a solver anomaly to be clipped away. + interior_o.impact_reset = True + + # Log the impact kinetic energy for context. A full re-melt injects mantle- + # scale enthalpy with no source term, so leave a line the reader can weigh + # against the impact energy the event carries. + if event is not None: + reduced = ( + event.M_target_before + * event.M_impactor + / (event.M_target_before + event.M_impactor) + ) + e_impact = 0.5 * reduced * event.v_impact**2 + log.info( + ' impact kinetic energy %.3e J (re-melt injects mantle-scale enthalpy)', e_impact + ) + + def solve_structure( dirs: dict, config: Config, hf_all: pd.DataFrame, hf_row: dict, outdir: str ): @@ -2045,8 +2194,14 @@ def run_interior( # Update planet mass update_planet_mass(hf_row) + # The step after a giant-impact re-melt legitimately jumps the temperature + # from the cooled state to fully molten. Skip the warming clamp and the + # large-increase clip for that one step, then clear the flag, so the + # deliberate re-melt is not treated as a solver anomaly and clipped away. + impact_reset = getattr(interior_o, 'impact_reset', False) + # Apply step limiters - if hf_row['Time'] > 0: + if hf_row['Time'] > 0 and not impact_reset: # Prevent increasing surface temperature, if enabled. Gated by # _prevent_warming_clamp_active(); the runaway-T fallback below # remains active regardless. @@ -2091,6 +2246,10 @@ def run_interior( log.warning(' Clipped from %.2f K' % hf_row['T_surf']) hf_row['T_surf'] = T_surf_prev + dT_delta_surf + # One-shot: the post-impact step has now passed the limiters unclipped. + if impact_reset: + interior_o.impact_reset = False + # Print result of interior module if verbose: log.info(' T_magma = %.3f K' % float(hf_row['T_magma'])) diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index fd83fb4d8..092a541d7 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -192,17 +192,38 @@ def _impact_event(**overrides): return ImpactEvent(**base) -def _impact_handler(mass_tot=1.0, semimajoraxis=0.5, eccentricity=0.1): - """Build the minimal handler shape apply_impact reads and mutates.""" +def _impact_handler( + mass_tot=1.0, semimajoraxis=0.5, eccentricity=0.1, tsurf_init=4000.0, crystallized=False +): + """Build the minimal handler shape apply_impact reads and mutates. + + The dummy interior is used so the mantle re-melt runs for real (it resets + the temperature and the melt state) without needing a live solver. + """ from proteus.utils.constants import AU return SimpleNamespace( config=SimpleNamespace( - planet=SimpleNamespace(mass_tot=mass_tot), + planet=SimpleNamespace(mass_tot=mass_tot, tsurf_init=tsurf_init), orbit=SimpleNamespace(semimajoraxis=semimajoraxis, eccentricity=eccentricity), + interior_energetics=SimpleNamespace( + module='dummy', + dummy=SimpleNamespace(mantle_tliq=2700.0, mantle_tsol=1700.0), + ), + interior_struct=SimpleNamespace(core_frac=0.55), ), - hf_row={'semimajorax': semimajoraxis * AU, 'eccentricity': eccentricity}, + hf_row={ + 'semimajorax': semimajoraxis * AU, + 'eccentricity': eccentricity, + 'T_magma': 2000.0, # cooled; the re-melt should reset it + 'M_int': mass_tot * 5.9736e24, + 'M_core': 0.3 * mass_tot * 5.9736e24, + 'R_int': 6.4e6, + 'R_core': 3.5e6, + }, hf_all=None, + interior_o=SimpleNamespace(impact_reset=False), + crystallized=crystallized, directories={'output': '/tmp/unused'}, ) @@ -245,6 +266,37 @@ def test_impact_grows_the_planet_by_the_impactor_mass_and_re_solves(monkeypatch) # The structure was re-solved exactly once, against the grown planet. assert len(calls) == 1 + # The mantle was re-melted to its molten initial temperature, above the + # cooled 2000 K it started this step at, and fully molten. + assert handler.hf_row['T_magma'] == pytest.approx(4000.0, rel=1e-12) + assert handler.hf_row['T_magma'] > 2000.0 + assert handler.hf_row['Phi_global'] == pytest.approx(1.0, rel=1e-12) + # The interior stepper is told the temperature jump is a deliberate reset. + assert handler.interior_o.impact_reset is True + + +@pytest.mark.unit +def test_impact_on_a_crystallised_planet_reopens_outgassing(monkeypatch): + """A re-melting impact clears the one-way solidification latch. + + Once the mantle solidifies the run latches into a frozen-mantle path with + outgassing shut off. A giant impact that re-melts the mantle to a magma + ocean must lift that latch, or the re-melted planet would keep being + treated as a solid with its volatiles trapped for the rest of the run. + """ + from proteus.accretion.wrapper import apply_impact + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler(crystallized=True) + assert handler.crystallized is True # latched before the impact + apply_impact(handler, _impact_event()) + + # The impact re-melted the mantle, so the latch is lifted. + assert handler.crystallized is False + @pytest.mark.unit @pytest.mark.physics_invariant diff --git a/tests/config/test_accretion.py b/tests/config/test_accretion.py index 8cdd396d2..4cc22d91f 100644 --- a/tests/config/test_accretion.py +++ b/tests/config/test_accretion.py @@ -230,3 +230,42 @@ def test_reference_config_declares_the_accretion_section(): assert raw['accretion']['morrigan'][key] == pytest.approx( getattr(morrigan_defaults, key) ) + + +def _compat_instance(accretion_module, interior_module, temperature_mode='liquidus_super'): + """Duck-typed config instance the interior-compatibility validator reads.""" + from types import SimpleNamespace + + return SimpleNamespace( + accretion=SimpleNamespace(module=accretion_module), + interior_energetics=SimpleNamespace(module=interior_module), + planet=SimpleNamespace(temperature_mode=temperature_mode), + ) + + +@pytest.mark.unit +def test_accretion_on_spider_is_refused_at_config_load(): + """An accretion run on the SPIDER interior is rejected before it starts. + + A giant impact re-melts the mantle and SPIDER has no validated re-melt + path, so the combination must fail at configuration load rather than many + hours into a run at the first impact. The supported interiors are accepted, + and a run without accretion is never blocked on this ground. + """ + from proteus.config._config import check_accretion_interior_compatibility + + # The unsupported combination is refused, and the message names the fix. + with pytest.raises(ValueError, match='SPIDER has no supported re-melt path'): + check_accretion_interior_compatibility( + _compat_instance('morrigan', 'spider'), None, None + ) + with pytest.raises(ValueError, match='spider'): + check_accretion_interior_compatibility(_compat_instance('dummy', 'spider'), None, None) + + # The supported interiors pass, and so does any run without accretion. + for interior in ('aragog', 'dummy'): + check_accretion_interior_compatibility( + _compat_instance('morrigan', interior), None, None + ) + # No accretion: SPIDER is fine, the check does not fire. + check_accretion_interior_compatibility(_compat_instance(None, 'spider'), None, None) diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index ba4896f00..18ed87046 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -21,6 +21,7 @@ import logging import os from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, patch import numpy as np @@ -33,6 +34,7 @@ _eos_grid_extent_up_step, _prevent_warming_clamp_active, _refresh_composition_sentinels, + remelt_mantle, update_structure_from_interior, ) @@ -5642,3 +5644,197 @@ def _grid_up_step_no_scalar(*args, **kwargs): # The composition sentinel advanced off its stale seed (reject-path refresh). assert dirs['_last_w_H2O_liquid'] == pytest.approx(5.0e21 / 3.0e24, rel=1e-9) assert dirs['_last_w_H2O_liquid'] != pytest.approx(1.0e21 / 3.0e24) + + +def _remelt_config(module, tsurf_init=4000.0, mantle_tliq=2700.0, mantle_tsol=1700.0): + """Config shape remelt_mantle and the scalar-backend melt state read.""" + return SimpleNamespace( + interior_energetics=SimpleNamespace( + module=module, + dummy=SimpleNamespace(mantle_tliq=mantle_tliq, mantle_tsol=mantle_tsol), + ), + planet=SimpleNamespace(tsurf_init=tsurf_init), + interior_struct=SimpleNamespace(core_frac=0.55), + ) + + +def _remelt_hf_row(T_magma=2100.0): + """Cooled helpfile row carrying the structure the melt state needs.""" + return { + 'T_magma': T_magma, + 'M_int': 6.0e24, + 'M_core': 2.0e24, + 'R_int': 6.4e6, + 'R_core': 3.5e6, + } + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_remelt_returns_the_dummy_mantle_to_a_fully_molten_consistent_state(): + """A dummy re-melt rewrites the temperature AND every quantity it implies. + + The mantle must come back fully molten, which is the physical invariant: + at the reset temperature (above the liquidus) the melt fraction is 1 and + the entire mantle mass is liquid. Rewriting only the temperature and + leaving the melt fraction at its cooled value would be an impossible + state, so the derived quantities must move with it. + """ + config = _remelt_config('dummy', tsurf_init=4000.0) + hf_row = _remelt_hf_row(T_magma=2100.0) # cooled, partly solid + interior_o = SimpleNamespace(impact_reset=False) + + remelt_mantle({'output': '/tmp/unused'}, config, hf_row, interior_o) + + assert hf_row['T_magma'] == pytest.approx(4000.0, rel=1e-12) + # The invariant: fully molten, so melt fraction is exactly 1 and all of + # the mantle mass (M_int - M_core) is liquid. + assert hf_row['Phi_global'] == pytest.approx(1.0, rel=1e-12) + m_mantle = hf_row['M_int'] - hf_row['M_core'] + assert hf_row['M_mantle_liquid'] == pytest.approx(m_mantle, rel=1e-12) + assert hf_row['M_mantle_solid'] == pytest.approx(0.0, abs=1e12) + # Discrimination: leaving the cooled melt fraction would give Phi = 0.4 + # here (T=2100 between tsol=1700 and tliq=2700), far from 1. + assert hf_row['Phi_global'] > 0.9 + # The re-melt flags the coming temperature jump so it is not clipped. + assert interior_o.impact_reset is True + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_remelt_below_the_liquidus_warns_and_is_not_fully_molten(): + """A reset temperature below the liquidus cannot fully re-melt, and says so. + + Decision 7 is a full re-melt, but the dummy reset temperature is a free + configuration value; if it is set below the liquidus the mantle comes back + only partly molten. That must surface as a warning and a melt fraction + below 1, not pass silently as if the mantle were molten. + """ + config = _remelt_config('dummy', tsurf_init=2200.0, mantle_tliq=2700.0, mantle_tsol=1700.0) + hf_row = _remelt_hf_row(T_magma=1800.0) + interior_o = SimpleNamespace(impact_reset=False) + + import logging + + with pytest.MonkeyPatch.context(): + caplog_records = [] + handler = logging.Handler() + handler.emit = lambda record: caplog_records.append(record.getMessage()) + logger = logging.getLogger('fwl.proteus.interior_energetics.wrapper') + logger.addHandler(handler) + try: + remelt_mantle({'output': '/tmp/unused'}, config, hf_row, interior_o) + finally: + logger.removeHandler(handler) + + # (2200 - 1700) / (2700 - 1700) = 0.5, not fully molten. + assert hf_row['Phi_global'] == pytest.approx(0.5, rel=1e-9) + assert any('below' in m and 'liquidus' in m for m in caplog_records) + + +@pytest.mark.unit +def test_remelt_boundary_backend_resets_both_magma_and_surface_temperature(): + """The boundary backend shares the dummy reset, plus its surface temperature. + + The boundary backend carries a surface temperature the atmosphere reads in + addition to the magma temperature, so both must be reset together or the + two would disagree after the re-melt. + """ + config = _remelt_config('boundary', tsurf_init=4000.0) + hf_row = _remelt_hf_row(T_magma=2000.0) + hf_row['T_surf'] = 1500.0 + interior_o = SimpleNamespace(impact_reset=False) + + remelt_mantle({'output': '/tmp/unused'}, config, hf_row, interior_o) + + assert hf_row['T_magma'] == pytest.approx(4000.0, rel=1e-12) + assert hf_row['T_surf'] == pytest.approx(4000.0, rel=1e-12) + assert hf_row['Phi_global'] == pytest.approx(1.0, rel=1e-12) + + +class _FakeAragogSolver: + """Aragog solver stand-in with the state the re-melt round-trip touches. + + Mimics the two pieces that matter for the survival of a re-melt: the + ``entropy_staggered`` view of the current profile, and the ``_solution`` + trajectory that the coupling's restore step re-derives the initial + condition from when it is present. + """ + + def __init__(self, cooled_profile): + self._S0 = np.asarray(cooled_profile, dtype=float).copy() + self.entropy_staggered = np.asarray(cooled_profile, dtype=float).copy() + self._solution = object() # a stale (cooled) trajectory exists + self._dSdr_cmb_init = 1.234e-6 # stale CMB gradient from the cooled solve + + def set_initial_entropy(self, S): + # Real set_initial_entropy writes _S0 and, in the coupled path, the + # current profile view; mirror both so the re-melt can read it back. + self._S0 = np.asarray(S, dtype=float).copy() + self.entropy_staggered = np.asarray(S, dtype=float).copy() + + +@pytest.mark.unit +def test_aragog_remelt_survives_the_next_coupling_restore(): + """The Aragog re-melt must persist past the next step's entropy restore. + + The coupling restores the solver entropy from the previous solution at the + start of each step, so a re-melt that only rewrites the solver's initial + vector is erased. The re-melt must instead update the restore carrier with + the molten profile and drop the stale trajectory, so the restore that runs + next step re-applies the molten profile, not the cooled one. + """ + molten = np.full(6, 3900.0) + solver = _FakeAragogSolver(cooled_profile=np.full(6, 2400.0)) + interior_o = SimpleNamespace( + aragog_solver=solver, _last_entropy=np.full(6, 2400.0), impact_reset=False + ) + config = _remelt_config('aragog') + + # _set_entropy_ic is the start-up helper; here it stands in for "the solver + # now holds the molten profile", which is what the survival logic reads. + def _fake_set_ic(cfg, io, outdir, hf_row): + io.aragog_solver.set_initial_entropy(molten) + + with patch( + 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', + side_effect=_fake_set_ic, + ): + remelt_mantle({'output': '/tmp/out'}, config, hf_row={}, interior_o=interior_o) + + # The restore carrier now holds the molten profile, not the cooled one. + np.testing.assert_allclose(interior_o._last_entropy, molten) + # The stale trajectory and CMB gradient are cleared, so the next step's + # restore cannot re-derive the cooled field over the molten carrier. + assert solver._solution is None + assert solver._dSdr_cmb_init is None + + # Simulate the next step's restore (setup_or_update_solver): with no stale + # trajectory, _last_entropy is untouched, then re-applied. The solver ends + # holding the molten profile. + if solver._solution is not None: # the branch that would resurrect the cooled field + interior_o._last_entropy = solver.entropy_staggered + solver.set_initial_entropy(interior_o._last_entropy) + np.testing.assert_allclose(solver._S0, molten) + assert interior_o.impact_reset is True + + +@pytest.mark.unit +def test_remelt_refuses_spider_and_rejects_an_unknown_backend(): + """Re-melt fails loudly where it has no validated path, updating the status. + + SPIDER keeps its state in an external restart file with no validated + re-melt, so an accretion run on it must stop with an actionable message and + a written status file, not continue with an un-melted mantle. An + unrecognised backend is a programming error and is rejected outright. + """ + dirs = {'output': '/tmp/out'} + with patch('proteus.interior_energetics.wrapper.UpdateStatusfile') as mock_status: + with pytest.raises(NotImplementedError, match='SPIDER'): + remelt_mantle(dirs, _remelt_config('spider'), hf_row={}, interior_o=None) + # The status file is written before the raise, so the run does not die + # leaving the status reading "Running". + mock_status.assert_called_once() + + with pytest.raises(ValueError, match='unknown interior module'): + remelt_mantle(dirs, _remelt_config('nonsense'), hf_row={}, interior_o=None) From 780d27a85400ea5fb7d3297d342cf2d0832908ae Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 23 Jul 2026 23:59:05 +0200 Subject: [PATCH 08/71] Make the giant-impact re-melt survive and stay consistent The mantle re-melt landed but did not hold up under scrutiny. This is the correction round. The Aragog re-melt was being discarded: it wrote the molten profile into the solver's initial vector, but the next step restored the entropy from the previous cooled solution and overwrote it. The molten profile is now taken from the initial-condition helper's return value and placed on the carrier the next step restores from, and the stale trajectory and its cached boundary gradient are dropped before the initial condition is rebuilt, so the gradient is derived from the molten profile rather than inherited from the cooled one. A real coupled run confirms it: the step after an impact restores a uniform molten profile, not the pre-impact field. The temperature-jump limiter no longer clips the re-melt: the reset raises a one-shot flag consumed at the top of the interior step, which is cleared even on an early solver-retry exit so it can never suppress the limiter on a later ordinary step. The flux positivity floor stays outside that bypass, since a negative flux must never reach the atmosphere regardless. The boundary backend is re-melted with its own melting curve rather than the dummy backend's, and the scalar backends now refresh the interior melt-fraction arrays the same-iteration tidal call reads. The solidification latch, cleared by an impact, is no longer re-armed on the same iteration from the stale pre-impact melt fraction. The end-of-run interior snapshot guards on the solver's solution, which an impact on the final step clears. The molten-start advisory for Aragog moves to the accretion setup, where the run log exists, and no longer fires for the temperature modes that are molten by construction. Several items are deliberately left open and recorded in the project notes: the injected re-melt enthalpy is not yet entered into the energy-conservation residual; the structure re-solve already re-melts the scalar backends, so a coupled dummy run does not isolate the re-melt; and for Aragog the melt fraction the same-iteration outgassing sees lags one step. These need design decisions rather than a quick patch. --- src/proteus/accretion/wrapper.py | 18 ++++ src/proteus/config/_config.py | 18 ---- src/proteus/interior_energetics/aragog.py | 4 + src/proteus/interior_energetics/dummy.py | 23 ++++-- src/proteus/interior_energetics/wrapper.py | 83 +++++++++++-------- src/proteus/proteus.py | 14 +++- tests/accretion/test_wrapper.py | 13 ++- tests/interior_energetics/test_wrapper.py | 95 ++++++++++++---------- 8 files changed, 164 insertions(+), 104 deletions(-) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index be86275d1..85a7099fa 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -39,6 +39,24 @@ def init_accretion(handler: Proteus) -> list[ImpactEvent]: return [] log.info('Preparing accretion model') + + # Advise when the Aragog re-melt initial condition is not guaranteed molten. + # The re-melt re-applies the run's temperature-mode initial condition, and + # only some modes guarantee it is fully molten; the others are only as molten + # as the user's temperature or entropy value. Emitted here, after the file + # logger exists, rather than in the config validator, which runs before it. + _MOLTEN_MODES = ('liquidus_super', 'accretion', 'adiabatic_from_cmb') + if ( + config.interior_energetics.module == 'aragog' + and config.planet.temperature_mode not in _MOLTEN_MODES + ): + log.warning( + "Accretion on Aragog with temperature_mode='%s': each impact re-melts " + 'the mantle by re-applying this initial condition, which is not guaranteed ' + "fully molten. Use temperature_mode='liquidus_super' for a molten re-melt, " + 'or confirm the initial melt fraction is what you intend.', + config.planet.temperature_mode, + ) log.info('') match module: diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index b5604e6cf..c22e39aee 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -143,24 +143,6 @@ def check_accretion_interior_compatibility(instance, attribute, value): "Use interior_energetics.module = 'aragog' (or 'dummy' for a test)." ) - # The Aragog re-melt re-applies the run's entropy initial condition. Only - # the liquidus_super temperature mode guarantees that condition is fully - # molten; with the others the re-melt is only as molten as the user's - # temperature or entropy value, so warn rather than silently under-melt. - if ( - instance.accretion.module is not None - and instance.interior_energetics.module == 'aragog' - and instance.planet.temperature_mode != 'liquidus_super' - ): - log.warning( - "accretion with interior_energetics.module = 'aragog' and " - "temperature_mode = '%s': a giant-impact re-melt re-applies this " - 'initial condition, which is only guaranteed fully molten for ' - "temperature_mode = 'liquidus_super'. Check the initial melt fraction " - 'is what you intend.', - instance.planet.temperature_mode, - ) - def boreas_requires_atmosphere(instance, attribute, value): """BOREAS escape requires a radiative atmosphere (not dummy).""" diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index d41296aa2..332c2fdda 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -1459,6 +1459,10 @@ def _set_entropy_ic( float(S_target), N, ) + # Return the staggered entropy profile just set, so a caller re-melting + # mid-run can carry it forward without re-deriving it from the solver's + # solution object (which still holds the pre-reset trajectory). + return S_init @staticmethod def _verify_entropy_ic( diff --git a/src/proteus/interior_energetics/dummy.py b/src/proteus/interior_energetics/dummy.py index a7113f468..6ee826109 100644 --- a/src/proteus/interior_energetics/dummy.py +++ b/src/proteus/interior_energetics/dummy.py @@ -18,11 +18,25 @@ log = logging.getLogger('fwl.' + __name__) +def _solidus_liquidus(config: Config) -> tuple[float, float]: + """Return the (solidus, liquidus) the active scalar backend uses [K]. + + The dummy and boundary backends carry separate melting curves; the melt + fraction of a re-melt must use the one the running backend evolves against, + not the dummy defaults for both. + """ + if config.interior_energetics.module == 'boundary': + b = config.interior_energetics.boundary + return b.T_solidus, b.T_liquidus + d = config.interior_energetics.dummy + return d.mantle_tsol, d.mantle_tliq + + def melt_fraction(config: Config, temperature: float) -> float: - """Global melt fraction of the dummy mantle at a surface magma temperature. + """Global melt fraction of the scalar-backend mantle at a surface temperature. - Linear between the configured solidus and liquidus, saturating at fully - solid below the solidus and fully molten above the liquidus. + Linear between the active backend's solidus and liquidus, saturating at + fully solid below the solidus and fully molten above the liquidus. Parameters ---------- @@ -36,8 +50,7 @@ def melt_fraction(config: Config, temperature: float) -> float: float Melt fraction in [0, 1]. """ - tliq = config.interior_energetics.dummy.mantle_tliq - tsol = config.interior_energetics.dummy.mantle_tsol + tsol, tliq = _solidus_liquidus(config) if temperature >= tliq: return 1.0 if temperature <= tsol: diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 18b5c7941..af5e77715 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -1795,16 +1795,19 @@ def equilibrate_initial_state(dirs: dict, config: Config, hf_row: dict, outdir: dirs['spider_liquidus_ps'] = spider_tables['liquidus_path'] -def _remelt_scalar_backend(config: Config, hf_row: dict) -> None: +def _remelt_scalar_backend(config: Config, hf_row: dict, interior_o) -> None: """Re-melt a temperature-state backend (dummy or boundary) in place. These backends carry the mantle thermal state as a surface magma temperature they cool from the configured initial value. Resetting that temperature, and every melt quantity derived from it, returns the mantle - to its molten start. Writing the derived quantities as well keeps the - impact iteration self-consistent: the melt fraction and reservoir masses - match the reset temperature instead of lagging a step behind it. + to its molten start. Writing the derived quantities as well, and the melt + fraction and temperature onto the interior arrays the same-iteration tidal + call reads, keeps the impact iteration self-consistent rather than leaving + those quantities a step behind the reset temperature. """ + import numpy as np + from proteus.interior_energetics.dummy import melt_state_from_temperature t_reset = config.planet.tsurf_init @@ -1815,6 +1818,11 @@ def _remelt_scalar_backend(config: Config, hf_row: dict) -> None: if config.interior_energetics.module == 'boundary': hf_row['T_surf'] = t_reset + # Refresh the single-cell interior arrays the orbit/tides block reads later + # in this same iteration, so tidal heating uses the re-melted melt fraction. + interior_o.phi = np.array([state['Phi_global']]) + interior_o.temp = np.array([t_reset]) + if state['Phi_global'] < 1.0: log.warning( ' mantle re-melt left it only %.0f%% molten: tsurf_init=%.0f K is below ' @@ -1848,19 +1856,23 @@ def _remelt_aragog(config: Config, dirs: dict, hf_row: dict, interior_o) -> None 'An impact cannot precede the first interior solve.' ) - AragogRunner._set_entropy_ic(config, interior_o, dirs['output'], hf_row) - solver = interior_o.aragog_solver - # Carry the molten profile onto the carrier the next step restores from, - # and drop the cooled trajectory so update_solver cannot re-derive the old - # field over it. - S_block = solver.entropy_staggered - S_molten = S_block[:, -1] if getattr(S_block, 'ndim', 1) > 1 else S_block - interior_o._last_entropy = np.asarray(S_molten, dtype=float).ravel().copy() + # Drop the cooled trajectory and its cached CMB gradient BEFORE rebuilding + # the initial condition. This has to come first: _set_entropy_ic hot-starts + # the boundary gradient from the solution when one is present, so clearing + # it first forces a cold-start derived from the molten profile. Clearing the + # trajectory also stops the next step's restore from re-deriving the cooled + # field over the molten one. solver._solution = None if hasattr(solver, '_dSdr_cmb_init'): solver._dSdr_cmb_init = None + # _set_entropy_ic returns the staggered molten profile it just set. Take it + # from the return value rather than from the solver's solution object, which + # holds no valid trajectory now and would in any case lag the reset. + S_molten = AragogRunner._set_entropy_ic(config, interior_o, dirs['output'], hf_row) + interior_o._last_entropy = np.asarray(S_molten, dtype=float).ravel().copy() + log.info(' mantle re-melted: Aragog entropy reset to the molten initial condition') @@ -1910,7 +1922,7 @@ def remelt_mantle(dirs: dict, config: Config, hf_row: dict, interior_o, event=No match module: case 'dummy' | 'boundary': - _remelt_scalar_backend(config, hf_row) + _remelt_scalar_backend(config, hf_row, interior_o) case 'aragog': _remelt_aragog(config, dirs, hf_row, interior_o) case 'spider': @@ -2033,6 +2045,13 @@ def run_interior( log.info('Evolve interior...') log.debug('Using %s module to evolve interior' % config.interior_energetics.module) + # Consume the one-shot giant-impact re-melt flag up front, so the step after + # a re-melt skips the temperature-jump clip below, and so the flag is cleared + # even on an early return further down (e.g. a solver retry-ladder exit) and + # cannot wrongly suppress the clip on a later, ordinary step. + impact_reset = getattr(interior_o, 'impact_reset', False) + interior_o.impact_reset = False + # Write tidal heating file if config.interior_energetics.heat_tidal: interior_o.write_tides(dirs['output']) @@ -2194,14 +2213,11 @@ def run_interior( # Update planet mass update_planet_mass(hf_row) - # The step after a giant-impact re-melt legitimately jumps the temperature - # from the cooled state to fully molten. Skip the warming clamp and the - # large-increase clip for that one step, then clear the flag, so the - # deliberate re-melt is not treated as a solver anomaly and clipped away. - impact_reset = getattr(interior_o, 'impact_reset', False) - - # Apply step limiters - if hf_row['Time'] > 0 and not impact_reset: + # Apply step limiters. The F_int positivity floor is applied unconditionally + # (below); the warming clamp and the large-increase clips are skipped on the + # single step after a giant-impact re-melt, whose deliberate temperature jump + # must not be treated as a solver anomaly. + if hf_row['Time'] > 0: # Prevent increasing surface temperature, if enabled. Gated by # _prevent_warming_clamp_active(); the runaway-T fallback below # remains active regardless. @@ -2209,23 +2225,24 @@ def run_interior( T_surf_prev = float(hf_all.iloc[-1]['T_surf']) Phi_global_prev = float(hf_all.iloc[-1]['Phi_global']) F_int_prev = float(hf_all.iloc[-1]['F_int']) - if _prevent_warming_clamp_active(config) and (interior_o.ic == 2): + if _prevent_warming_clamp_active(config) and (interior_o.ic == 2) and not impact_reset: hf_row['Phi_global'] = min(hf_row['Phi_global'], Phi_global_prev) hf_row['T_magma'] = min(hf_row['T_magma'], T_magma_prev) hf_row['T_surf'] = min(hf_row['T_surf'], T_surf_prev) hf_row['F_int'] = min(hf_row['F_int'], F_int_prev) # F_int positivity floor under prevent_warming, applied for all - # ic values (not just ic == 2). SPIDER's JSON output can produce - # a slightly-negative F_int on the first post-restart step (ic - # = 1) because the thermal state is read from the previous - # solver epoch; the floor is what stopped a negative flux from - # propagating to the helpfile + atmosphere BC before this floor - # was relocated out of ReadSPIDER in the 7g commit. + # ic values (not just ic == 2), and NOT skipped on the impact-reset + # step: a negative flux must never reach the helpfile or the atmosphere + # BC. SPIDER's JSON output can produce a slightly-negative F_int on the + # first post-restart step (ic = 1) because the thermal state is read from + # the previous solver epoch; the floor is what stopped a negative flux + # from propagating before this floor was relocated out of ReadSPIDER. if _prevent_warming_clamp_active(config): hf_row['F_int'] = max(1.0e-8, hf_row['F_int']) - # Do not allow massive increases to T_magma or T_surf. + # Do not allow massive increases to T_magma or T_surf. Skipped on the + # impact-reset step so the re-melt's jump survives. # # T_magma uses the SPIDER/Aragog/dummy tolerance formula for # every backend. For all backends T_surf shares the @@ -2235,21 +2252,17 @@ def run_interior( dT_delta_surf = dT_delta_magma - if hf_row['T_magma'] > T_magma_prev + dT_delta_magma: + if (not impact_reset) and hf_row['T_magma'] > T_magma_prev + dT_delta_magma: log.warning('Prevented large increase to T_magma!') log.warning(' Clipped from %.2f K' % hf_row['T_magma']) hf_row['T_magma'] = T_magma_prev + dT_delta_magma hf_row['Phi_global'] = Phi_global_prev - if hf_row['T_surf'] > T_surf_prev + dT_delta_surf: + if (not impact_reset) and hf_row['T_surf'] > T_surf_prev + dT_delta_surf: log.warning('Prevented large increase to T_surf!') log.warning(' Clipped from %.2f K' % hf_row['T_surf']) hf_row['T_surf'] = T_surf_prev + dT_delta_surf - # One-shot: the post-impact step has now passed the limiters unclipped. - if impact_reset: - interior_o.impact_reset = False - # Print result of interior module if verbose: log.info(' T_magma = %.3f K' % float(hf_row['T_magma'])) diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index 172ce3d40..84c42a003 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -993,7 +993,16 @@ def start(self, *, resume: bool = False, offline: bool = False): # Fractional crystallization with compositional zonation requires # explicit tracking of the solid composition field, which is beyond # the current solver capabilities. See Boujibar+2020 for discussion. - if self.config.params.stop.solid.freeze_volatiles and not self.crystallized: + # A giant impact this iteration just re-melted the mantle; its + # true melt state is regenerated by the next interior solve, so + # do not re-arm the latch from this iteration's stale Phi_global + # (which for Aragog still reads the pre-impact cooled value). + impact_this_iter = getattr(self.interior_o, 'impact_reset', False) + if ( + self.config.params.stop.solid.freeze_volatiles + and not self.crystallized + and not impact_this_iter + ): if ( self.hf_row.get('Phi_global', 1.0) <= self.config.params.stop.solid.phi_crit @@ -1248,9 +1257,12 @@ def start(self, *, resume: bool = False, offline: bool = False): # Ensure the final interior state is on disk so resume can find it. # dt_write_rel may have suppressed the write on the last iteration. + # A giant-impact re-melt on the last iteration clears the solver's + # solution object, so guard on it: get_state() dereferences it. if ( self.config.interior_energetics.module == 'aragog' and self.interior_o.aragog_solver is not None + and self.interior_o.aragog_solver.solution is not None ): from proteus.interior_energetics.aragog import AragogRunner diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 092a541d7..7035cf83c 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -71,7 +71,14 @@ def _timeline_file(path): return path -def _handler(module=None, timeline_path=None, time_offset=0.0, time_start=0.0): +def _handler( + module=None, + timeline_path=None, + time_offset=0.0, + time_start=0.0, + interior_module='dummy', + temperature_mode='liquidus_super', +): """Build the minimal Proteus handler shape init_accretion reads.""" return SimpleNamespace( config=SimpleNamespace( @@ -81,7 +88,9 @@ def _handler(module=None, timeline_path=None, time_offset=0.0, time_start=0.0): dummy=SimpleNamespace( timeline_path=None if timeline_path is None else str(timeline_path) ), - ) + ), + interior_energetics=SimpleNamespace(module=interior_module), + planet=SimpleNamespace(temperature_mode=temperature_mode), ), hf_row={'Time': time_start}, ) diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index 18ed87046..13a4581aa 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -5646,12 +5646,20 @@ def _grid_up_step_no_scalar(*args, **kwargs): assert dirs['_last_w_H2O_liquid'] != pytest.approx(1.0e21 / 3.0e24) -def _remelt_config(module, tsurf_init=4000.0, mantle_tliq=2700.0, mantle_tsol=1700.0): +def _remelt_config( + module, + tsurf_init=4000.0, + mantle_tliq=2700.0, + mantle_tsol=1700.0, + b_tsol=1420.0, + b_tliq=2020.0, +): """Config shape remelt_mantle and the scalar-backend melt state read.""" return SimpleNamespace( interior_energetics=SimpleNamespace( module=module, dummy=SimpleNamespace(mantle_tliq=mantle_tliq, mantle_tsol=mantle_tsol), + boundary=SimpleNamespace(T_solidus=b_tsol, T_liquidus=b_tliq), ), planet=SimpleNamespace(tsurf_init=tsurf_init), interior_struct=SimpleNamespace(core_frac=0.55), @@ -5702,34 +5710,24 @@ def test_remelt_returns_the_dummy_mantle_to_a_fully_molten_consistent_state(): @pytest.mark.unit @pytest.mark.physics_invariant -def test_remelt_below_the_liquidus_warns_and_is_not_fully_molten(): +def test_remelt_below_the_liquidus_warns_and_is_not_fully_molten(caplog): """A reset temperature below the liquidus cannot fully re-melt, and says so. - Decision 7 is a full re-melt, but the dummy reset temperature is a free - configuration value; if it is set below the liquidus the mantle comes back - only partly molten. That must surface as a warning and a melt fraction + A full re-melt is the intended behaviour, but the dummy reset temperature is + a free configuration value; if it is set below the liquidus the mantle comes + back only partly molten. That must surface as a warning and a melt fraction below 1, not pass silently as if the mantle were molten. """ config = _remelt_config('dummy', tsurf_init=2200.0, mantle_tliq=2700.0, mantle_tsol=1700.0) hf_row = _remelt_hf_row(T_magma=1800.0) interior_o = SimpleNamespace(impact_reset=False) - import logging - - with pytest.MonkeyPatch.context(): - caplog_records = [] - handler = logging.Handler() - handler.emit = lambda record: caplog_records.append(record.getMessage()) - logger = logging.getLogger('fwl.proteus.interior_energetics.wrapper') - logger.addHandler(handler) - try: - remelt_mantle({'output': '/tmp/unused'}, config, hf_row, interior_o) - finally: - logger.removeHandler(handler) + with caplog.at_level('WARNING', logger='fwl.proteus.interior_energetics.wrapper'): + remelt_mantle({'output': '/tmp/unused'}, config, hf_row, interior_o) # (2200 - 1700) / (2700 - 1700) = 0.5, not fully molten. assert hf_row['Phi_global'] == pytest.approx(0.5, rel=1e-9) - assert any('below' in m and 'liquidus' in m for m in caplog_records) + assert any('below' in m and 'liquidus' in m for m in caplog.messages) @pytest.mark.unit @@ -5753,36 +5751,44 @@ def test_remelt_boundary_backend_resets_both_magma_and_surface_temperature(): class _FakeAragogSolver: - """Aragog solver stand-in with the state the re-melt round-trip touches. - - Mimics the two pieces that matter for the survival of a re-melt: the - ``entropy_staggered`` view of the current profile, and the ``_solution`` - trajectory that the coupling's restore step re-derives the initial - condition from when it is present. + """Aragog solver stand-in faithful to the property semantics that matter. + + On the real solver ``entropy_staggered`` is a read-only property computed + from ``_solution.y`` and raises when no solve has run; ``set_initial_entropy`` + writes only ``_S0``. Modelling that faithfully is the point: a re-melt that + reads ``entropy_staggered`` after clearing ``_solution`` would raise here, + exactly as it would on the real solver, so a return-to-``entropy_staggered`` + regression cannot pass this test. """ + class _Solution: + def __init__(self, profile): + self.y = np.asarray(profile, dtype=float).reshape(-1, 1) + def __init__(self, cooled_profile): self._S0 = np.asarray(cooled_profile, dtype=float).copy() - self.entropy_staggered = np.asarray(cooled_profile, dtype=float).copy() - self._solution = object() # a stale (cooled) trajectory exists + self._solution = self._Solution(cooled_profile) # a stale (cooled) trajectory self._dSdr_cmb_init = 1.234e-6 # stale CMB gradient from the cooled solve + @property + def entropy_staggered(self): + # Read-only view over the solved trajectory, as on the real solver. + return self._solution.y[:, -1] + def set_initial_entropy(self, S): - # Real set_initial_entropy writes _S0 and, in the coupled path, the - # current profile view; mirror both so the re-melt can read it back. + # The real method writes only _S0; it does NOT update entropy_staggered. self._S0 = np.asarray(S, dtype=float).copy() - self.entropy_staggered = np.asarray(S, dtype=float).copy() @pytest.mark.unit -def test_aragog_remelt_survives_the_next_coupling_restore(): +def test_aragog_remelt_carries_the_molten_profile_past_the_next_restore(): """The Aragog re-melt must persist past the next step's entropy restore. The coupling restores the solver entropy from the previous solution at the - start of each step, so a re-melt that only rewrites the solver's initial - vector is erased. The re-melt must instead update the restore carrier with - the molten profile and drop the stale trajectory, so the restore that runs - next step re-applies the molten profile, not the cooled one. + start of each step, so a re-melt that reads the cooled solution back into the + restore carrier is erased. The re-melt must take the molten profile from + what it just set (the helper's return value), put it on the restore carrier, + and drop the stale trajectory and its CMB gradient BEFORE rebuilding the IC. """ molten = np.full(6, 3900.0) solver = _FakeAragogSolver(cooled_profile=np.full(6, 2400.0)) @@ -5791,10 +5797,16 @@ def test_aragog_remelt_survives_the_next_coupling_restore(): ) config = _remelt_config('aragog') - # _set_entropy_ic is the start-up helper; here it stands in for "the solver - # now holds the molten profile", which is what the survival logic reads. + # _set_entropy_ic sets the molten profile onto _S0 and returns it, and must + # see the trajectory already cleared (so its CMB-gradient hot-start cannot + # inherit the cooled solve). Assert both here. def _fake_set_ic(cfg, io, outdir, hf_row): + assert io.aragog_solver._solution is None, ( + 'trajectory must be cleared before IC rebuild' + ) + assert io.aragog_solver._dSdr_cmb_init is None, 'CMB gradient must be cleared first' io.aragog_solver.set_initial_entropy(molten) + return molten with patch( 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', @@ -5804,16 +5816,13 @@ def _fake_set_ic(cfg, io, outdir, hf_row): # The restore carrier now holds the molten profile, not the cooled one. np.testing.assert_allclose(interior_o._last_entropy, molten) - # The stale trajectory and CMB gradient are cleared, so the next step's - # restore cannot re-derive the cooled field over the molten carrier. assert solver._solution is None assert solver._dSdr_cmb_init is None - # Simulate the next step's restore (setup_or_update_solver): with no stale - # trajectory, _last_entropy is untouched, then re-applied. The solver ends - # holding the molten profile. - if solver._solution is not None: # the branch that would resurrect the cooled field - interior_o._last_entropy = solver.entropy_staggered + # The next step's restore re-applies the carrier: because the trajectory is + # cleared, update_solver leaves _last_entropy alone, and set_initial_entropy + # writes the molten profile onto _S0. A regression reading entropy_staggered + # here would raise (no _solution), which is the point of the faithful fake. solver.set_initial_entropy(interior_o._last_entropy) np.testing.assert_allclose(solver._S0, molten) assert interior_o.impact_reset is True From 4bcc589a43e877f1879317d8d05de0d3c3ba7845 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 24 Jul 2026 00:08:03 +0200 Subject: [PATCH 09/71] Deliver an impactor's volatiles into the planet at each impact An impact can carry volatiles into the planet. Each impact now adds, per element, the impactor mass times the configured content in parts per million by weight to the whole-planet element budget the outgassing step reads, so the volatiles are re-equilibrated into the melt and atmosphere on the same iteration. Every element defaults to zero, a dry impactor, so a run that does not opt in is unchanged, and an element that is deferred to the chemistry step is left untouched when nothing is delivered for it. A real run delivering 5000 ppmw of hydrogen on a half-Earth-mass impactor adds 1.49e22 kg of hydrogen, as expected. One interaction is recorded in the project notes rather than patched here: the structure re-solve that follows the mass growth recomputes the parts-per-million element budgets against the grown mass, which rescales the volatile budgets as if the accreted rock carried the planet's volatile content. Handling that correctly, rock scaling with the added mass while volatiles are conserved and only grown by delivery, is part of the whole-planet element accounting and wants a deliberate decision. --- src/proteus/accretion/wrapper.py | 38 +++++++++++++++ tests/accretion/test_wrapper.py | 82 +++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index 85a7099fa..3ec8cdbc3 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -139,6 +139,44 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: config.orbit.eccentricity, ) + # Deliver the impactor's volatiles into the whole-planet element budgets, + # so the outgassing step later this iteration re-equilibrates with them. The + # amount per element is the impactor mass times the configured content in + # ppmw; every element defaults to zero (a dry impactor), so this is a no-op + # unless a run opts in. Only the keys with a non-zero content are touched, so + # an element deferred to the chemistry step (e.g. oxygen under ic_chemistry) + # is left alone when nothing is delivered for it. + _deliver_impactor_volatiles(config, hf_row, event.M_impactor) + + +def _deliver_impactor_volatiles(config, hf_row: dict, m_impactor: float) -> None: + """Add the impactor's volatile content to the whole-planet element budgets. + + Parameters + ---------- + config : Config + Model configuration; reads ``accretion.impactor__ppmw``. + hf_row : dict + Current helpfile row, whose ``_kg_total`` budgets are grown in place. + m_impactor : float + Impactor mass [kg]. + """ + delivered = {} + for element in ('H', 'C', 'N', 'S', 'O'): + ppmw = getattr(config.accretion, f'impactor_{element}_ppmw') + if ppmw <= 0.0: + continue + added = m_impactor * ppmw / 1.0e6 + key = f'{element}_kg_total' + hf_row[key] = hf_row.get(key, 0.0) + added + delivered[element] = added + + if delivered: + log.info( + ' delivered impactor volatiles [kg]: %s', + ', '.join(f'{e}={v:.3e}' for e, v in delivered.items()), + ) + def _drop_events_before_start( events: list[ImpactEvent], time_start: float diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 7035cf83c..d030a77ad 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -201,8 +201,24 @@ def _impact_event(**overrides): return ImpactEvent(**base) +def _impact_accretion(**ppmw): + """Accretion sub-config with impactor volatile contents (default dry).""" + return SimpleNamespace( + impactor_H_ppmw=ppmw.get('H', 0.0), + impactor_C_ppmw=ppmw.get('C', 0.0), + impactor_N_ppmw=ppmw.get('N', 0.0), + impactor_S_ppmw=ppmw.get('S', 0.0), + impactor_O_ppmw=ppmw.get('O', 0.0), + ) + + def _impact_handler( - mass_tot=1.0, semimajoraxis=0.5, eccentricity=0.1, tsurf_init=4000.0, crystallized=False + mass_tot=1.0, + semimajoraxis=0.5, + eccentricity=0.1, + tsurf_init=4000.0, + crystallized=False, + accretion=None, ): """Build the minimal handler shape apply_impact reads and mutates. @@ -220,6 +236,7 @@ def _impact_handler( dummy=SimpleNamespace(mantle_tliq=2700.0, mantle_tsol=1700.0), ), interior_struct=SimpleNamespace(core_frac=0.55), + accretion=accretion if accretion is not None else _impact_accretion(), ), hf_row={ 'semimajorax': semimajoraxis * AU, @@ -367,3 +384,66 @@ def test_a_grazing_head_on_impact_leaves_the_orbit_circular(monkeypatch): # Equal before/after semi-major axis is a unit ratio, so the orbit size # is unchanged while the eccentricity is reset. assert handler.config.orbit.semimajoraxis == pytest.approx(1.0, rel=1e-12) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_impact_delivers_configured_volatiles_into_the_element_budgets(monkeypatch): + """An opted-in impactor adds its volatile content to the planet budgets. + + Delivery is the impactor mass times the configured content in parts per + million by weight, added to the whole-planet element inventory the + outgassing step reads. Only the elements with a non-zero content are + touched: a dry element leaves its budget, and a budget deferred to the + chemistry step, untouched. + """ + from proteus.accretion.wrapper import apply_impact + from proteus.utils.constants import M_earth + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + # Impactor delivers 1000 ppmw H and 500 ppmw S; C, N, O are dry. + handler = _impact_handler(accretion=_impact_accretion(H=1000.0, S=500.0)) + handler.hf_row['H_kg_total'] = 2.0e20 # a pre-existing hydrogen budget + handler.hf_row['S_kg_total'] = 1.0e20 + m_impactor = 0.5 * M_earth + apply_impact(handler, _impact_event(M_impactor=m_impactor)) + + # Hydrogen grew by exactly M_impactor * 1000e-6. + expected_H = 2.0e20 + m_impactor * 1000.0 / 1.0e6 + assert handler.hf_row['H_kg_total'] == pytest.approx(expected_H, rel=1e-12) + # Discrimination: forgetting the ppmw-to-fraction 1e6 would overshoot by a + # million-fold, and delivering nothing would leave it at 2e20. + assert handler.hf_row['H_kg_total'] > 2.0e20 + assert handler.hf_row['H_kg_total'] < 2.0e20 + m_impactor # never the full impactor mass + + # Sulfur grew by its own configured amount. + assert handler.hf_row['S_kg_total'] == pytest.approx( + 1.0e20 + m_impactor * 500.0 / 1.0e6, rel=1e-12 + ) + # A dry element that was never in the row is not created. + assert 'O_kg_total' not in handler.hf_row + + +@pytest.mark.unit +def test_a_dry_impactor_delivers_no_volatiles(monkeypatch): + """The default dry impactor leaves every element budget untouched. + + Delivery is opt-in per element and defaults to zero, so a run that sets no + impactor content must not create or grow any element budget at an impact. + """ + from proteus.accretion.wrapper import apply_impact + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler() # dry impactor (all ppmw zero) + handler.hf_row['H_kg_total'] = 3.0e20 + apply_impact(handler, _impact_event()) + + # The existing budget is unchanged and no new element key appears. + assert handler.hf_row['H_kg_total'] == pytest.approx(3.0e20, rel=1e-12) + assert not any(k.endswith('_kg_total') and k != 'H_kg_total' for k in handler.hf_row) From 93d280f5e6935a9f05568a4143397107a4e04167 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 24 Jul 2026 08:25:31 +0200 Subject: [PATCH 10/71] Conserve the planet's volatiles across an impact's mass growth A giant impact grows the planet's mass and re-solves its structure, which recomputed the ppmw volatile budgets against the grown mass. A dry, rock-dominated impactor then inflated the hydrogen, carbon, nitrogen and sulfur inventories as if the added rock carried the planet's own volatile content, which is unphysical. The mass growth now adds rock, not volatiles: the volatile budgets are snapshotted before the structure re-solve and restored afterwards, so they are conserved across the growth and grow only through the impactor's explicit delivery. The rock mass still grows through the structure solve. The tracked-element total is refreshed from the conserved-plus-delivered budgets. Verified in a dummy coupled run: a dry 0.5 Earth-mass impactor leaves H, C, N, S, O and M_ele flat across the impact while the interior mass grows from 1.0 to 1.5 Earth masses. --- src/proteus/accretion/wrapper.py | 81 ++++++++++++++++++++++- tests/accretion/test_wrapper.py | 107 +++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index 3ec8cdbc3..3a62518f2 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -4,7 +4,7 @@ import logging from typing import TYPE_CHECKING -from proteus.utils.constants import M_earth +from proteus.utils.constants import M_earth, element_list, noble_gases if TYPE_CHECKING: from proteus.accretion.common import ImpactEvent @@ -12,6 +12,12 @@ log = logging.getLogger('fwl.' + __name__) +# Volatile and noble-gas elements whose whole-planet budgets are conserved +# across an impact's mass growth. The rock-forming elements (Si, Mg, Fe, Na) +# are not carried in the volatile budget; the planet's rock mass grows through +# the structure solve (mass_tot and the equation of state), not here. +_VOLATILE_ELEMENTS = ('H', 'O', 'C', 'N', 'S', *noble_gases) + def init_accretion(handler: Proteus) -> list[ImpactEvent]: """Prepare the impact timeline for a run. @@ -106,6 +112,14 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: event.mass_delta / M_earth, ) + # Snapshot the whole-planet volatile budgets before the structure re-solve. + # solve_structure recomputes the ppmw-mode budgets against the grown mass, + # which would let even a dry impactor inflate the volatile inventory as if + # the added rock carried the planet's volatile content. Volatiles are + # conserved across the mass growth and grow only through the explicit + # delivery below; the rock mass grows through the structure solve itself. + volatile_budgets = _snapshot_volatile_budgets(hf_row) + # Grow the planet by the impactor mass and re-solve the structure. mass_tot # is in Earth masses; the event's mass delta is the impactor mass in kg. config.planet.mass_tot += event.mass_delta / M_earth @@ -113,6 +127,10 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: handler.directories, config, handler.hf_all, hf_row, handler.directories['output'] ) + # Restore the conserved volatile budgets over the mass-scaled values the + # structure solve wrote, so the growth adds rock, not volatiles. + _restore_volatile_budgets(hf_row, volatile_budgets) + # Re-melt the mantle to its molten initial condition, so the interior # evolves from a fully molten state after the impact. remelt_mantle(handler.directories, config, hf_row, handler.interior_o, event) @@ -148,6 +166,11 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: # is left alone when nothing is delivered for it. _deliver_impactor_volatiles(config, hf_row, event.M_impactor) + # Refresh the tracked-element total from the conserved budgets plus any + # delivered volatiles. solve_structure set M_ele from the mass-scaled + # values it computed, which the restore above has overridden. + _refresh_tracked_element_total(hf_row) + def _deliver_impactor_volatiles(config, hf_row: dict, m_impactor: float) -> None: """Add the impactor's volatile content to the whole-planet element budgets. @@ -178,6 +201,62 @@ def _deliver_impactor_volatiles(config, hf_row: dict, m_impactor: float) -> None ) +def _snapshot_volatile_budgets(hf_row: dict) -> dict: + """Capture the whole-planet volatile element budgets [kg]. + + Parameters + ---------- + hf_row : dict + Current helpfile row. + + Returns + ------- + budgets : dict + Mapping of volatile element symbol to its ``_kg_total`` value [kg], + for the elements conserved across an impact's mass growth. Only the + elements that already carry a budget in the row are captured, so the + restore conserves what existed rather than fabricating zero-valued keys + for volatiles the run does not track. + """ + return { + e: float(hf_row[f'{e}_kg_total']) + for e in _VOLATILE_ELEMENTS + if f'{e}_kg_total' in hf_row + } + + +def _restore_volatile_budgets(hf_row: dict, budgets: dict) -> None: + """Write conserved volatile element budgets back into the helpfile row. + + Parameters + ---------- + hf_row : dict + Current helpfile row, mutated in place. + budgets : dict + Snapshot returned by :func:`_snapshot_volatile_budgets`. + """ + for element, kg in budgets.items(): + hf_row[f'{element}_kg_total'] = kg + + +def _refresh_tracked_element_total(hf_row: dict) -> None: + """Recompute the total tracked-element mass ``M_ele`` [kg]. + + Mirrors the aggregation in + :func:`proteus.outgas.wrapper.calc_target_elemental_inventories`, summing + every tracked element's ``_kg_total``. Called after the volatile budgets + are restored and the impactor delivery is added, so ``M_ele`` reflects the + conserved-plus-delivered inventory rather than the mass-scaled values the + structure solve produced. + + Parameters + ---------- + hf_row : dict + Current helpfile row, whose ``M_ele`` is updated in place. + """ + hf_row['M_ele'] = sum(float(hf_row.get(f'{e}_kg_total', 0.0)) for e in element_list) + + def _drop_events_before_start( events: list[ImpactEvent], time_start: float ) -> list[ImpactEvent]: diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index d030a77ad..09af74338 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -447,3 +447,110 @@ def test_a_dry_impactor_delivers_no_volatiles(monkeypatch): # The existing budget is unchanged and no new element key appears. assert handler.hf_row['H_kg_total'] == pytest.approx(3.0e20, rel=1e-12) assert not any(k.endswith('_kg_total') and k != 'H_kg_total' for k in handler.hf_row) + + +def _rescaling_solve_structure(factor): + """Mock of solve_structure that rescales the volatile budgets by ``factor``. + + The real structure solve calls calc_target_elemental_inventories, which for + ppmw-mode budgets recomputes ``_kg_total`` against the grown reservoir + mass, so a mass-growth impact multiplies every volatile budget by roughly + the mass-growth ratio and rewrites ``M_ele`` to match. This stand-in + reproduces that mass-scaling so the conservation contract can be exercised + without a live solver: a passing test must show the budgets are conserved + against exactly this rescaling, not merely left untouched by a no-op mock. + """ + + def _mock(dirs, config, hf_all, hf_row, outdir): + for key in list(hf_row): + if key.endswith('_kg_total'): + hf_row[key] *= factor + hf_row['M_ele'] = sum(v for k, v in hf_row.items() if k.endswith('_kg_total')) + + return _mock + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_mass_growth_conserves_volatiles_a_dry_impactor_creates_none(monkeypatch): + """Growing the planet with a dry impactor conserves the volatile budgets. + + The mass growth adds rock, not volatiles: a rock-dominated dry impactor + cannot manufacture hydrogen. The structure re-solve rescales the ppmw + budgets against the grown mass, so without conservation a dry impact would + inflate H, C, N, S in lockstep with the added mass. The impact must leave + every volatile budget at its pre-impact value. + """ + from proteus.accretion.wrapper import apply_impact + from proteus.utils.constants import M_earth + + # A 0.5 Earth-mass impactor on a 1.0 Earth-mass planet grows the reservoir + # by 1.5x, the factor by which the structure solve would rescale the ppmw + # budgets. Dry impactor: no delivery. + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', + _rescaling_solve_structure(1.5), + ) + + handler = _impact_handler(mass_tot=1.0) + handler.hf_row['H_kg_total'] = 4.0e22 + handler.hf_row['C_kg_total'] = 1.0e21 + handler.hf_row['O_kg_total'] = 8.0e22 + event = _impact_event( + M_target_before=6.0 * M_earth, + M_impactor=0.5 * M_earth, + M_merged_after=6.5 * M_earth, + ) + apply_impact(handler, event) + + # Every volatile budget is conserved at its pre-impact value. + assert handler.hf_row['H_kg_total'] == pytest.approx(4.0e22, rel=1e-12) + assert handler.hf_row['C_kg_total'] == pytest.approx(1.0e21, rel=1e-12) + assert handler.hf_row['O_kg_total'] == pytest.approx(8.0e22, rel=1e-12) + # Discrimination: the mass-scaled (unconserved) value is 1.5x larger, a 50% + # divergence far outside the 1e-12 tolerance. This is the value the row + # would carry if the restore were absent. + assert abs(handler.hf_row['H_kg_total'] - 4.0e22 * 1.5) > 1.0e22 + # M_ele reflects the conserved inventory, not the rescaled one. + assert handler.hf_row['M_ele'] == pytest.approx(4.0e22 + 1.0e21 + 8.0e22, rel=1e-12) + assert handler.hf_row['M_ele'] < 1.5 * (4.0e22 + 1.0e21 + 8.0e22) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_mass_growth_conserves_then_delivery_adds_only_the_delivered_mass(monkeypatch): + """Under mass growth the budget is the conserved base plus the delivery. + + With a wet impactor the two mechanisms compose: the mass growth conserves + the pre-impact inventory (it does not rescale it), and the delivery adds + exactly the impactor mass times its ppmw content on top. The final budget + must be base + delivered, never the mass-scaled base or the mass-scaled + base plus the delivery. + """ + from proteus.accretion.wrapper import apply_impact + from proteus.utils.constants import M_earth + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', + _rescaling_solve_structure(1.5), + ) + + handler = _impact_handler(mass_tot=1.0, accretion=_impact_accretion(H=1000.0)) + handler.hf_row['H_kg_total'] = 4.0e22 + m_impactor = 0.5 * M_earth + event = _impact_event( + M_target_before=6.0 * M_earth, + M_impactor=m_impactor, + M_merged_after=6.5 * M_earth, + ) + apply_impact(handler, event) + + delivered = m_impactor * 1000.0 / 1.0e6 + expected = 4.0e22 + delivered # conserved base + delivery + assert handler.hf_row['H_kg_total'] == pytest.approx(expected, rel=1e-12) + # Discrimination against the two wrong compositions: rescaled base (+50%) + # and rescaled base plus delivery both exceed the correct value by the + # 2.0e22 mass-scaling term, far outside tolerance. + assert abs(handler.hf_row['H_kg_total'] - (4.0e22 * 1.5)) > 1.0e22 + assert abs(handler.hf_row['H_kg_total'] - (4.0e22 * 1.5 + delivered)) > 1.0e22 + assert handler.hf_row['M_ele'] == pytest.approx(expected, rel=1e-12) From b038b10fd9fb92cbc0d3141a32e38154bb01bdc9 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 24 Jul 2026 10:07:29 +0200 Subject: [PATCH 11/71] Account the giant-impact re-melt heat in the energy budget A full mantle re-melt injects mantle-scale heat as an entropy jump between solver calls, which no per-call state integral carries, so the energy-conservation residual reported closure across an impact it never accounted for. The Aragog re-melt now books the injected heat into a step_dE_impact_J column, evaluated with the solver's own entropy-transported heat quadrature over the jump from the cooled to the molten profile, the same rho(P,S) T dS frame the residual integrates. The coupler adds the column to both sides of the budget: the state side gains the heat that was actually added and the predicted side gains the impact as an energy source, so the residual is invariant across an impact by construction and the injection is quantified in the helpfile instead of silently absorbed. When no pre-impact profile exists the booking is skipped with a warning rather than invented. The re-melt itself remains unconditional. Verified in an Aragog coupled run: a 0.5 Earth-mass impact books 1.944e30 J, the residual stays at 3.79e27 J across the impact row and settles at 8.1e27 J after, a shift of 0.2 percent of the injection and within the diagnostic's closure floor, where an unbooked injection would have shifted it by the full amount. The relative-residual column spikes on the impact row because the injection nearly cancels the cumulative state heat in its denominator; the absolute column is the diagnostic there. --- src/proteus/interior_energetics/aragog.py | 6 ++ src/proteus/interior_energetics/wrapper.py | 36 +++++++- src/proteus/utils/coupler.py | 33 ++++++- tests/interior_energetics/test_wrapper.py | 101 ++++++++++++++++++++- tests/utils/test_coupler.py | 70 ++++++++++++++ 5 files changed, 240 insertions(+), 6 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 332c2fdda..c4102c52e 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -2332,6 +2332,12 @@ def _build_helpfile_output( # the table-vs-phase density difference); machine-precision # conservation is the separate solver-residual column. 'step_dE_state_heat_J': out.step_dE_state_heat_J, + # Giant-impact re-melt heat [J]. Zeroed on every solve call so + # ordinary rows carry no impact energy; the accretion handler, + # which runs after this call on the iteration an impact lands, + # overwrites it with the heat the re-melt injects. The coupler + # adds it to both sides of the conservation budget. + 'step_dE_impact_J': 0.0, # Boundary layer thickness, taken straight from the atmosphere # config. Surfaced here so the helpfile carries a single # backend-agnostic field for downstream tooling that has to diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index af5e77715..c376b5c5b 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -1847,6 +1847,13 @@ def _remelt_aragog(config: Config, dirs: dict, hf_row: dict, interior_o) -> None the stale trajectory is cleared so the restore path cannot resurrect it, and the cached CMB-gradient state is cleared so it is re-derived from the molten profile rather than inherited from the cooled one. + + The heat the re-melt injects is booked into ``hf_row['step_dE_impact_J']`` + using the solver's own entropy-transported heat quadrature over the jump + from the cooled to the molten profile, the same ``rho(P,S) T dS`` frame the + conservation residual integrates. The coupler adds it to both sides of the + energy budget, so the residual stays closed across the impact while the + injected energy is quantified rather than silently absorbed. """ from proteus.interior_energetics.aragog import AragogRunner @@ -1857,6 +1864,13 @@ def _remelt_aragog(config: Config, dirs: dict, hf_row: dict, interior_o) -> None ) solver = interior_o.aragog_solver + + # Capture the cooled entropy profile before the reset replaces it; it is + # the start state of the heat-injection quadrature below. + S_cooled = getattr(interior_o, '_last_entropy', None) + if S_cooled is not None: + S_cooled = np.asarray(S_cooled, dtype=float).ravel().copy() + # Drop the cooled trajectory and its cached CMB gradient BEFORE rebuilding # the initial condition. This has to come first: _set_entropy_ic hot-starts # the boundary gradient from the solution when one is present, so clearing @@ -1871,7 +1885,27 @@ def _remelt_aragog(config: Config, dirs: dict, hf_row: dict, interior_o) -> None # from the return value rather than from the solver's solution object, which # holds no valid trajectory now and would in any case lag the reset. S_molten = AragogRunner._set_entropy_ic(config, interior_o, dirs['output'], hf_row) - interior_o._last_entropy = np.asarray(S_molten, dtype=float).ravel().copy() + S_molten = np.asarray(S_molten, dtype=float).ravel() + interior_o._last_entropy = S_molten.copy() + + # Book the injected heat over the cooled-to-molten entropy jump, in the + # residual's own frame: the solver's Σ V_i ∫ rho(P_i,S) T(P_i,S) dS + # quadrature evaluated between the two profiles. Positive when the re-melt + # heats the mantle. The jump falls between solver calls, so no per-call + # state integral carries it; this column is how it enters the budget. + if S_cooled is not None and S_cooled.size > 0: + dE_impact = float(solver._step_heat_content(S_cooled, S_molten)) + hf_row['step_dE_impact_J'] = dE_impact + log.info(' re-melt heat injection %.3e J booked into the energy budget', dE_impact) + else: + # No prior profile to measure the jump from (no completed solve has + # stored one). The injection cannot be quantified, so it is left + # unbooked and said so, rather than booking a silent zero. + hf_row['step_dE_impact_J'] = 0.0 + log.warning( + ' re-melt heat injection not booked: no pre-impact entropy ' + 'profile is available to measure the jump from' + ) log.info(' mantle re-melted: Aragog entropy reset to the molten initial condition') diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index 25e260b52..0a4e04073 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -688,10 +688,18 @@ def GetHelpfileKeys(): # cumulative residual. The residual pairs the entropy-transported # heat (state side) against the boundary-flux and source prediction # (predicted side), both in the live EOS density frame ``ρ(P,S)``: - # E_state_heat_cons_J = Σ step_dE_state_heat_J across rows [J] + # E_state_heat_cons_J = Σ (step_dE_state_heat_J + step_dE_impact_J) # dE_predicted_cons_J = Σ (step_dE_F_int_J + step_dE_F_cmb_J - # + step_dE_Q_radio_J + step_dE_Q_tidal_J) + # + step_dE_Q_radio_J + step_dE_Q_tidal_J + # + step_dE_impact_J) # E_residual_cons_J = E_state_heat_cons_J - dE_predicted_cons_J + # ``step_dE_impact_J`` is the heat a giant-impact mantle re-melt + # injects, evaluated in the same ρ(P,S)·T·dS frame over the + # entropy jump from the cooled to the molten profile. It enters + # BOTH cumulatives: the state side because the jump falls between + # solver calls so no per-call state integral carries it, and the + # predicted side because the impact is an energy source. The + # residual is therefore invariant across an impact by construction. # E_residual_cons_frac = E_residual_cons_J / max(|E_state_heat_cons_J|, 1 J) # This closes to about a percent of the cumulative cooling (largest # near full melt and at crystallisation-front / structure-remesh @@ -736,6 +744,7 @@ def GetHelpfileKeys(): 'step_solver_residual_J', # per-call entropy-ODE LHS-RHS [J] 'step_dE_compression_J', # per-call structure-re-solve compression work [J] (diagnostic) 'step_dE_state_heat_J', # per-call entropy-transported heat content change [J] + 'step_dE_impact_J', # giant-impact re-melt heat injection [J] (both residual sides) 'E_state_heat_cons_J', # cumulative sum of step_dE_state_heat_J across rows [J] 'dE_predicted_cons_J', # cumulative sum of boundary fluxes + live-density step_dE_Q_*_J [J] 'E_residual_cons_J', # E_state_heat_cons_J - dE_predicted_cons_J [J] @@ -892,6 +901,15 @@ def _populate_energy_residual(current_hf: pd.DataFrame, new_row: dict) -> None: step_dE_state_heat_J = ∫ Σ rho T dS over the call [J]. + A giant-impact mantle re-melt contributes ``step_dE_impact_J``, the + heat the re-melt injects evaluated in the same ``rho T dS`` frame + over the entropy jump from the cooled to the molten profile. It is + added to BOTH cumulatives: to the state side because the jump falls + between solver calls, so no per-call state integral carries it, and + to the predicted side because the impact is an energy source. The + residual is therefore invariant across an impact by construction, + and the injected energy is booked rather than silently absorbed. + The heating sources use the live-density (state-mass) Q variants so they share the ``rho(P,S)`` frame the state side integrates; the frozen-mass ``step_dE_Q_*_cons_J`` variants are not summed here. @@ -956,15 +974,22 @@ def _populate_energy_residual(current_hf: pd.DataFrame, new_row: dict) -> None: # fluxes are area-weighted and frame-independent. The compression term # is informational and is deliberately excluded: the state side carries # the full thermodynamic content via Σ rho T dS. + # Giant-impact re-melt heat [J], zero on rows without an impact. Enters + # both increments below so the residual stays closed across an impact + # while the injection is booked on both sides of the budget. + dE_impact_inc = float(new_row.get('step_dE_impact_J', 0.0)) + dE_inc_cons = ( float(new_row.get('step_dE_F_int_J', 0.0)) + float(new_row.get('step_dE_F_cmb_J', 0.0)) + float(new_row.get('step_dE_Q_radio_J', 0.0)) + float(new_row.get('step_dE_Q_tidal_J', 0.0)) + + dE_impact_inc ) # State increment [J]: the entropy-transported heat content change over - # the call, Σ rho T dS by EOS quadrature (step_dE_state_heat_J). - dE_state_heat_inc = float(new_row.get('step_dE_state_heat_J', 0.0)) + # the call, Σ rho T dS by EOS quadrature (step_dE_state_heat_J), plus + # the impact re-melt jump the per-call integral cannot see. + dE_state_heat_inc = float(new_row.get('step_dE_state_heat_J', 0.0)) + dE_impact_inc solver_inc = float(new_row.get('step_solver_residual_J', 0.0)) n_prior = len(current_hf) diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index 13a4581aa..33ab01932 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -5759,8 +5759,17 @@ class _FakeAragogSolver: reads ``entropy_staggered`` after clearing ``_solution`` would raise here, exactly as it would on the real solver, so a return-to-``entropy_staggered`` regression cannot pass this test. + + ``_step_heat_content`` mirrors the real quadrature's contract: it takes the + start and end entropy profiles, is antisymmetric in their order (heating is + positive), and returns a float. The linear stand-in keeps the sign and + argument-order semantics that the booking test discriminates on. """ + # J per (J/kg/K) of summed entropy rise; linear stand-in for the + # rho*T*V quadrature weight, sized so a profile swap is unmissable. + _HEAT_PER_ENTROPY = 2.0e27 + class _Solution: def __init__(self, profile): self.y = np.asarray(profile, dtype=float).reshape(-1, 1) @@ -5769,6 +5778,7 @@ def __init__(self, cooled_profile): self._S0 = np.asarray(cooled_profile, dtype=float).copy() self._solution = self._Solution(cooled_profile) # a stale (cooled) trajectory self._dSdr_cmb_init = 1.234e-6 # stale CMB gradient from the cooled solve + self.heat_calls = [] # (S0, Sf) pairs _step_heat_content was asked for @property def entropy_staggered(self): @@ -5779,6 +5789,14 @@ def set_initial_entropy(self, S): # The real method writes only _S0; it does NOT update entropy_staggered. self._S0 = np.asarray(S, dtype=float).copy() + def _step_heat_content(self, S0_stag, Sf_stag, n_quad: int = 16) -> float: + # Positive when Sf > S0 (heating), negative when the caller swaps the + # order: the same antisymmetry as the real trapezoid over rho*T dS. + S0 = np.asarray(S0_stag, dtype=float).ravel() + Sf = np.asarray(Sf_stag, dtype=float).ravel() + self.heat_calls.append((S0.copy(), Sf.copy())) + return float(np.sum(Sf - S0) * self._HEAT_PER_ENTROPY) + @pytest.mark.unit def test_aragog_remelt_carries_the_molten_profile_past_the_next_restore(): @@ -5808,11 +5826,12 @@ def _fake_set_ic(cfg, io, outdir, hf_row): io.aragog_solver.set_initial_entropy(molten) return molten + hf_row = {} with patch( 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', side_effect=_fake_set_ic, ): - remelt_mantle({'output': '/tmp/out'}, config, hf_row={}, interior_o=interior_o) + remelt_mantle({'output': '/tmp/out'}, config, hf_row=hf_row, interior_o=interior_o) # The restore carrier now holds the molten profile, not the cooled one. np.testing.assert_allclose(interior_o._last_entropy, molten) @@ -5827,6 +5846,86 @@ def _fake_set_ic(cfg, io, outdir, hf_row): np.testing.assert_allclose(solver._S0, molten) assert interior_o.impact_reset is True + # The injected heat is booked, positive for a heating re-melt. + assert hf_row['step_dE_impact_J'] > 0.0 + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_aragog_remelt_books_the_injected_heat_over_the_cooled_to_molten_jump(): + """The booked impact heat is the quadrature from the cooled to the molten state. + + The energy the re-melt injects is the entropy-transported heat over the + jump from the pre-impact cooled profile to the molten initial condition, + evaluated by the solver's own quadrature. Booking must pass the profiles in + that order: the re-melt heats the mantle, so the booked energy is positive, + and a swapped argument order would negate it. The cooled start state must + be the profile held BEFORE the reset, not the molten one the reset writes + onto the restore carrier. + """ + n = 6 + cooled = np.full(n, 2400.0) + molten = np.full(n, 3900.0) + solver = _FakeAragogSolver(cooled_profile=cooled) + interior_o = SimpleNamespace( + aragog_solver=solver, _last_entropy=cooled.copy(), impact_reset=False + ) + config = _remelt_config('aragog') + hf_row = {} + + with patch( + 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', + return_value=molten, + ): + remelt_mantle({'output': '/tmp/out'}, config, hf_row=hf_row, interior_o=interior_o) + + # Exactly one quadrature, from the cooled profile to the molten one. + assert len(solver.heat_calls) == 1 + S0_seen, Sf_seen = solver.heat_calls[0] + np.testing.assert_allclose(S0_seen, cooled) + np.testing.assert_allclose(Sf_seen, molten) + + # The booked value is the quadrature of the jump: n cells x 1500 J/kg/K + # rise at the fake's weight. Positive because the re-melt heats. + expected = n * (3900.0 - 2400.0) * _FakeAragogSolver._HEAT_PER_ENTROPY + assert hf_row['step_dE_impact_J'] == pytest.approx(expected, rel=1e-12) + # Discrimination: a swapped argument order (molten -> cooled) would book + # the negated value, 2x the expected magnitude away, far outside tolerance. + assert abs(hf_row['step_dE_impact_J'] - (-expected)) > expected + + +@pytest.mark.unit +def test_aragog_remelt_without_a_prior_profile_warns_and_books_nothing(caplog): + """With no pre-impact profile the injection is unquantifiable and says so. + + When no completed solve has stored an entropy profile, there is no start + state to measure the jump from. The re-melt must still proceed, but the + booking is left at zero with a warning, rather than inventing a value or + failing the impact. + """ + import logging + + molten = np.full(6, 3900.0) + solver = _FakeAragogSolver(cooled_profile=np.full(6, 2400.0)) + interior_o = SimpleNamespace(aragog_solver=solver, _last_entropy=None, impact_reset=False) + config = _remelt_config('aragog') + hf_row = {} + + with patch( + 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', + return_value=molten, + ): + with caplog.at_level(logging.WARNING, logger='fwl.proteus.interior_energetics'): + remelt_mantle({'output': '/tmp/out'}, config, hf_row=hf_row, interior_o=interior_o) + + # Nothing booked, no quadrature attempted, and the gap is announced. + assert hf_row['step_dE_impact_J'] == 0.0 + assert len(solver.heat_calls) == 0 + assert 'not booked' in '\n'.join(r.getMessage() for r in caplog.records) + # The re-melt itself still completed: the carrier holds the molten profile. + np.testing.assert_allclose(interior_o._last_entropy, molten) + assert interior_o.impact_reset is True + @pytest.mark.unit def test_remelt_refuses_spider_and_rejects_an_unknown_backend(): diff --git a/tests/utils/test_coupler.py b/tests/utils/test_coupler.py index f035352cd..79b7158e8 100644 --- a/tests/utils/test_coupler.py +++ b/tests/utils/test_coupler.py @@ -1175,6 +1175,7 @@ def _aragog_row( step_dE_Q_tidal_J: float = 0.0, step_solver_residual_J: float = 0.0, step_dE_state_heat_J: float = 0.0, + step_dE_impact_J: float = 0.0, F_cmb: float = 0.0, R_int: float = 6.371e6, R_core: float = 3.481e6, @@ -1198,6 +1199,7 @@ def _aragog_row( row['step_dE_Q_tidal_J'] = step_dE_Q_tidal_J row['step_solver_residual_J'] = step_solver_residual_J row['step_dE_state_heat_J'] = step_dE_state_heat_J + row['step_dE_impact_J'] = step_dE_impact_J row['F_cmb'] = F_cmb row['R_int'] = R_int row['R_core'] = R_core @@ -1221,6 +1223,8 @@ def test_helpfile_keys_include_energy_conservation_columns(): 'step_solver_residual_J', # State-side primitive: the entropy-transported heat content change. 'step_dE_state_heat_J', + # Giant-impact re-melt heat injection (enters both residual sides). + 'step_dE_impact_J', # Cumulative columns derived from the primitives above. 'E_state_heat_cons_J', 'dE_predicted_cons_J', @@ -1559,6 +1563,72 @@ def test_populate_energy_residual_predicted_uses_live_mass_heating(): assert abs(row1['E_residual_cons_J']) < 1e-3 * abs(live_radio) +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_populate_energy_residual_is_invariant_across_a_giant_impact(): + """A giant-impact re-melt is booked on both sides, leaving the residual closed. + + The re-melt injects mantle-scale heat as an entropy jump between solver + calls, so no per-call state integral carries it. The booking enters the + impact heat on BOTH cumulatives: the state side gains the heat that was + actually added, the predicted side gains the impact as an energy source, + and the residual is unchanged across the impact. A one-sided booking would + shift the residual by the full injection, which dwarfs every physical + increment here, so closure is the discriminating signature. + """ + E0 = 1.0e31 + row0 = _aragog_row(time_yr=0.0, E_state_cons_J=E0) + hf = CreateHelpfileFromDict(row0) + + # Ordinary cooling step before the impact. + cool = -2.0e29 + row1 = _aragog_row( + time_yr=10.0, + E_state_cons_J=E0 + cool, + step_dE_F_int_J=cool, + step_dE_state_heat_J=cool, + ) + _populate_energy_residual(hf, row1) + hf = ExtendHelpfile(hf, row1) + residual_before = row1['E_residual_cons_J'] + + # Impact row: the solve itself cooled a little more, then the re-melt + # injected mantle-scale heat (two orders above the step increments). + dE_impact = +5.0e30 + row2 = _aragog_row( + time_yr=20.0, + E_state_cons_J=E0 + 2 * cool + dE_impact, + step_dE_F_int_J=cool, + step_dE_state_heat_J=cool, + step_dE_impact_J=dE_impact, + ) + _populate_energy_residual(hf, row2) + + # Both cumulatives carry the injection. + assert row2['dE_predicted_cons_J'] == pytest.approx(2 * cool + dE_impact, rel=1e-12) + assert row2['E_state_heat_cons_J'] == pytest.approx(2 * cool + dE_impact, rel=1e-12) + # The residual is invariant across the impact: booked, not leaked. + assert row2['E_residual_cons_J'] == pytest.approx(residual_before, abs=1e-3 * abs(cool)) + # Discrimination: booking on only one side would shift the residual by the + # full 5e30 J injection, twenty-five times the physical step increment. + assert abs(dE_impact) > 20 * abs(cool) + + # Boundary case: a zero-impact row must reduce to the ordinary bookkeeping, + # so the column's default cannot perturb quiet steps. + row3 = _aragog_row( + time_yr=30.0, + E_state_cons_J=E0 + 3 * cool + dE_impact, + step_dE_F_int_J=cool, + step_dE_state_heat_J=cool, + step_dE_impact_J=0.0, + ) + hf = ExtendHelpfile(hf, row2) + _populate_energy_residual(hf, row3) + assert row3['E_residual_cons_J'] == pytest.approx( + row2['E_residual_cons_J'], abs=1e-3 * abs(cool) + ) + + @pytest.mark.unit def test_get_proteus_directories_has_required_keys(): """Sanity: the directory dict exposes the keys the runtime depends on.""" From ea93d1ce8cd281647415b8d9a84a848c8d86b0d3 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 24 Jul 2026 10:07:44 +0200 Subject: [PATCH 12/71] Strip part of the atmosphere at each giant impact A giant impact blows off part of the atmosphere the planet already holds, a loss channel the accretion coupling did not yet model. Each impact now removes a fraction of the atmosphere, debited through the same path continuous escape uses: the lost mass is partitioned over the elements in proportion to their atmospheric reservoirs and subtracted from the whole-planet budgets, so the per-element loss can never exceed what the atmosphere holds and the dissolved interior inventory is untouched. The stripping acts on the pre-impact atmosphere before the impactor's volatiles are delivered, and the removed mass is added to the cumulative escaped-mass ledger so a planet dried out partly by impacts still passes the desiccation audit. calc_new_elements accepts an explicit mass for this, partitioning an impulsive loss exactly like the rate integral. The loss fraction comes from accretion.atmloss_module, disabled by default; the constant module applies a fixed accretion.atmloss_frac and stands in for a coming ZEPHYRUS collision-loss law with the same call shape, so PROTEUS itself ships no impact loss physics. A loss module returning a fraction outside zero to one is rejected rather than clamped. --- src/proteus/accretion/wrapper.py | 111 ++++++++++++++ src/proteus/config/_accretion.py | 20 ++- src/proteus/escape/wrapper.py | 14 +- tests/accretion/test_wrapper.py | 162 +++++++++++++++++++- tests/escape/test_wrapper.py | 49 ++++++ tests/tools/test_migrate_config_v2_to_v3.py | 9 +- 6 files changed, 356 insertions(+), 9 deletions(-) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index 3a62518f2..15db976a9 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -157,6 +157,13 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: config.orbit.eccentricity, ) + # Strip part of the pre-impact atmosphere, before the impactor's own + # volatiles are delivered: the shock blows off what the planet already + # has, while the delivery goes into the molten post-impact planet. The + # outgassing step later this iteration re-equilibrates the atmosphere + # against the debited totals. + _strip_impact_atmosphere(config, hf_row, event) + # Deliver the impactor's volatiles into the whole-planet element budgets, # so the outgassing step later this iteration re-equilibrates with them. The # amount per element is the impactor mass times the configured content in @@ -201,6 +208,110 @@ def _deliver_impactor_volatiles(config, hf_row: dict, m_impactor: float) -> None ) +def _impact_loss_fraction(config, hf_row: dict, event: ImpactEvent) -> float: + """Fraction of the atmosphere removed by this impact [0-1]. + + Dispatches on ``accretion.atmloss_module``. The constant module returns + the configured fixed fraction and stands in for the coming ZEPHYRUS + collision-loss law, which will compute the fraction from the impact + parameters this function already receives; PROTEUS itself deliberately + ships no impact loss physics. + + Parameters + ---------- + config : Config + Model configuration; reads ``accretion.atmloss_module`` and + ``accretion.atmloss_frac``. + hf_row : dict + Current helpfile row (the planet state a loss law reads). + event : ImpactEvent + The impact being applied (the collision parameters a loss law reads). + + Returns + ------- + float + Loss fraction in [0, 1]. Zero when the loss is disabled. + + Raises + ------ + ValueError + If a loss module returns a fraction outside [0, 1]. The debit + partitioning is only meaningful on that interval, so a provider + violating it is a contract error, not a value to clamp silently. + """ + module = config.accretion.atmloss_module + if module is None: + return 0.0 + + match module: + case 'constant': + f_loss = float(config.accretion.atmloss_frac) + case _: + raise ValueError(f"Invalid accretion.atmloss_module: '{module}'") + + if not 0.0 <= f_loss <= 1.0: + raise ValueError( + f'Impact atmosphere loss fraction must be in [0, 1], got {f_loss!r} ' + f"from atmloss_module '{module}'" + ) + return f_loss + + +def _strip_impact_atmosphere(config, hf_row: dict, event: ImpactEvent) -> None: + """Remove part of the atmosphere at an impact and debit the element budgets. + + The lost mass is the loss fraction times the atmospheric reservoir, and is + partitioned over the elements in proportion to their atmospheric masses + through the same path continuous escape uses, so the per-element loss can + never exceed what the atmosphere holds and the dissolved interior inventory + is untouched. The debited mass is added to the cumulative escaped-mass + ledger, which the desiccation gate audits: without that booking, a planet + dried out partly by impacts would be refused desiccation later. + + Parameters + ---------- + config : Config + Model configuration. + hf_row : dict + Current helpfile row, mutated in place. + event : ImpactEvent + The impact being applied. + """ + from proteus.escape.wrapper import calc_new_elements + + f_loss = _impact_loss_fraction(config, hf_row, event) + if f_loss <= 0.0: + return + + # Atmospheric reservoir mass, summed the same way the debit partitions it. + m_atm = sum(float(hf_row.get(f'{e}_kg_atm', 0.0)) for e in element_list) + if m_atm <= 0.0: + log.info(' impact atmosphere loss: no atmosphere to strip') + return + + before = {e: float(hf_row.get(f'{e}_kg_total', 0.0)) for e in element_list} + tgt = calc_new_elements( + hf_row, + dt=0.0, + reservoir='outgas', + min_thresh=config.outgas.mass_thresh, + esc_mass=f_loss * m_atm, + ) + for e, mass in tgt.items(): + hf_row[f'{e}_kg_total'] = mass + + # Book the actually-debited mass (including any desiccation-floor + # truncation) into the escaped-mass ledger the desiccation gate audits. + lost = sum(before[e] - float(tgt.get(e, before[e])) for e in before) + hf_row['esc_kg_cumulative'] = float(hf_row.get('esc_kg_cumulative', 0.0)) + lost + + log.info( + ' impact stripped %.1f%% of the atmosphere: %.3e kg removed', + 100.0 * f_loss, + lost, + ) + + def _snapshot_volatile_budgets(hf_row: dict) -> dict: """Capture the whole-planet volatile element budgets [kg]. diff --git a/src/proteus/config/_accretion.py b/src/proteus/config/_accretion.py index 92b22a379..fb5d3f4b7 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -1,6 +1,6 @@ from __future__ import annotations -from attr.validators import ge, gt, in_ +from attr.validators import ge, gt, in_, le from attrs import define, field from ._converters import none_if_none @@ -164,6 +164,15 @@ class Accretion: Sulfur carried by each impactor [ppmw of impactor mass]. impactor_O_ppmw: float Oxygen carried by each impactor [ppmw of impactor mass]. + atmloss_module: str or None + How the fraction of atmosphere lost to each impact is computed. + Choices: None (no impact atmosphere loss), "constant" (the fixed + fraction below). A ZEPHYRUS collision-loss law will become a + further choice when available; PROTEUS itself ships no impact + loss physics. + atmloss_frac: float + Fraction of the atmosphere removed by each impact when + ``atmloss_module = "constant"`` [0-1]. """ module: str | None = field( @@ -186,6 +195,15 @@ class Accretion: impactor_S_ppmw: float = field(default=0.0, validator=ge(0)) impactor_O_ppmw: float = field(default=0.0, validator=ge(0)) + # Impact atmosphere loss. Disabled by default; the constant module is a + # placeholder with the call shape of the coming ZEPHYRUS collision law. + atmloss_module: str | None = field( + default='none', + validator=in_((None, 'constant')), + converter=none_if_none, + ) + atmloss_frac: float = field(default=0.0, validator=[ge(0), le(1)]) + @property def delivers_volatiles(self) -> bool: """Does any impactor volatile budget exceed zero?""" diff --git a/src/proteus/escape/wrapper.py b/src/proteus/escape/wrapper.py index 46a994c14..ef9f1f2e5 100644 --- a/src/proteus/escape/wrapper.py +++ b/src/proteus/escape/wrapper.py @@ -259,6 +259,7 @@ def calc_new_elements( dt: float, reservoir: str, min_thresh: float = 1e10, + esc_mass: float | None = None, ): """Calculate new elemental inventory based on escape rate. @@ -267,9 +268,15 @@ def calc_new_elements( hf_row : dict Dictionary of helpfile variables, at this iteration only dt : float - Time-step length [years] + Time-step length [years]. Ignored when ``esc_mass`` is given. min_thresh: float Minimum threshold for element mass [kg]. Inventories below this are set to zero. + esc_mass : float, optional + Total mass to remove [kg]. When given, this mass is partitioned + over the reservoir instead of the rate-times-timestep integral; + an impulsive loss (a giant impact stripping the atmosphere) is + debited through the same proportional partitioning, desiccation + floor, and noble-gas exemption as continuous escape. Returns ------- @@ -305,8 +312,9 @@ def calc_new_elements( # compute mass ratios in escaping reservoir emr = {e: (res[e] / M_vols if M_vols > 0 else 0.0) for e in res} - # total escaped mass over dt [kg] - esc_mass = float(hf_row.get('esc_rate_total', 0.0)) * secs_per_year * float(dt) + # total escaped mass [kg]: explicit when given, else the rate integral over dt + if esc_mass is None: + esc_mass = float(hf_row.get('esc_rate_total', 0.0)) * secs_per_year * float(dt) # compute new TOTAL inventories tgt: dict[str, float] = {} diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 09af74338..15dce1079 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -201,14 +201,16 @@ def _impact_event(**overrides): return ImpactEvent(**base) -def _impact_accretion(**ppmw): - """Accretion sub-config with impactor volatile contents (default dry).""" +def _impact_accretion(atmloss_module=None, atmloss_frac=0.0, **ppmw): + """Accretion sub-config: impactor volatiles and atmosphere loss (default off).""" return SimpleNamespace( impactor_H_ppmw=ppmw.get('H', 0.0), impactor_C_ppmw=ppmw.get('C', 0.0), impactor_N_ppmw=ppmw.get('N', 0.0), impactor_S_ppmw=ppmw.get('S', 0.0), impactor_O_ppmw=ppmw.get('O', 0.0), + atmloss_module=atmloss_module, + atmloss_frac=atmloss_frac, ) @@ -449,6 +451,162 @@ def test_a_dry_impactor_delivers_no_volatiles(monkeypatch): assert not any(k.endswith('_kg_total') and k != 'H_kg_total' for k in handler.hf_row) +def _atm_state(hf_row, **kg): + """Write an atmospheric composition: per-element atm and total budgets. + + Each keyword is an element symbol mapped to ``(kg_atm, kg_total)`` so a + test can set up asymmetric atmospheric and dissolved reservoirs. + """ + for e, (atm, total) in kg.items(): + hf_row[f'{e}_kg_atm'] = atm + hf_row[f'{e}_kg_total'] = total + + +@pytest.mark.unit +def test_impact_atmosphere_loss_is_off_by_default(monkeypatch): + """Without an atmosphere-loss module the impact leaves the atmosphere alone. + + Every existing accretion configuration predates impact atmosphere loss, so + the default must be a strict no-op: no element budget moves and the + escaped-mass ledger is untouched, even for a violent impact on a planet + with a massive atmosphere. + """ + from proteus.accretion.wrapper import apply_impact + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler() # atmloss_module=None + _atm_state(handler.hf_row, H=(2.0e20, 5.0e20), N=(1.0e19, 4.0e19)) + handler.hf_row['esc_kg_cumulative'] = 7.0e18 + apply_impact(handler, _impact_event()) + + assert handler.hf_row['H_kg_total'] == pytest.approx(5.0e20, rel=1e-12) + assert handler.hf_row['N_kg_total'] == pytest.approx(4.0e19, rel=1e-12) + assert handler.hf_row['esc_kg_cumulative'] == pytest.approx(7.0e18, rel=1e-12) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_impact_strips_the_atmosphere_in_proportion_to_its_composition(monkeypatch): + """The stripped mass is drawn from the atmosphere, element by element. + + A constant 25% loss removes exactly a quarter of each element's + ATMOSPHERIC reservoir from its whole-planet budget: the dissolved interior + inventory is untouched, so an element that is mostly dissolved loses far + less of its total than one that is mostly atmospheric. Partitioning by the + total budgets instead would shift mass between the two, which the + asymmetric reservoirs here are chosen to expose. The removed mass is + booked into the escaped-mass ledger the desiccation gate audits. + """ + from proteus.accretion.wrapper import apply_impact + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler( + accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=0.25) + ) + handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) + # H is mostly atmospheric; N is mostly dissolved. A total-budget + # partitioning would debit N nearly 4x more than the atmosphere holds. + _atm_state(handler.hf_row, H=(4.0e20, 5.0e20), N=(1.0e19, 4.0e20)) + apply_impact(handler, _impact_event()) + + # Each element loses a quarter of its ATMOSPHERIC mass from the total. + assert handler.hf_row['H_kg_total'] == pytest.approx(5.0e20 - 0.25 * 4.0e20, rel=1e-9) + assert handler.hf_row['N_kg_total'] == pytest.approx(4.0e20 - 0.25 * 1.0e19, rel=1e-9) + # Discrimination: partitioning over the equal TOTAL budgets would debit + # both elements identically (0.25 * 0.5 * (4e20 + 1e19) each ~ 5.1e19), + # putting N at ~3.49e20, more than 5e18 away from the correct 3.975e20. + assert abs(handler.hf_row['N_kg_total'] - 3.4875e20) > 4.0e18 + + # The debit never exceeds what the atmosphere held. + assert handler.hf_row['H_kg_total'] >= 5.0e20 - 4.0e20 + assert handler.hf_row['N_kg_total'] >= 4.0e20 - 1.0e19 + + # The stripped mass is booked for the desiccation ledger. + assert handler.hf_row['esc_kg_cumulative'] == pytest.approx( + 0.25 * (4.0e20 + 1.0e19), rel=1e-9 + ) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_total_impact_loss_removes_the_atmosphere_but_not_the_interior(monkeypatch): + """A loss fraction of one is the boundary: the atmosphere goes, no more. + + Full stripping removes each element's atmospheric reservoir exactly, so + the dissolved inventory survives and no budget goes negative. Loss beyond + the atmosphere is unphysical, and the ledger booking equals the + atmosphere's whole mass. + """ + from proteus.accretion.wrapper import apply_impact + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler( + accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=1.0) + ) + handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) + _atm_state(handler.hf_row, H=(4.0e20, 5.0e20), C=(2.0e19, 9.0e19)) + apply_impact(handler, _impact_event()) + + # The dissolved part survives complete atmospheric stripping. + assert handler.hf_row['H_kg_total'] == pytest.approx(1.0e20, rel=1e-9) + assert handler.hf_row['C_kg_total'] == pytest.approx(7.0e19, rel=1e-9) + assert handler.hf_row['H_kg_total'] >= 0.0 + assert handler.hf_row['C_kg_total'] >= 0.0 + assert handler.hf_row['esc_kg_cumulative'] == pytest.approx(4.2e20, rel=1e-9) + + +@pytest.mark.unit +def test_impact_loss_composes_with_delivery_and_a_broken_provider_raises(monkeypatch): + """Stripping acts on the pre-impact atmosphere, then delivery adds on top. + + The loss and the delivery are independent physical channels of one impact: + the shock strips what the planet had, the impactor's volatiles arrive + regardless. The final budget is (total - stripped) + delivered. A loss + module returning a fraction outside [0, 1] violates the partitioning + contract and must raise rather than be clamped in silence. + """ + from proteus.accretion.wrapper import _impact_loss_fraction, apply_impact + from proteus.utils.constants import M_earth + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler( + accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=0.5, H=1000.0) + ) + handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) + _atm_state(handler.hf_row, H=(2.0e20, 6.0e20)) + m_impactor = 0.5 * M_earth + apply_impact(handler, _impact_event(M_impactor=m_impactor)) + + delivered = m_impactor * 1000.0 / 1.0e6 + expected = (6.0e20 - 0.5 * 2.0e20) + delivered + assert handler.hf_row['H_kg_total'] == pytest.approx(expected, rel=1e-9) + # Both channels are present at full size: the stripped 1e20 and the + # delivered 2.986e21 are each far larger than the tolerance, so a missing + # channel cannot pass. The tracked-element total reflects the composition. + assert handler.hf_row['M_ele'] == pytest.approx(expected, rel=1e-9) + assert abs(handler.hf_row['H_kg_total'] - 6.0e20) > 1.0e20 # not "no strip, no delivery" + assert abs(handler.hf_row['H_kg_total'] - (6.0e20 + delivered)) > 5.0e19 # not "no strip" + + # A provider outside the contract is rejected loudly. + bad = _impact_handler( + accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=1.5) + ) + with pytest.raises(ValueError, match=r'\[0, 1\]'): + _impact_loss_fraction(bad.config, bad.hf_row, _impact_event()) + + def _rescaling_solve_structure(factor): """Mock of solve_structure that rescales the volatile budgets by ``factor``. diff --git a/tests/escape/test_wrapper.py b/tests/escape/test_wrapper.py index 3345f36aa..9f6701b53 100644 --- a/tests/escape/test_wrapper.py +++ b/tests/escape/test_wrapper.py @@ -734,6 +734,55 @@ def test_calc_new_elements_outgas_reservoir(): assert tgt['S'] < hf_row['S_kg_total'] +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_calc_new_elements_explicit_mass_overrides_the_rate_integral(): + """An explicit mass debits exactly that mass, ignoring rate and timestep. + + An impulsive loss (a giant impact stripping the atmosphere) hands the + total mass to remove directly. The rate-times-timestep integral must play + no part: the row carries a deliberately absurd escape rate whose integral + over dt would strip 300x more, so any leakage of the rate path into the + debit is unmissable. The explicit mass partitions proportionally, and + omitting it (the default) must reproduce the rate-integral behaviour + unchanged. + """ + from proteus.escape.wrapper import calc_new_elements + from proteus.utils.constants import secs_per_year + + def _row(): + return { + 'esc_rate_total': 1e8, # absurd rate; must be IGNORED when mass is given + 'H_kg_total': 8.0e20, + 'C_kg_total': 2.0e20, + 'H_kg_atm': 4.0e20, + 'C_kg_atm': 1.0e20, + } + + dt = 1000.0 + explicit = 1.0e20 # 1/5 of the 5e20 kg atmosphere + + tgt = calc_new_elements(_row(), dt, 'outgas', min_thresh=1e10, esc_mass=explicit) + + # Exactly the explicit mass leaves, split 4:1 by atmospheric composition. + assert tgt['H'] == pytest.approx(8.0e20 - 0.8e20, rel=1e-9) + assert tgt['C'] == pytest.approx(2.0e20 - 0.2e20, rel=1e-9) + total_lost = (8.0e20 + 2.0e20) - (tgt['H'] + tgt['C']) + assert total_lost == pytest.approx(explicit, rel=1e-9) + # Discrimination: the rate integral over dt (1e8 kg/s * 3.156e7 s/yr * + # 1000 yr = 3.16e18 kg) is 30x smaller than the explicit mass, so the + # exact-equality checks above could not pass had the rate path leaked in. + rate_mass = 1e8 * secs_per_year * dt + assert abs(rate_mass - explicit) > 0.5 * explicit + + # Back-compat: with esc_mass omitted the rate integral governs as before. + row2 = _row() + row2['esc_rate_total'] = explicit / (secs_per_year * dt) # same mass via the rate + tgt2 = calc_new_elements(row2, dt, 'outgas', min_thresh=1e10) + assert tgt2['H'] == pytest.approx(tgt['H'], rel=1e-9) + assert tgt2['C'] == pytest.approx(tgt['C'], rel=1e-9) + + @pytest.mark.unit def test_calc_new_elements_below_threshold(): """Test elemental inventory when mass falls below minimum threshold. diff --git a/tests/tools/test_migrate_config_v2_to_v3.py b/tests/tools/test_migrate_config_v2_to_v3.py index 4436e792e..375ddb57c 100644 --- a/tests/tools/test_migrate_config_v2_to_v3.py +++ b/tests/tools/test_migrate_config_v2_to_v3.py @@ -79,9 +79,12 @@ def _v3(): _REVIEWED_NEUTRAL = frozenset( { # Accretion is off by default and every impactor budget starts at - # zero, so a migrated config experiences no impacts and no delivery. - # The morrigan and dummy sub-blocks are only read once their backend - # is selected, which migration never does. + # zero, so a migrated config experiences no impacts, no delivery, + # and no impact atmosphere loss (atmloss_module is off and its + # fraction is zero). The morrigan and dummy sub-blocks are only + # read once their backend is selected, which migration never does. + 'accretion.atmloss_frac', + 'accretion.atmloss_module', 'accretion.dummy.timeline_path', 'accretion.impactor_C_ppmw', 'accretion.impactor_H_ppmw', From 230b06578b8b0b5af7a61bbdf8402568c533850a Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 24 Jul 2026 10:34:57 +0200 Subject: [PATCH 13/71] Harden the impact accounting against thin-atmosphere and drift cases A sub-threshold atmosphere made the impact strip destructive: the below-threshold return in calc_new_elements handed back the atmospheric reservoir masses, which the caller then wrote into the whole-planet totals, deleting the dissolved inventory and booking it as escaped. The below-threshold branch now returns the totals unchanged, a true no-op for every caller and reservoir mode, and the strip skips an atmosphere below the outgassing mass threshold outright. A negative explicit removal mass is rejected rather than silently adding mass. The conserved-volatile set is now derived from the tracked-element registry minus the rock-forming elements, so a future element is conserved by default instead of silently mass-scaled. The energy-budget documentation now states the booking convention precisely: the re-melt heat is evaluated on the pre-impact solver mesh, the impactor's own heat content arrives as part of the new initial condition unbooked, and the residual is invariant for any booked value, so the column quantifies a convention rather than a residual-checked quantity. The escaped-mass ledger comments name both of its channels, continuous escape and impact stripping. Tests: a regression pins the sub-threshold strip as a no-op that preserves the dissolved inventory; the atmosphere-loss config bounds and module selection are exercised through real config construction; new edge cases cover an airless planet with loss enabled, atmospheric oxygen in the strip partitioning, two sequential impacts, an explicit zero removal, and the negative-mass rejection. The empty-reservoir arms of the atmodeller escape tests now pin totals-preserved instead of the historical collapse. --- src/proteus/accretion/wrapper.py | 23 ++-- src/proteus/escape/wrapper.py | 19 ++- src/proteus/interior_energetics/wrapper.py | 13 +- src/proteus/utils/coupler.py | 41 +++--- tests/accretion/test_wrapper.py | 143 +++++++++++++++++++++ tests/config/test_accretion.py | 41 ++++++ tests/escape/test_wrapper.py | 21 ++- tests/outgas/test_atmodeller.py | 30 +++-- 8 files changed, 282 insertions(+), 49 deletions(-) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index 15db976a9..a183ba57b 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -4,7 +4,7 @@ import logging from typing import TYPE_CHECKING -from proteus.utils.constants import M_earth, element_list, noble_gases +from proteus.utils.constants import M_earth, element_list if TYPE_CHECKING: from proteus.accretion.common import ImpactEvent @@ -12,11 +12,14 @@ log = logging.getLogger('fwl.' + __name__) -# Volatile and noble-gas elements whose whole-planet budgets are conserved -# across an impact's mass growth. The rock-forming elements (Si, Mg, Fe, Na) -# are not carried in the volatile budget; the planet's rock mass grows through -# the structure solve (mass_tot and the equation of state), not here. -_VOLATILE_ELEMENTS = ('H', 'O', 'C', 'N', 'S', *noble_gases) +# Rock-forming elements, whose mass grows through the structure solve +# (mass_tot and the equation of state) rather than the volatile budgets. +_ROCK_ELEMENTS = ('Si', 'Mg', 'Fe', 'Na') + +# Every other tracked element's whole-planet budget is conserved across an +# impact's mass growth. Derived from the tracked-element registry so an +# element added there is conserved by default unless declared rock-forming. +_VOLATILE_ELEMENTS = tuple(e for e in element_list if e not in _ROCK_ELEMENTS) def init_accretion(handler: Proteus) -> list[ImpactEvent]: @@ -284,9 +287,13 @@ def _strip_impact_atmosphere(config, hf_row: dict, event: ImpactEvent) -> None: return # Atmospheric reservoir mass, summed the same way the debit partitions it. + # An atmosphere below the outgassing mass threshold is treated as nothing + # to strip, the same convention continuous escape applies to it. m_atm = sum(float(hf_row.get(f'{e}_kg_atm', 0.0)) for e in element_list) - if m_atm <= 0.0: - log.info(' impact atmosphere loss: no atmosphere to strip') + if m_atm < config.outgas.mass_thresh: + log.info( + ' impact atmosphere loss: atmosphere below the mass threshold, not stripped' + ) return before = {e: float(hf_row.get(f'{e}_kg_total', 0.0)) for e in element_list} diff --git a/src/proteus/escape/wrapper.py b/src/proteus/escape/wrapper.py index ef9f1f2e5..ab04c4b67 100644 --- a/src/proteus/escape/wrapper.py +++ b/src/proteus/escape/wrapper.py @@ -101,9 +101,11 @@ def run_escape( if esc_e > 0: log.info(' %2s = %.2e kg s-1' % (e, esc_e)) - # Accumulate cumulative escaped mass [kg] for the desiccation gate. This - # is the integral of `esc_rate_total * dt` over all escape calls and is - # persisted to the helpfile so it survives resume. + # Accumulate this call's contribution to the atmospheric-loss ledger the + # desiccation gate audits. The ledger carries the integral of + # `esc_rate_total * dt` over all escape calls plus the mass each giant + # impact strips from the atmosphere (booked by the accretion handler), + # and is persisted to the helpfile so it survives resume. esc_step_kg = float(hf_row.get('esc_rate_total', 0.0)) * secs_per_year * float(dt) if np.isfinite(esc_step_kg) and esc_step_kg > 0.0: hf_row['esc_kg_cumulative'] = float(hf_row.get('esc_kg_cumulative', 0.0)) + esc_step_kg @@ -294,6 +296,9 @@ def calc_new_elements( case _: raise ValueError(f"Invalid escape reservoir '{reservoir}'") + if esc_mass is not None and esc_mass < 0.0: + raise ValueError(f'esc_mass must be non-negative, got {esc_mass!r}') + # Calculate mass of elements in the reservoir. Issue #677 fix: # include O so the per-element subtraction sums to esc_mass and # the planetary O budget responds to escape (CALLIOPE's next call @@ -304,10 +309,14 @@ def calc_new_elements( res[e] = float(hf_row.get(f'{e}{key}', 0.0)) M_vols = float(sum(res.values())) - # check if we just desiccated the planet... + # Below-threshold reservoir: nothing to remove. Return the TOTAL + # inventories unchanged, whatever reservoir sized the loss: every caller + # writes the returned dict into ``_kg_total``, so returning the + # atmospheric masses here would replace the totals with them and delete + # the dissolved inventory whenever the atmosphere is thin. if M_vols < min_thresh: log.debug(' Total mass of volatiles below threshold in escape calculation') - return res + return {e: float(hf_row.get(f'{e}_kg_total', 0.0)) for e in element_list} # compute mass ratios in escaping reservoir emr = {e: (res[e] / M_vols if M_vols > 0 else 0.0) for e in res} diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index c376b5c5b..5e1d90ced 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -1851,9 +1851,16 @@ def _remelt_aragog(config: Config, dirs: dict, hf_row: dict, interior_o) -> None The heat the re-melt injects is booked into ``hf_row['step_dE_impact_J']`` using the solver's own entropy-transported heat quadrature over the jump from the cooled to the molten profile, the same ``rho(P,S) T dS`` frame the - conservation residual integrates. The coupler adds it to both sides of the - energy budget, so the residual stays closed across the impact while the - injected energy is quantified rather than silently absorbed. + conservation residual integrates. The quadrature runs on the solver's + current, pre-impact mesh (the solver is rebuilt for the grown planet only + at its next solve), so the booked value is the heat that re-melts the + mantle the planet had when the impact struck; the impactor's own heat + content arrives as part of the new initial condition and is not booked, + the same way the run's t=0 heat content is not. The coupler adds the + column to both sides of the energy budget, which keeps the residual closed + across the impact for any booked value; the magnitude is therefore a + defined convention quantified in the helpfile, not a quantity the residual + itself can validate. """ from proteus.interior_energetics.aragog import AragogRunner diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index 0a4e04073..bcb48674a 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -695,11 +695,13 @@ def GetHelpfileKeys(): # E_residual_cons_J = E_state_heat_cons_J - dE_predicted_cons_J # ``step_dE_impact_J`` is the heat a giant-impact mantle re-melt # injects, evaluated in the same ρ(P,S)·T·dS frame over the - # entropy jump from the cooled to the molten profile. It enters - # BOTH cumulatives: the state side because the jump falls between - # solver calls so no per-call state integral carries it, and the - # predicted side because the impact is an energy source. The - # residual is therefore invariant across an impact by construction. + # entropy jump from the cooled to the molten profile on the + # pre-impact solver mesh. It enters BOTH cumulatives: the state + # side because the jump falls between solver calls so no per-call + # state integral carries it, and the predicted side because the + # impact is an energy source. The residual is invariant across an + # impact for any booked value; the column is a defined convention, + # not a residual-checked quantity. # E_residual_cons_frac = E_residual_cons_J / max(|E_state_heat_cons_J|, 1 J) # This closes to about a percent of the cumulative cooling (largest # near full melt and at crystallisation-front / structure-remesh @@ -798,11 +800,14 @@ def GetHelpfileKeys(): # first escape call, used # as the reference point for `outgas.wrapper.check_desiccation`'s # "is the loss accounted for by escape?" sanity check. - # esc_kg_cumulative is the running sum of esc_rate_total * dt - # over the whole run. Both must be persisted to the CSV so - # resume preserves the gate's state. + # esc_kg_cumulative is the whole-run atmospheric-loss ledger: + # the running sum of esc_rate_total * dt from continuous escape + # plus the mass each giant impact strips from the atmosphere. + # The desiccation gate audits the sum of both channels. Both + # columns must be persisted to the CSV so resume preserves the + # gate's state. 'M_vol_initial', # bulk volatile inventory baseline [kg] - 'esc_kg_cumulative', # cumulative escaped mass [kg] + 'esc_kg_cumulative', # cumulative mass lost to space [kg] (escape + impact stripping) ] # quantities for each gas, from outgassing @@ -903,12 +908,18 @@ def _populate_energy_residual(current_hf: pd.DataFrame, new_row: dict) -> None: A giant-impact mantle re-melt contributes ``step_dE_impact_J``, the heat the re-melt injects evaluated in the same ``rho T dS`` frame - over the entropy jump from the cooled to the molten profile. It is - added to BOTH cumulatives: to the state side because the jump falls - between solver calls, so no per-call state integral carries it, and - to the predicted side because the impact is an energy source. The - residual is therefore invariant across an impact by construction, - and the injected energy is booked rather than silently absorbed. + over the entropy jump from the cooled to the molten profile, on the + pre-impact solver mesh (the impactor's own heat content arrives as + part of the new initial condition and is not booked). It is added to + BOTH cumulatives: to the state side because the jump falls between + solver calls, so no per-call state integral carries it, and to the + predicted side because the impact is an energy source. The residual + is therefore invariant across an impact for any booked value, which + means it cannot validate the injection's magnitude; the column + quantifies a defined convention rather than a residual-checked + quantity. The relative residual can spike on the impact row when the + injection nearly cancels the cumulative state heat in its + denominator; the absolute residual is the diagnostic there. The heating sources use the live-density (state-mass) Q variants so they share the ``rho(P,S)`` frame the state side integrates; the diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 15dce1079..b093cc01e 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -564,6 +564,149 @@ def test_total_impact_loss_removes_the_atmosphere_but_not_the_interior(monkeypat assert handler.hf_row['esc_kg_cumulative'] == pytest.approx(4.2e20, rel=1e-9) +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_stripping_a_sub_threshold_atmosphere_leaves_the_dissolved_inventory(monkeypatch): + """An atmosphere below the outgassing mass threshold is not strippable. + + On a magma-ocean planet most volatiles are dissolved and the atmosphere can + sit below ``outgas.mass_thresh`` (1e16 kg by default) while the totals are + orders of magnitude larger. The strip must leave every whole-planet budget + and the escaped-mass ledger untouched in that regime: the failure mode this + pins is the totals being overwritten with the tiny atmospheric masses, + which deletes the dissolved inventory and books it as escaped. + """ + from proteus.accretion.wrapper import apply_impact + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler( + accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=0.5) + ) + # Production default threshold; the atmosphere sits well below it while the + # dissolved reservoirs dominate the totals. + handler.config.outgas = SimpleNamespace(mass_thresh=1.0e16) + _atm_state(handler.hf_row, H=(1.0e15, 5.0e20), C=(5.0e14, 2.0e20)) + handler.hf_row['esc_kg_cumulative'] = 0.0 + apply_impact(handler, _impact_event()) + + # The dissolved inventory survives, exactly. + assert handler.hf_row['H_kg_total'] == pytest.approx(5.0e20, rel=1e-12) + assert handler.hf_row['C_kg_total'] == pytest.approx(2.0e20, rel=1e-12) + # Nothing is booked as escaped: the corrupted path would book ~7e20 kg. + assert handler.hf_row['esc_kg_cumulative'] == pytest.approx(0.0, abs=1.0) + # Discrimination: the failure mode leaves the totals at the atmospheric + # masses, five orders of magnitude below the correct values. + assert handler.hf_row['H_kg_total'] > 1.0e18 + + +@pytest.mark.unit +def test_stripping_with_no_atmosphere_at_all_is_a_clean_no_op(monkeypatch): + """Loss enabled on an airless planet strips nothing and books nothing. + + An impact can land before any outgassing has produced an atmosphere. With + the loss module active the strip must pass through without touching the + budgets, creating atmospheric keys, or moving the escaped-mass ledger. + """ + from proteus.accretion.wrapper import apply_impact + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler( + accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=0.9) + ) + handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) + handler.hf_row['H_kg_total'] = 3.0e20 # dissolved only; no _kg_atm keys exist + apply_impact(handler, _impact_event()) + + assert handler.hf_row['H_kg_total'] == pytest.approx(3.0e20, rel=1e-12) + assert float(handler.hf_row.get('esc_kg_cumulative', 0.0)) == pytest.approx(0.0, abs=1.0) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_impact_strips_oxygen_with_the_other_atmospheric_elements(monkeypatch): + """Atmospheric oxygen is stripped in proportion, like every other element. + + Under whole-planet oxygen accounting the atmosphere carries O (in H2O, + CO2, SO2), so an impact that removes atmosphere removes O with it. The + strip must debit O_kg_total by the loss fraction times the atmospheric O, + or the O ledger would keep mass the atmosphere no longer holds. + """ + from proteus.accretion.wrapper import apply_impact + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler( + accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=0.4) + ) + handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) + _atm_state(handler.hf_row, H=(1.0e20, 3.0e20), O=(8.0e20, 1.2e21)) + apply_impact(handler, _impact_event()) + + assert handler.hf_row['O_kg_total'] == pytest.approx(1.2e21 - 0.4 * 8.0e20, rel=1e-9) + assert handler.hf_row['H_kg_total'] == pytest.approx(3.0e20 - 0.4 * 1.0e20, rel=1e-9) + # O dominates the atmosphere 8:1, so the ledger booking is mostly O; a + # partitioning that skipped O would book 4e19 instead of 3.6e20. + assert handler.hf_row['esc_kg_cumulative'] == pytest.approx( + 0.4 * (8.0e20 + 1.0e20), rel=1e-9 + ) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_two_sequential_impacts_compose_their_consequences(monkeypatch): + """Each impact conserves, strips, and delivers against the state it finds. + + A Morrigan timeline routinely carries several impacts. The second impact + must act on the post-first-impact budgets: conservation brackets its own + structure solve, the strip debits the atmosphere it finds, and the + delivery adds its own impactor's content, with the ledger accumulating + across both. + """ + from proteus.accretion.wrapper import apply_impact + from proteus.utils.constants import M_earth + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', + _rescaling_solve_structure(1.2), + ) + + handler = _impact_handler( + mass_tot=1.0, + accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=0.5, H=1000.0), + ) + handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) + _atm_state(handler.hf_row, H=(2.0e20, 6.0e20)) + m_imp = 0.2 * M_earth + event = _impact_event( + M_target_before=6.0 * M_earth, + M_impactor=m_imp, + M_merged_after=6.2 * M_earth, + ) + + apply_impact(handler, event) + delivered = m_imp * 1000.0 / 1.0e6 + after_first = 6.0e20 - 0.5 * 2.0e20 + delivered + assert handler.hf_row['H_kg_total'] == pytest.approx(after_first, rel=1e-9) + + # Second impact: same event again; the atmosphere was not re-equilibrated + # between them (no outgas call here), so the strip debits the same + # atmospheric reservoir and the delivery adds the same amount. + apply_impact(handler, event) + after_second = after_first - 0.5 * 2.0e20 + delivered + assert handler.hf_row['H_kg_total'] == pytest.approx(after_second, rel=1e-9) + # The planet grew twice and the ledger accumulated both strips. + assert handler.config.planet.mass_tot == pytest.approx(1.4, rel=1e-12) + assert handler.hf_row['esc_kg_cumulative'] == pytest.approx(2 * 1.0e20, rel=1e-9) + + @pytest.mark.unit def test_impact_loss_composes_with_delivery_and_a_broken_provider_raises(monkeypatch): """Stripping acts on the pre-impact atmosphere, then delivery adds on top. diff --git a/tests/config/test_accretion.py b/tests/config/test_accretion.py index 4cc22d91f..1e36987a2 100644 --- a/tests/config/test_accretion.py +++ b/tests/config/test_accretion.py @@ -187,6 +187,47 @@ def test_impactor_composition_drives_the_delivery_flag(): Accretion(impactor_C_ppmw=-1.0) +@pytest.mark.unit +def test_atmloss_config_bounds_and_module_selection_bind_at_load(): + """The impact atmosphere-loss options are validated at construction. + + The loss fraction only means anything on [0, 1]: partitioning a negative + or beyond-total loss over the atmosphere is undefined, so both must be + rejected when the config is built, not discovered mid-run at the first + impact. The module selector accepts only the registered choices and the + 'none' string resolves to the None singleton, which is what the runtime + dispatch tests identity against. + """ + from proteus.config._accretion import Accretion + + # Defaults: loss disabled, fraction zero. + a = Accretion() + assert a.atmloss_module is None + assert a.atmloss_module != 'none' + assert a.atmloss_frac == pytest.approx(0.0) + + # The registered module and the full open interval load cleanly. + assert Accretion(atmloss_module='constant', atmloss_frac=0.35).atmloss_frac == ( + pytest.approx(0.35) + ) + # Both boundary values are legal: no loss, and complete stripping. + assert Accretion(atmloss_frac=0.0).atmloss_frac == pytest.approx(0.0) + assert Accretion(atmloss_frac=1.0).atmloss_frac == pytest.approx(1.0) + + # Out-of-bounds fractions are rejected at load. + with pytest.raises(ValueError): + Accretion(atmloss_frac=1.5) + with pytest.raises(ValueError): + Accretion(atmloss_frac=-0.1) + + # Unregistered loss modules are rejected at load; 'zephyrus' is the + # realistic future name and must fail until the law actually exists. + with pytest.raises(ValueError): + Accretion(atmloss_module='zephyrus') + with pytest.raises(ValueError): + Accretion(atmloss_module='kegerreis') + + @pytest.mark.unit def test_reference_config_declares_the_accretion_section(): """The shipped reference config parses and agrees with the schema. diff --git a/tests/escape/test_wrapper.py b/tests/escape/test_wrapper.py index 9f6701b53..a0ae7771f 100644 --- a/tests/escape/test_wrapper.py +++ b/tests/escape/test_wrapper.py @@ -741,11 +741,11 @@ def test_calc_new_elements_explicit_mass_overrides_the_rate_integral(): An impulsive loss (a giant impact stripping the atmosphere) hands the total mass to remove directly. The rate-times-timestep integral must play - no part: the row carries a deliberately absurd escape rate whose integral - over dt would strip 300x more, so any leakage of the rate path into the - debit is unmissable. The explicit mass partitions proportionally, and - omitting it (the default) must reproduce the rate-integral behaviour - unchanged. + no part: the row carries an escape rate whose integral over dt is thirty + times smaller than the explicit mass, so any leakage of the rate path + into the debit is unmissable. The explicit mass partitions + proportionally, and omitting it (the default) must reproduce the + rate-integral behaviour unchanged. """ from proteus.escape.wrapper import calc_new_elements from proteus.utils.constants import secs_per_year @@ -782,6 +782,17 @@ def _row(): assert tgt2['H'] == pytest.approx(tgt['H'], rel=1e-9) assert tgt2['C'] == pytest.approx(tgt['C'], rel=1e-9) + # An explicit zero is a real value, not "fall back to the rate": nothing + # is removed even though the rate integral would remove 3.16e18 kg. + tgt0 = calc_new_elements(_row(), dt, 'outgas', min_thresh=1e10, esc_mass=0.0) + assert tgt0['H'] == pytest.approx(8.0e20, rel=1e-12) + assert tgt0['C'] == pytest.approx(2.0e20, rel=1e-12) + + # A negative mass has no meaning in the partitioning and is rejected + # rather than silently adding mass to the planet. + with pytest.raises(ValueError, match='non-negative'): + calc_new_elements(_row(), dt, 'outgas', min_thresh=1e10, esc_mass=-1.0e19) + @pytest.mark.unit def test_calc_new_elements_below_threshold(): diff --git a/tests/outgas/test_atmodeller.py b/tests/outgas/test_atmodeller.py index 09f425794..07fc94fb5 100644 --- a/tests/outgas/test_atmodeller.py +++ b/tests/outgas/test_atmodeller.py @@ -211,14 +211,16 @@ def test_element_budget_survives_outgas_reservoir_escape(): assert (h_budget - tgt['H']) > 0.01 * h_budget # a real, resolvable debit assert tgt['H'] == pytest.approx(expected_h, rel=1e-9) - # Discrimination: with the atmospheric reservoir zeroed (the bug state), the - # same call collapses every element to zero instead of debiting it. - bug_row = dict(hf_row) + # Boundary: with the atmospheric reservoir zeroed there is nothing to + # partition the loss over, so the call is a no-op that PRESERVES the + # whole-planet totals. The historical failure mode returned the zeroed + # reservoir dict here, collapsing every total to zero once written back. + empty_row = dict(hf_row) for e in element_mmw: - bug_row[f'{e}_kg_atm'] = 0.0 - tgt_bug = calc_new_elements(bug_row, dt=dt, reservoir='outgas') - assert tgt_bug['H'] == 0.0 - assert all(v == 0.0 for v in tgt_bug.values()) + empty_row[f'{e}_kg_atm'] = 0.0 + tgt_empty = calc_new_elements(empty_row, dt=dt, reservoir='outgas') + assert tgt_empty['H'] == pytest.approx(h_budget, rel=1e-12) + assert tgt_empty['H'] > 1.0e20 # discriminates against the zero collapse @pytest.mark.physics_invariant @@ -259,13 +261,15 @@ def test_o2_endpoint_keeps_live_budget_no_false_desiccation(): # the O2 endpoint. assert tgt['O'] > 0.99 * 9.0e21 - # Discrimination: the pre-fix state (no O reservoir) collapses the budget, - # which is the false-refusal / livelock the fix removes. - bug_row = dict(hf_row) + # Boundary: with no atmospheric reservoir at all the call is a no-op that + # preserves the O budget; a collapse to zero here is the false-refusal / + # livelock failure mode, on either side of the reservoir reconstruction. + empty_row = dict(hf_row) for e in element_mmw: - bug_row[f'{e}_kg_atm'] = 0.0 - tgt_bug = calc_new_elements(bug_row, dt=1.0, reservoir='outgas') - assert tgt_bug['O'] == 0.0 + empty_row[f'{e}_kg_atm'] = 0.0 + tgt_empty = calc_new_elements(empty_row, dt=1.0, reservoir='outgas') + assert tgt_empty['O'] == pytest.approx(hf_row['O_kg_total'], rel=1e-12) + assert tgt_empty['O'] > 8.0e21 # discriminates against the zero collapse def test_model_cache_builds_once_per_species_network(): From d35fe8b710d37c638dce0dc46ec338eae0177e2d Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 24 Jul 2026 12:23:37 +0200 Subject: [PATCH 14/71] Let impactors carry the planet's formation volatile abundances Impactors previously carried either nothing or hand-set per-element budgets, with the whole content delivered regardless of the collision. Embryos that co-form from the same disk material should instead share the planet's own composition, and a giant impact should not deliver the volatiles its own atmosphere loses in the collision. A new accretion.impactor_volatiles selector selects the impactor content: dry (the default), match_planet, or ppmw for the existing per-element fields. Planet-matching impactors carry the planet's fractional volatile abundances at formation, read from the settled initial row of the run's own helpfile so a resumed run recovers the same composition, scaled to the impactor mass. In every non-dry mode the content is split by mirroring the planet's per-element atmosphere fraction at impact time: with impact atmosphere loss active the impactor's atmospheric part is lost with the collision and only its dissolved part is delivered, while with loss disabled the whole content arrives. An element the planet no longer holds falls back to the bulk atmospheric fraction. The mirror understates a smaller body's atmospheric fraction, so delivery is somewhat overestimated; the coming ZEPHYRUS collision law replaces the convention. The planet's total mass now grows by the merger mass net of every volatile lost at the impact, the target strip included, so mass_tot matches what the planet actually holds; the net change can be negative when a small impactor blows off a heavier atmosphere. All consequence sizes are computed from the pre-impact state and the structure is solved once against the final mass. Verified in a coupled run against closed-form values: delivered, lost, stripped, and net mass all match exactly. --- input/all_options.toml | 23 +- src/proteus/accretion/wrapper.py | 359 +++++++++++++------ src/proteus/config/_accretion.py | 78 ++++- tests/accretion/test_wrapper.py | 370 ++++++++++++++++++-- tests/config/test_accretion.py | 33 +- tests/tools/test_migrate_config_v2_to_v3.py | 1 + 6 files changed, 704 insertions(+), 160 deletions(-) diff --git a/input/all_options.toml b/input/all_options.toml index 6c4f8a1ba..21ac20f08 100644 --- a/input/all_options.toml +++ b/input/all_options.toml @@ -581,13 +581,22 @@ config_version = "3.0" module = "none" # none | dummy | morrigan time_offset = 0.0 # shift of impact times onto the PROTEUS time axis [yr] - # Volatile content of each impactor [ppmw of impactor mass]. - # Zero means impactors add silicate and iron mass only. - impactor_H_ppmw = 0.0 # hydrogen delivered per impact - impactor_C_ppmw = 0.0 # carbon delivered per impact - impactor_N_ppmw = 0.0 # nitrogen delivered per impact - impactor_S_ppmw = 0.0 # sulfur delivered per impact - impactor_O_ppmw = 0.0 # oxygen delivered per impact + # Impactor volatile content source: dry impactors add silicate and iron + # mass only; match_planet impactors carry the planet's own formation + # composition scaled to their mass; ppmw impactors carry the per-element + # budgets below (read only in ppmw mode). + impactor_volatiles = "dry" # dry | match_planet | ppmw + impactor_H_ppmw = 0.0 # hydrogen per impactor [ppmw of impactor mass] + impactor_C_ppmw = 0.0 # carbon per impactor [ppmw of impactor mass] + impactor_N_ppmw = 0.0 # nitrogen per impactor [ppmw of impactor mass] + impactor_S_ppmw = 0.0 # sulfur per impactor [ppmw of impactor mass] + impactor_O_ppmw = 0.0 # oxygen per impactor [ppmw of impactor mass] + + # Impact atmosphere loss. With a loss module active the target loses the + # configured fraction of its atmosphere at each impact and the impactor's + # own atmospheric volatiles are fully lost with the collision. + atmloss_module = "none" # none | constant + atmloss_frac = 0.0 # target atmosphere fraction removed per impact [0-1] [accretion.morrigan] seed = 1 # Monte Carlo seed diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index a183ba57b..ae60185db 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -19,8 +19,15 @@ # Every other tracked element's whole-planet budget is conserved across an # impact's mass growth. Derived from the tracked-element registry so an # element added there is conserved by default unless declared rock-forming. +# The set includes the noble gases, so a planet-matching impactor carries +# them in proportion like every other volatile. _VOLATILE_ELEMENTS = tuple(e for e in element_list if e not in _ROCK_ELEMENTS) +# Elements configurable through the per-element ppmw fields. The ppmw mode +# can only deliver these; the planet-matching mode covers the full volatile +# set above, noble gases included. +_PPMW_ELEMENTS = ('H', 'C', 'N', 'S', 'O') + def init_accretion(handler: Proteus) -> list[ImpactEvent]: """Prepare the impact timeline for a run. @@ -115,17 +122,38 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: event.mass_delta / M_earth, ) + # Size every volatile consequence from the pre-impact state, before any of + # it is applied: what the impact strips from the target's atmosphere, and + # what the impactor carries, split into the part delivered into the planet + # and the part its own atmosphere loses with the collision. + log.info( + ' impactor volatiles: %s; atmosphere loss: %s', + config.accretion.impactor_volatiles, + config.accretion.atmloss_module or 'off', + ) + strip = _target_strip_amounts(config, hf_row, event) + content = _impactor_volatile_content(config, handler.hf_all, event) + delivered, impactor_lost = _partition_impactor_content(config, hf_row, content) + # Snapshot the whole-planet volatile budgets before the structure re-solve. # solve_structure recomputes the ppmw-mode budgets against the grown mass, # which would let even a dry impactor inflate the volatile inventory as if # the added rock carried the planet's volatile content. Volatiles are - # conserved across the mass growth and grow only through the explicit + # conserved across the mass growth and change only through the strip and # delivery below; the rock mass grows through the structure solve itself. volatile_budgets = _snapshot_volatile_budgets(hf_row) - # Grow the planet by the impactor mass and re-solve the structure. mass_tot - # is in Earth masses; the event's mass delta is the impactor mass in kg. - config.planet.mass_tot += event.mass_delta / M_earth + # Grow the interior anchor by the impactor's rock alone: the merger mass + # minus the impactor's full volatile content. The anchor and the volatile + # budgets are the two halves of the whole-planet mass, so each impact + # channel must land in exactly one of them; the delivered volatiles and + # the target strip move the budgets below, and the impactor's lost + # atmosphere never enters the planet at all. The whole-planet mass then + # closes to before + rock + delivered - stripped, which can be a net + # shrink when a small impactor blows off a heavier atmosphere. + # mass_tot is in Earth masses; the amounts are in kg. + impactor_rock = event.mass_delta - sum(content.values()) + config.planet.mass_tot += impactor_rock / M_earth solve_structure( handler.directories, config, handler.hf_all, hf_row, handler.directories['output'] ) @@ -134,6 +162,10 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: # structure solve wrote, so the growth adds rock, not volatiles. _restore_volatile_budgets(hf_row, volatile_budgets) + # Apply the sized consequences to the whole-planet budgets and refresh + # the tracked-element total the budgets aggregate into. + _apply_volatile_consequences(config, hf_row, strip, delivered, impactor_lost) + # Re-melt the mantle to its molten initial condition, so the interior # evolves from a fully molten state after the impact. remelt_mantle(handler.directories, config, hf_row, handler.interior_o, event) @@ -160,55 +192,243 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: config.orbit.eccentricity, ) - # Strip part of the pre-impact atmosphere, before the impactor's own - # volatiles are delivered: the shock blows off what the planet already - # has, while the delivery goes into the molten post-impact planet. The - # outgassing step later this iteration re-equilibrates the atmosphere - # against the debited totals. - _strip_impact_atmosphere(config, hf_row, event) - - # Deliver the impactor's volatiles into the whole-planet element budgets, - # so the outgassing step later this iteration re-equilibrates with them. The - # amount per element is the impactor mass times the configured content in - # ppmw; every element defaults to zero (a dry impactor), so this is a no-op - # unless a run opts in. Only the keys with a non-zero content are touched, so - # an element deferred to the chemistry step (e.g. oxygen under ic_chemistry) - # is left alone when nothing is delivered for it. - _deliver_impactor_volatiles(config, hf_row, event.M_impactor) - - # Refresh the tracked-element total from the conserved budgets plus any - # delivered volatiles. solve_structure set M_ele from the mass-scaled - # values it computed, which the restore above has overridden. - _refresh_tracked_element_total(hf_row) +def _apply_volatile_consequences( + config, hf_row: dict, strip: dict, delivered: dict, impactor_lost: dict +) -> None: + """Apply an impact's sized volatile changes to the whole-planet budgets. -def _deliver_impactor_volatiles(config, hf_row: dict, m_impactor: float) -> None: - """Add the impactor's volatile content to the whole-planet element budgets. + Debits the stripped target atmosphere, books it into the escaped-mass + ledger the desiccation gate audits, credits the delivered impactor + volatiles, and refreshes the tracked-element total. The outgassing step + later this iteration re-equilibrates the atmosphere against the updated + totals; an element deferred to the chemistry step (e.g. oxygen under + ic_chemistry) is re-derived there either way. Parameters ---------- config : Config - Model configuration; reads ``accretion.impactor__ppmw``. + Model configuration, read for the strip-percentage log line. hf_row : dict - Current helpfile row, whose ``_kg_total`` budgets are grown in place. - m_impactor : float - Impactor mass [kg]. + Current helpfile row, mutated in place. + strip, delivered, impactor_lost : dict + Per-element masses [kg] sized from the pre-impact state. """ - delivered = {} - for element in ('H', 'C', 'N', 'S', 'O'): - ppmw = getattr(config.accretion, f'impactor_{element}_ppmw') - if ppmw <= 0.0: - continue - added = m_impactor * ppmw / 1.0e6 - key = f'{element}_kg_total' - hf_row[key] = hf_row.get(key, 0.0) + added - delivered[element] = added - + for e, removed in strip.items(): + key = f'{e}_kg_total' + hf_row[key] = max(0.0, float(hf_row.get(key, 0.0)) - removed) + if strip: + stripped_total = sum(strip.values()) + hf_row['esc_kg_cumulative'] = ( + float(hf_row.get('esc_kg_cumulative', 0.0)) + stripped_total + ) + log.info( + ' impact stripped %.1f%% of the atmosphere: %.3e kg removed', + 100.0 * float(config.accretion.atmloss_frac), + stripped_total, + ) + for e, added in delivered.items(): + key = f'{e}_kg_total' + hf_row[key] = float(hf_row.get(key, 0.0)) + added if delivered: log.info( ' delivered impactor volatiles [kg]: %s', ', '.join(f'{e}={v:.3e}' for e, v in delivered.items()), ) + if impactor_lost: + log.info( + ' impactor atmosphere lost with the collision [kg]: %s (%.3e total)', + ', '.join(f'{e}={v:.3e}' for e, v in impactor_lost.items()), + sum(impactor_lost.values()), + ) + + # Refresh the tracked-element total from the conserved budgets plus the + # strip and delivery. solve_structure set M_ele from the mass-scaled + # values it computed, which the updates above have overridden. + _refresh_tracked_element_total(hf_row) + + +def _primordial_mass_fractions(hf_all) -> dict: + """Volatile mass fractions of the planet at formation [kg/kg]. + + Reads the settled initial state from the run's own history: the last row + of the init epoch (``Time < 1`` yr, the same discriminator the outgassing + warm start uses), or the first row when no init row exists. The helpfile + is persisted, so a resumed run recovers the same formation composition + without any extra state. + + Parameters + ---------- + hf_all : pd.DataFrame + Full helpfile history of the run. + + Returns + ------- + dict + Mapping of volatile element to ``_kg_total / M_planet`` at the + formation state. + + Raises + ------ + RuntimeError + If no history is available or the formation row carries no positive + planet mass; the impactor composition would be undefined. + """ + if hf_all is None or len(hf_all) == 0: + raise RuntimeError( + 'Cannot scale impactor volatiles to the planet: no helpfile history ' + 'is available to read the formation composition from.' + ) + + init_rows = hf_all[hf_all['Time'] < 1.0] + t0 = init_rows.iloc[-1] if len(init_rows) else hf_all.iloc[0] + + m_planet = float(t0.get('M_planet', 0.0)) + if m_planet <= 0.0: + raise RuntimeError( + 'Cannot scale impactor volatiles to the planet: the formation row ' + f'carries M_planet = {m_planet!r}.' + ) + + fractions = {e: float(t0.get(f'{e}_kg_total', 0.0)) / m_planet for e in _VOLATILE_ELEMENTS} + log.info( + ' formation composition (M_planet=%.3e kg at t=%.2e yr): %s', + m_planet, + float(t0.get('Time', 0.0)), + ', '.join(f'{e}={x:.2e}' for e, x in fractions.items() if x > 0.0), + ) + return fractions + + +def _impactor_volatile_content(config, hf_all, event: ImpactEvent) -> dict: + """Total volatile mass the impactor carries, per element [kg]. + + Dispatches on ``accretion.impactor_volatiles``: a dry impactor carries + nothing; ``match_planet`` scales the planet's formation mass fractions to + the impactor mass, on the assumption that every embryo in the dynamical + model co-formed from the same disk material; ``ppmw`` uses the configured + per-element budgets. Only positive contributions are returned. + + Under ``O_mode = 'ic_chemistry'`` oxygen is excluded from the content: + the volatile O budget is chemistry-derived (the next outgassing call + re-equilibrates it against the fO2 buffer for the grown planet), so a + delivered O mass would be overwritten while its subtraction from the + interior anchor persisted. The impactor's oxygen then arrives as part of + its rock, which is where oxide-bound oxygen belongs. + """ + mode = config.accretion.impactor_volatiles + content: dict[str, float] = {} + + if mode == 'match_planet': + fractions = _primordial_mass_fractions(hf_all) + for e, x0 in fractions.items(): + if x0 > 0.0: + content[e] = x0 * event.M_impactor + elif mode == 'ppmw': + for e in _PPMW_ELEMENTS: + ppmw = getattr(config.accretion, f'impactor_{e}_ppmw') + if ppmw > 0.0: + content[e] = event.M_impactor * ppmw / 1.0e6 + + o_mode = getattr(getattr(config.planet, 'elements', None), 'O_mode', None) + if o_mode == 'ic_chemistry': + content.pop('O', None) + + return content + + +def _partition_impactor_content(config, hf_row: dict, content: dict) -> tuple[dict, dict]: + """Split the impactor's volatiles into a delivered and a lost part [kg]. + + The impactor's internal partitioning is unknowable, so the planet's own + atmosphere-versus-interior split per element at impact time is mirrored + onto it. With impact atmosphere loss active the impactor's atmospheric + part is lost with the collision (the impactor is disrupted and its + gravity is lower than the target's) and only the dissolved part is + delivered; with loss disabled the whole content is delivered. The mirror + understates a smaller body's atmospheric fraction (it equilibrates at + lower surface pressure), so delivery is somewhat overestimated; the + coming ZEPHYRUS collision law replaces this convention. + + For an element the planet no longer holds, the per-element mirror is + undefined and the planet's bulk atmospheric fraction is used instead. + + Returns + ------- + (delivered, lost) : tuple of dict + Per-element masses delivered into the planet and lost to space [kg]. + """ + if config.accretion.atmloss_module is None: + return dict(content), {} + + # Bulk atmospheric fraction as the fallback mirror for elements the + # planet no longer tracks a budget for. + tot_all = sum(float(hf_row.get(f'{e}_kg_total', 0.0)) for e in _VOLATILE_ELEMENTS) + atm_all = sum(float(hf_row.get(f'{e}_kg_atm', 0.0)) for e in _VOLATILE_ELEMENTS) + f_atm_bulk = atm_all / tot_all if tot_all > 0.0 else 0.0 + + delivered: dict[str, float] = {} + lost: dict[str, float] = {} + mirror: dict[str, float] = {} + for e, mass in content.items(): + total_e = float(hf_row.get(f'{e}_kg_total', 0.0)) + if total_e > 0.0: + f_atm = float(hf_row.get(f'{e}_kg_atm', 0.0)) / total_e + else: + f_atm = f_atm_bulk + f_atm = min(max(f_atm, 0.0), 1.0) + mirror[e] = f_atm + lost_e = mass * f_atm + if lost_e > 0.0: + lost[e] = lost_e + if mass - lost_e > 0.0: + delivered[e] = mass - lost_e + + if mirror: + log.info( + ' impactor atmospheric fraction per element (planet mirror): %s', + ', '.join(f'{e}={f:.2f}' for e, f in mirror.items()), + ) + return delivered, lost + + +def _target_strip_amounts(config, hf_row: dict, event: ImpactEvent) -> dict: + """Mass the impact strips from the target's atmosphere, per element [kg]. + + Sizes the debit from the pre-impact state without mutating it: the loss + fraction times the atmospheric reservoir, partitioned over the elements in + proportion to their atmospheric masses through the same path continuous + escape uses, so the per-element loss can never exceed what the atmosphere + holds and the dissolved interior inventory is untouched. An atmosphere + below the outgassing mass threshold is treated as nothing to strip, the + same convention continuous escape applies to it. + """ + from proteus.escape.wrapper import calc_new_elements + + f_loss = _impact_loss_fraction(config, hf_row, event) + if f_loss <= 0.0: + return {} + + m_atm = sum(float(hf_row.get(f'{e}_kg_atm', 0.0)) for e in element_list) + if m_atm < config.outgas.mass_thresh: + log.info( + ' impact atmosphere loss: atmosphere below the mass threshold, not stripped' + ) + return {} + + tgt = calc_new_elements( + hf_row, + dt=0.0, + reservoir='outgas', + min_thresh=config.outgas.mass_thresh, + esc_mass=f_loss * m_atm, + ) + strip = {} + for e, new_total in tgt.items(): + removed = float(hf_row.get(f'{e}_kg_total', 0.0)) - float(new_total) + if removed > 0.0: + strip[e] = removed + return strip def _impact_loss_fraction(config, hf_row: dict, event: ImpactEvent) -> float: @@ -260,65 +480,6 @@ def _impact_loss_fraction(config, hf_row: dict, event: ImpactEvent) -> float: return f_loss -def _strip_impact_atmosphere(config, hf_row: dict, event: ImpactEvent) -> None: - """Remove part of the atmosphere at an impact and debit the element budgets. - - The lost mass is the loss fraction times the atmospheric reservoir, and is - partitioned over the elements in proportion to their atmospheric masses - through the same path continuous escape uses, so the per-element loss can - never exceed what the atmosphere holds and the dissolved interior inventory - is untouched. The debited mass is added to the cumulative escaped-mass - ledger, which the desiccation gate audits: without that booking, a planet - dried out partly by impacts would be refused desiccation later. - - Parameters - ---------- - config : Config - Model configuration. - hf_row : dict - Current helpfile row, mutated in place. - event : ImpactEvent - The impact being applied. - """ - from proteus.escape.wrapper import calc_new_elements - - f_loss = _impact_loss_fraction(config, hf_row, event) - if f_loss <= 0.0: - return - - # Atmospheric reservoir mass, summed the same way the debit partitions it. - # An atmosphere below the outgassing mass threshold is treated as nothing - # to strip, the same convention continuous escape applies to it. - m_atm = sum(float(hf_row.get(f'{e}_kg_atm', 0.0)) for e in element_list) - if m_atm < config.outgas.mass_thresh: - log.info( - ' impact atmosphere loss: atmosphere below the mass threshold, not stripped' - ) - return - - before = {e: float(hf_row.get(f'{e}_kg_total', 0.0)) for e in element_list} - tgt = calc_new_elements( - hf_row, - dt=0.0, - reservoir='outgas', - min_thresh=config.outgas.mass_thresh, - esc_mass=f_loss * m_atm, - ) - for e, mass in tgt.items(): - hf_row[f'{e}_kg_total'] = mass - - # Book the actually-debited mass (including any desiccation-floor - # truncation) into the escaped-mass ledger the desiccation gate audits. - lost = sum(before[e] - float(tgt.get(e, before[e])) for e in before) - hf_row['esc_kg_cumulative'] = float(hf_row.get('esc_kg_cumulative', 0.0)) + lost - - log.info( - ' impact stripped %.1f%% of the atmosphere: %.3e kg removed', - 100.0 * f_loss, - lost, - ) - - def _snapshot_volatile_budgets(hf_row: dict) -> dict: """Capture the whole-planet volatile element budgets [kg]. diff --git a/src/proteus/config/_accretion.py b/src/proteus/config/_accretion.py index fb5d3f4b7..53f5f2103 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -130,15 +130,34 @@ class AccretionDummy: timeline_path: str | None = field(default=None, converter=none_if_none) +def valid_impactor_volatiles(instance, attribute, value): + """Refuse ppmw budgets that the selected content mode would ignore.""" + if instance.impactor_volatiles == 'ppmw': + return + set_fields = [ + f'impactor_{e}_ppmw' + for e in ('H', 'C', 'N', 'S', 'O') + if getattr(instance, f'impactor_{e}_ppmw') > 0.0 + ] + if set_fields: + raise ValueError( + f'`accretion.{"`, `accretion.".join(set_fields)}` set, but the ppmw ' + f"budgets are read only when accretion.impactor_volatiles = 'ppmw' " + f"(currently '{instance.impactor_volatiles}'). Select the ppmw mode " + 'or remove the budgets.' + ) + + @define class Accretion: """Giant-impact accretion, delivery, and module selection. An impact grows the planet, delivers volatiles, re-melts the mantle, strips part of the atmosphere, and moves the orbit. The impactor - composition below sets how much volatile mass each impactor carries; - it defaults to zero, so impactors are dry unless delivery is - requested. + volatile content is set by ``impactor_volatiles``: "dry" impactors + (the default) add silicate and iron mass only, "match_planet" + impactors carry the planet's own formation composition, and "ppmw" + impactors carry the per-element budgets configured below. Attributes ---------- @@ -154,6 +173,17 @@ class Accretion: disk dispersal, while PROTEUS measures it from the start of its own evolution. Impacts landing before the start of the run are folded into the initial condition. + impactor_volatiles: str + Where each impactor's volatile content comes from. Choices: + "dry" (impactors carry rock and iron only), "match_planet" (every + impactor carries the planet's own initial fractional volatile + abundances, scaled to the impactor mass, on the assumption that + all embryos co-formed from the same disk material), "ppmw" (the + per-element ``impactor__ppmw`` budgets below). The content is + split into an atmospheric and a dissolved part by mirroring the + planet's own partitioning at impact time; with impact atmosphere + loss active the atmospheric part is lost with the collision and + only the dissolved part is delivered. impactor_H_ppmw: float Hydrogen carried by each impactor [ppmw of impactor mass]. impactor_C_ppmw: float @@ -165,14 +195,18 @@ class Accretion: impactor_O_ppmw: float Oxygen carried by each impactor [ppmw of impactor mass]. atmloss_module: str or None - How the fraction of atmosphere lost to each impact is computed. - Choices: None (no impact atmosphere loss), "constant" (the fixed - fraction below). A ZEPHYRUS collision-loss law will become a - further choice when available; PROTEUS itself ships no impact - loss physics. + How impact atmosphere loss is computed. Choices: None (no impact + atmosphere loss at all: the target keeps its atmosphere and a + volatile-bearing impactor delivers its whole content), "constant" + (the fixed target fraction below). Whenever a loss module is + active, the impactor's own atmospheric volatiles are fully lost + with the collision, independently of the fraction below. A + ZEPHYRUS collision-loss law will become a further choice when + available; PROTEUS itself ships no impact loss physics. atmloss_frac: float - Fraction of the atmosphere removed by each impact when - ``atmloss_module = "constant"`` [0-1]. + Fraction of the TARGET's atmosphere removed by each impact when + ``atmloss_module = "constant"`` [0-1]. Does not scale the + impactor-side loss. """ module: str | None = field( @@ -186,14 +220,24 @@ class Accretion: time_offset: float = field(default=0.0) - # Impactor volatile content, applied to every impact. Zero means the - # impactor adds silicate and iron mass only, so the planet's bulk - # volatile concentration falls by dilution as it grows. + # Impactor volatile content source. 'dry' impactors add silicate and + # iron mass only, so the planet's bulk volatile concentration falls by + # dilution as it grows; 'match_planet' scales the planet's initial + # fractional abundances to the impactor; 'ppmw' uses the fields below. + impactor_volatiles: str = field( + default='dry', + validator=in_(('dry', 'match_planet', 'ppmw')), + ) + + # Per-element impactor content, read when impactor_volatiles = 'ppmw'. impactor_H_ppmw: float = field(default=0.0, validator=ge(0)) impactor_C_ppmw: float = field(default=0.0, validator=ge(0)) impactor_N_ppmw: float = field(default=0.0, validator=ge(0)) impactor_S_ppmw: float = field(default=0.0, validator=ge(0)) - impactor_O_ppmw: float = field(default=0.0, validator=ge(0)) + # The cross-field check rides on the LAST ppmw field: attrs runs field + # validators in definition order, so only here are the mode selector and + # every budget it guards populated. + impactor_O_ppmw: float = field(default=0.0, validator=[ge(0), valid_impactor_volatiles]) # Impact atmosphere loss. Disabled by default; the constant module is a # placeholder with the call shape of the coming ZEPHYRUS collision law. @@ -206,5 +250,9 @@ class Accretion: @property def delivers_volatiles(self) -> bool: - """Does any impactor volatile budget exceed zero?""" + """Can an impactor carry any volatile mass under the selected mode?""" + if self.impactor_volatiles == 'dry': + return False + if self.impactor_volatiles == 'match_planet': + return True return any(getattr(self, f'impactor_{e}_ppmw') > 0.0 for e in ('H', 'C', 'N', 'S', 'O')) diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index b093cc01e..7cd34b6c2 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -201,9 +201,16 @@ def _impact_event(**overrides): return ImpactEvent(**base) -def _impact_accretion(atmloss_module=None, atmloss_frac=0.0, **ppmw): - """Accretion sub-config: impactor volatiles and atmosphere loss (default off).""" +def _impact_accretion(atmloss_module=None, atmloss_frac=0.0, impactor_volatiles=None, **ppmw): + """Accretion sub-config: impactor volatiles and atmosphere loss (default off). + + The content mode defaults to 'ppmw' when per-element budgets are given and + to 'dry' otherwise, so a test states only the physics it exercises. + """ + if impactor_volatiles is None: + impactor_volatiles = 'ppmw' if any(v > 0.0 for v in ppmw.values()) else 'dry' return SimpleNamespace( + impactor_volatiles=impactor_volatiles, impactor_H_ppmw=ppmw.get('H', 0.0), impactor_C_ppmw=ppmw.get('C', 0.0), impactor_N_ppmw=ppmw.get('N', 0.0), @@ -659,16 +666,291 @@ def test_impact_strips_oxygen_with_the_other_atmospheric_elements(monkeypatch): ) +def _history(rows): + """Build a minimal helpfile history DataFrame for the formation lookup.""" + import pandas as pd + + return pd.DataFrame(rows) + + +def _converging_solve_structure(): + """Mock of solve_structure faithful to the root-finder's convergence state. + + The real solve moves R_int until the whole-planet mass matches the target: + at convergence ``M_planet = mass_tot * M_earth`` and the interior carries + what the volatile budgets do not, ``M_int = M_planet - M_ele``. The mock + reproduces exactly that end state (with the budgets it finds, mirroring + the config-driven recompute), so a test can check how apply_impact's mass + ledger and budget updates CLOSE into M_planet, which a no-op mock hides. + """ + from proteus.utils.constants import M_earth, element_list + + def _mock(dirs, config, hf_all, hf_row, outdir): + m_target = config.planet.mass_tot * M_earth + m_ele = sum(float(hf_row.get(f'{e}_kg_total', 0.0)) for e in element_list) + hf_row['M_int'] = m_target - m_ele + hf_row['M_ele'] = m_ele + hf_row['M_planet'] = m_target + + return _mock + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_impact_mass_closure_counts_each_volatile_channel_once(monkeypatch): + """The planet's mass closes to before + rock + delivered - stripped. + + The interior anchor (mass_tot) and the volatile budgets (M_ele) are the + two halves of M_planet, so each impact channel must land in exactly one + of them: the impactor's rock grows the anchor, its delivered volatiles + and the target strip move the budgets. Booking a channel in both halves + double-counts it: growing the anchor by the full merger mass while also + crediting the delivered content would inflate M_planet by the delivery, + and subtracting the strip from the anchor while also debiting the + budgets would remove it twice. + """ + from proteus.accretion.wrapper import apply_impact + from proteus.utils.constants import M_earth + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', + _converging_solve_structure(), + ) + + m_planet_0 = 6.0e24 + handler = _impact_handler( + mass_tot=m_planet_0 / M_earth, + accretion=_impact_accretion( + impactor_volatiles='match_planet', + atmloss_module='constant', + atmloss_frac=0.5, + ), + ) + handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) + handler.hf_all = _history([{'Time': 0.0, 'M_planet': m_planet_0, 'H_kg_total': 4.0e22}]) + # Half the hydrogen is atmospheric: the mirror loses half the impactor's + # content and the constant strip removes half the target atmosphere. + _atm_state(handler.hf_row, H=(2.0e22, 4.0e22)) + handler.hf_row['M_planet'] = m_planet_0 + + m_imp = 0.5 * M_earth + event = _impact_event( + M_target_before=m_planet_0, + M_impactor=m_imp, + M_merged_after=m_planet_0 + m_imp, + ) + apply_impact(handler, event) + + content = (4.0e22 / m_planet_0) * m_imp + delivered = 0.5 * content + stripped = 0.5 * 2.0e22 + rock = m_imp - content + + # The final whole-planet mass counts each channel exactly once. + m_ele_after = sum(v for k, v in handler.hf_row.items() if k.endswith('_kg_total')) + m_planet_after = handler.hf_row['M_int'] + m_ele_after + expected = m_planet_0 + rock + delivered - stripped + assert m_planet_after == pytest.approx(expected, rel=1e-9) + + # Discrimination: both double-counting failure modes sit far outside + # tolerance. Growing the anchor by the full merger mass over-counts the + # delivery (~1e21 kg); also subtracting the strip from the anchor + # under-counts it by another 1e22 kg. + assert abs(m_planet_after - (expected + delivered)) > 0.5 * delivered + assert abs(m_planet_after - (expected - stripped)) > 0.5 * stripped + + # The anchor itself grew by the impactor's rock alone. + assert handler.config.planet.mass_tot == pytest.approx( + (m_planet_0 + rock) / M_earth, rel=1e-12 + ) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_match_planet_impactor_carries_the_formation_composition(monkeypatch): + """A planet-matching impactor is scaled from the FORMATION state, not today. + + Every embryo co-formed from the same disk material, so the impactor + carries the planet's t=0 fractional abundances scaled to its own mass. + The planet here has since lost 90% of its hydrogen to escape; using the + live abundance instead of the formation one would deliver ten times less. + The formation row is the settled end of the init epoch (the last row + before one year), not the raw first row. + """ + from proteus.accretion.wrapper import apply_impact + from proteus.utils.constants import M_earth + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + m_planet_0 = 6.0e24 + x_h0 = 4.0e22 / m_planet_0 # formation H fraction + x_n0 = 2.0e21 / m_planet_0 + handler = _impact_handler(accretion=_impact_accretion(impactor_volatiles='match_planet')) + # Init epoch: an unsettled first row, then the settled formation row the + # lookup must select; both precede the 1 yr discriminator. + handler.hf_all = _history( + [ + {'Time': 0.0, 'M_planet': m_planet_0, 'H_kg_total': 1.0e21, 'N_kg_total': 1.0e19}, + {'Time': 0.0, 'M_planet': m_planet_0, 'H_kg_total': 4.0e22, 'N_kg_total': 2.0e21}, + {'Time': 5.0e2, 'M_planet': m_planet_0, 'H_kg_total': 4.0e21, 'N_kg_total': 2.0e21}, + ] + ) + # The planet TODAY holds only 10% of its formation hydrogen. + handler.hf_row['H_kg_total'] = 4.0e21 + handler.hf_row['N_kg_total'] = 2.0e21 + m_imp = 0.5 * M_earth + apply_impact(handler, _impact_event(M_impactor=m_imp)) + + # Delivery reflects the formation fractions (loss disabled: full content). + assert handler.hf_row['H_kg_total'] == pytest.approx(4.0e21 + x_h0 * m_imp, rel=1e-9) + assert handler.hf_row['N_kg_total'] == pytest.approx(2.0e21 + x_n0 * m_imp, rel=1e-9) + # Discrimination 1: the LIVE H abundance would deliver 10x less, a 1.8e22 + # kg difference, far outside tolerance. + x_h_live = 4.0e21 / m_planet_0 + assert abs(x_h0 * m_imp - x_h_live * m_imp) > 1.0e22 + # Discrimination 2: the unsettled first init row would deliver 40x less H + # than the settled formation row the lookup must pick. + assert x_h0 * m_imp > 40 * (1.0e21 / m_planet_0) * m_imp * 0.99 + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_match_planet_partition_mirror_and_fallback(monkeypatch): + """The impactor's loss split mirrors the planet, per element, with fallback. + + With loss active, each element's atmospheric (lost) fraction is the + planet's own at impact time: hydrogen here is half atmospheric, so half + the impactor's hydrogen is lost; nitrogen is fully dissolved, so all its + nitrogen arrives. An element the planet no longer holds cannot be + mirrored per-element and falls back to the planet's bulk atmospheric + fraction instead. + """ + from proteus.accretion.wrapper import apply_impact + from proteus.utils.constants import M_earth + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + m_planet_0 = 6.0e24 + handler = _impact_handler( + accretion=_impact_accretion( + impactor_volatiles='match_planet', atmloss_module='constant', atmloss_frac=0.0 + ) + ) + handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) + handler.hf_all = _history( + [ + { + 'Time': 0.0, + 'M_planet': m_planet_0, + 'H_kg_total': 4.0e22, + 'N_kg_total': 2.0e21, + 'C_kg_total': 1.0e21, + } + ] + ) + # Today: H half atmospheric, N fully dissolved, C fully escaped (no + # budget left to mirror). Bulk atm fraction = 2e21/6e21 = 1/3. + _atm_state(handler.hf_row, H=(2.0e21, 4.0e21), N=(0.0, 2.0e21)) + handler.hf_row['C_kg_total'] = 0.0 + m_imp = 0.5 * M_earth + apply_impact(handler, _impact_event(M_impactor=m_imp)) + + h_content = (4.0e22 / m_planet_0) * m_imp + n_content = (2.0e21 / m_planet_0) * m_imp + c_content = (1.0e21 / m_planet_0) * m_imp + # H: half lost (mirrors the planet's 50% atmospheric hydrogen). + assert handler.hf_row['H_kg_total'] == pytest.approx(4.0e21 + 0.5 * h_content, rel=1e-9) + # N: fully dissolved on the planet, so the impactor's N all arrives. + assert handler.hf_row['N_kg_total'] == pytest.approx(2.0e21 + n_content, rel=1e-9) + # C: fallback to the bulk atm fraction (1/3 lost, 2/3 delivered). + assert handler.hf_row['C_kg_total'] == pytest.approx((2.0 / 3.0) * c_content, rel=1e-9) + # Discrimination: a fallback of "deliver everything" would land a full + # third of the C content higher, resolvable at these magnitudes. + assert abs(handler.hf_row['C_kg_total'] - c_content) > 0.3 * c_content + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_a_small_impactor_stripping_a_heavy_atmosphere_shrinks_the_planet(monkeypatch): + """The whole-planet mass falls when losses beat accretion. + + A small dry impactor that blows off a much heavier atmosphere leaves the + planet lighter than before: the interior anchor still grows by the + accreted rock, but the stripped budgets pull the whole-planet mass below + its pre-impact value. + """ + from proteus.accretion.wrapper import apply_impact + from proteus.utils.constants import M_earth + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', + _converging_solve_structure(), + ) + + m_planet_0 = 6.0e24 + handler = _impact_handler( + mass_tot=m_planet_0 / M_earth, + accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=1.0), + ) + handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) + # Atmosphere of 2e23 kg; the impactor adds only 6.4e21 kg of rock. + _atm_state(handler.hf_row, H=(2.0e23, 5.0e23)) + event = _impact_event( + M_target_before=m_planet_0, M_impactor=6.4e21, M_merged_after=6.0064e24 + ) + apply_impact(handler, event) + + # The dry impactor's whole mass is rock: the anchor grows by all of it. + assert handler.config.planet.mass_tot == pytest.approx( + (m_planet_0 + 6.4e21) / M_earth, rel=1e-9 + ) + # The whole-planet mass shrank: rock in, a far heavier atmosphere out. + m_ele_after = sum(v for k, v in handler.hf_row.items() if k.endswith('_kg_total')) + m_planet_after = handler.hf_row['M_int'] + m_ele_after + assert m_planet_after == pytest.approx(m_planet_0 + 6.4e21 - 2.0e23, rel=1e-9) + assert m_planet_after < m_planet_0 # the planet got lighter + assert handler.hf_row['H_kg_total'] == pytest.approx(3.0e23, rel=1e-9) + + +@pytest.mark.unit +def test_match_planet_without_history_fails_loudly(): + """Planet-matching impactors need a usable formation state to scale from. + + With no helpfile history the impactor composition is undefined, and a + formation row without a positive planet mass cannot normalise the + fractions; both must refuse with an actionable error rather than deliver + zeros in silence. + """ + from proteus.accretion.wrapper import _impactor_volatile_content + + cfg = SimpleNamespace( + accretion=_impact_accretion(impactor_volatiles='match_planet'), + planet=SimpleNamespace(), + ) + with pytest.raises(RuntimeError, match='formation composition'): + _impactor_volatile_content(cfg, None, _impact_event()) + + # A degenerate formation row (no positive planet mass) is refused too. + broken = _history([{'Time': 0.0, 'M_planet': 0.0, 'H_kg_total': 1.0e21}]) + with pytest.raises(RuntimeError, match='M_planet'): + _impactor_volatile_content(cfg, broken, _impact_event()) + + @pytest.mark.unit @pytest.mark.physics_invariant def test_two_sequential_impacts_compose_their_consequences(monkeypatch): - """Each impact conserves, strips, and delivers against the state it finds. + """Each impact conserves and delivers against the state it finds. A Morrigan timeline routinely carries several impacts. The second impact must act on the post-first-impact budgets: conservation brackets its own - structure solve, the strip debits the atmosphere it finds, and the - delivery adds its own impactor's content, with the ledger accumulating - across both. + structure solve (proven against a rescaling solve both times) and the + delivery adds its own impactor's content on top of the first's. With + loss disabled the full content arrives and the planet grows by the full + merger mass each time. """ from proteus.accretion.wrapper import apply_impact from proteus.utils.constants import M_earth @@ -680,7 +962,7 @@ def test_two_sequential_impacts_compose_their_consequences(monkeypatch): handler = _impact_handler( mass_tot=1.0, - accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=0.5, H=1000.0), + accretion=_impact_accretion(H=1000.0), # ppmw mode, loss off ) handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) _atm_state(handler.hf_row, H=(2.0e20, 6.0e20)) @@ -693,29 +975,37 @@ def test_two_sequential_impacts_compose_their_consequences(monkeypatch): apply_impact(handler, event) delivered = m_imp * 1000.0 / 1.0e6 - after_first = 6.0e20 - 0.5 * 2.0e20 + delivered + after_first = 6.0e20 + delivered assert handler.hf_row['H_kg_total'] == pytest.approx(after_first, rel=1e-9) - # Second impact: same event again; the atmosphere was not re-equilibrated - # between them (no outgas call here), so the strip debits the same - # atmospheric reservoir and the delivery adds the same amount. + # Second impact: the conservation bracket must defeat the rescaling solve + # again, starting from the grown budget, and the delivery adds once more. apply_impact(handler, event) - after_second = after_first - 0.5 * 2.0e20 + delivered + after_second = after_first + delivered assert handler.hf_row['H_kg_total'] == pytest.approx(after_second, rel=1e-9) - # The planet grew twice and the ledger accumulated both strips. - assert handler.config.planet.mass_tot == pytest.approx(1.4, rel=1e-12) - assert handler.hf_row['esc_kg_cumulative'] == pytest.approx(2 * 1.0e20, rel=1e-9) + # Discrimination: an unbracketed second solve would carry a 1.2x rescale + # of after_first, over 1e20 kg above the correct composition. + assert abs(handler.hf_row['H_kg_total'] - (1.2 * after_first + delivered)) > 1.0e20 + # The anchor grew by each impactor's rock (merger mass minus content); + # the delivered volatiles reach the planet through the budgets instead. + expected_mass = 1.0 + 2 * (event.mass_delta - delivered) / M_earth + assert handler.config.planet.mass_tot == pytest.approx(expected_mass, rel=1e-12) + assert float(handler.hf_row.get('esc_kg_cumulative', 0.0)) == pytest.approx(0.0, abs=1.0) @pytest.mark.unit +@pytest.mark.physics_invariant def test_impact_loss_composes_with_delivery_and_a_broken_provider_raises(monkeypatch): - """Stripping acts on the pre-impact atmosphere, then delivery adds on top. - - The loss and the delivery are independent physical channels of one impact: - the shock strips what the planet had, the impactor's volatiles arrive - regardless. The final budget is (total - stripped) + delivered. A loss - module returning a fraction outside [0, 1] violates the partitioning - contract and must raise rather than be clamped in silence. + """With loss active, only the impactor's dissolved part arrives. + + One impact carries three volatile channels: the shock strips part of the + target's atmosphere, the impactor's own atmospheric volatiles are lost + with the collision, and its dissolved volatiles are delivered. The + impactor's split mirrors the planet's atmosphere fraction per element at + impact time (here exactly one third), and the planet's mass grows by the + merger mass net of everything lost. A loss module returning a fraction + outside [0, 1] violates the partitioning contract and must raise rather + than be clamped in silence. """ from proteus.accretion.wrapper import _impact_loss_fraction, apply_impact from proteus.utils.constants import M_earth @@ -725,22 +1015,40 @@ def test_impact_loss_composes_with_delivery_and_a_broken_provider_raises(monkeyp ) handler = _impact_handler( - accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=0.5, H=1000.0) + mass_tot=1.0, + accretion=_impact_accretion(atmloss_module='constant', atmloss_frac=0.5, H=1000.0), ) handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) + # One third of the planet's hydrogen sits in the atmosphere: the mirror + # then declares one third of the impactor's content atmospheric (lost) + # and delivers the remaining two thirds. _atm_state(handler.hf_row, H=(2.0e20, 6.0e20)) m_impactor = 0.5 * M_earth - apply_impact(handler, _impact_event(M_impactor=m_impactor)) + event = _impact_event(M_impactor=m_impactor) + mass_delta = event.mass_delta + apply_impact(handler, event) - delivered = m_impactor * 1000.0 / 1.0e6 - expected = (6.0e20 - 0.5 * 2.0e20) + delivered + content = m_impactor * 1000.0 / 1.0e6 + stripped = 0.5 * 2.0e20 + delivered = content * 2.0 / 3.0 + expected = 6.0e20 - stripped + delivered assert handler.hf_row['H_kg_total'] == pytest.approx(expected, rel=1e-9) - # Both channels are present at full size: the stripped 1e20 and the - # delivered 2.986e21 are each far larger than the tolerance, so a missing - # channel cannot pass. The tracked-element total reflects the composition. assert handler.hf_row['M_ele'] == pytest.approx(expected, rel=1e-9) - assert abs(handler.hf_row['H_kg_total'] - 6.0e20) > 1.0e20 # not "no strip, no delivery" - assert abs(handler.hf_row['H_kg_total'] - (6.0e20 + delivered)) > 5.0e19 # not "no strip" + # Discrimination: delivering the FULL content (no impactor-side loss) + # would land one third of the content higher, ~1e21 kg away. + assert abs(handler.hf_row['H_kg_total'] - (6.0e20 - stripped + content)) > 5.0e20 + # Only the target's stripped mass enters the planet's escape ledger; the + # impactor's lost volatiles never belonged to the planet's inventory. + assert handler.hf_row['esc_kg_cumulative'] == pytest.approx(stripped, rel=1e-9) + + # The interior anchor grew by the impactor's rock alone; the delivered + # and stripped volatiles reach the whole-planet mass through the budgets. + expected_mass = 1.0 + (mass_delta - content) / M_earth + assert handler.config.planet.mass_tot == pytest.approx(expected_mass, rel=1e-12) + # Discrimination: growing the anchor by the full merger mass would put + # the delivered content into the interior AND the budgets, resolvable + # far above the tolerance. + assert abs(handler.config.planet.mass_tot - (1.0 + mass_delta / M_earth)) > 1e-5 # A provider outside the contract is rejected loudly. bad = _impact_handler( diff --git a/tests/config/test_accretion.py b/tests/config/test_accretion.py index 1e36987a2..4c0e1f9b9 100644 --- a/tests/config/test_accretion.py +++ b/tests/config/test_accretion.py @@ -161,25 +161,42 @@ def test_targeted_selectors_require_a_selector_value(): @pytest.mark.unit def test_impactor_composition_drives_the_delivery_flag(): - """Delivery is on when any single element carries a positive budget. + """The delivery flag follows the content mode, then the ppmw budgets. The flag decides whether the impact handler touches the element - inventory at all, so it must respond to each element independently. A - flag wired to only one element would look correct in any test that set - hydrogen, which is why every element is checked in isolation here. + inventory at all. Dry impactors never deliver, whatever the ppmw fields + say; the planet-matching mode always can; the ppmw mode responds to + each element independently, since a flag wired to only one element + would look correct in any test that set hydrogen. """ from proteus.config._accretion import Accretion + # The default is a dry impactor with delivery off. + assert Accretion().impactor_volatiles == 'dry' assert Accretion().delivers_volatiles is False + # A ppmw budget under a mode that would ignore it is a configuration + # contradiction and is rejected at load rather than silently dropped: + # the identical config delivered hydrogen before the mode selector + # existed, so a silent dry run would invert the user's intent. + with pytest.raises(ValueError, match='ppmw'): + Accretion(impactor_H_ppmw=250.0) + with pytest.raises(ValueError, match='ppmw'): + Accretion(impactor_volatiles='match_planet', impactor_S_ppmw=10.0) + + # Planet-matching impactors always carry the planet's composition. + assert Accretion(impactor_volatiles='match_planet').delivers_volatiles is True + for element in ('H', 'C', 'N', 'S', 'O'): - cfg = Accretion(**{f'impactor_{element}_ppmw': 250.0}) + cfg = Accretion(impactor_volatiles='ppmw', **{f'impactor_{element}_ppmw': 250.0}) assert cfg.delivers_volatiles is True, f'{element} budget ignored' assert getattr(cfg, f'impactor_{element}_ppmw') == pytest.approx(250.0) - # A budget of exactly zero is the documented dry-impactor case and - # must not switch delivery on. - assert Accretion(impactor_H_ppmw=0.0).delivers_volatiles is False + # In ppmw mode a zero budget is the documented dry-impactor case and + # must not switch delivery on; an unregistered mode is rejected. + assert Accretion(impactor_volatiles='ppmw').delivers_volatiles is False + with pytest.raises(ValueError): + Accretion(impactor_volatiles='wet') # Negative budgets would remove volatiles at an impact, which is the # escape module's job, not delivery's. diff --git a/tests/tools/test_migrate_config_v2_to_v3.py b/tests/tools/test_migrate_config_v2_to_v3.py index 375ddb57c..38f548ed8 100644 --- a/tests/tools/test_migrate_config_v2_to_v3.py +++ b/tests/tools/test_migrate_config_v2_to_v3.py @@ -86,6 +86,7 @@ def _v3(): 'accretion.atmloss_frac', 'accretion.atmloss_module', 'accretion.dummy.timeline_path', + 'accretion.impactor_volatiles', 'accretion.impactor_C_ppmw', 'accretion.impactor_H_ppmw', 'accretion.impactor_N_ppmw', From adc8bcf51cc225115e37d5b903222db2219a6af0 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 24 Jul 2026 21:52:13 +0200 Subject: [PATCH 15/71] Compute impact atmosphere loss with the ZEPHYRUS erosion law The accretion coupling gains atmloss_module = "zephyrus", which evaluates the giant-impact erosion scaling law of Kegerreis et al. (2020) through zephyrus.collision.mass_loss. The law is fed entirely from the impact record, so the contact speed, masses, radii, densities, and angle stay in the one frame the dynamical model produced them in; Morrigan bodies carry no modelled atmosphere, matching the law's atmosphere-excluded conventions. An installation lacking the collision law produces an upgrade instruction at the first impact, and a planet whose atmosphere exceeds a few percent of its mass logs a warning that the fitted thin-atmosphere regime no longer covers the impact. One collision fraction now governs both bodies at each impact: the target loses that fraction of its atmosphere, and a volatile-bearing impactor loses the same fraction of its atmospheric part and delivers the remainder, so a fast head-on impact loses nearly all of it while a slow grazing one delivers most of it. The fraction is computed once per impact and passed to the strip sizing and the impactor partition; the constant module's fraction plays the same role on both sides, and the configuration documents the widened scope on every field it touches. The dispatch is pinned against the paper's closed form for identical twin bodies and against absolute fractions on both sides of the target/impactor mass assignment, so an argument-order regression fails the pins outright; the thin-atmosphere warning is pinned at its threshold from both sides. The validation inventory records the dispatch anchors. Verified in a coupled run against an independent evaluation of the law: the logged fraction, the stripped mass, the delivered and lost impactor volatiles, and the interior anchor growth all match hand computation exactly. --- docs/Validation/accretion/wrapper.md | 22 +++ docs/Validation/index.md | 1 + input/all_options.toml | 13 +- mkdocs.yml | 2 + src/proteus/accretion/wrapper.py | 126 ++++++++++++---- src/proteus/config/_accretion.py | 32 ++-- tests/accretion/test_wrapper.py | 211 ++++++++++++++++++++++++--- tests/config/test_accretion.py | 9 +- 8 files changed, 346 insertions(+), 70 deletions(-) create mode 100644 docs/Validation/accretion/wrapper.md diff --git a/docs/Validation/accretion/wrapper.md b/docs/Validation/accretion/wrapper.md new file mode 100644 index 000000000..b09fffbc2 --- /dev/null +++ b/docs/Validation/accretion/wrapper.md @@ -0,0 +1,22 @@ +# wrapper.py Validation + +## Source under test +`src/proteus/accretion/wrapper.py` (the impact atmosphere-loss dispatch: +`_impact_loss_fraction` with `accretion.atmloss_module = "zephyrus"`). + +## Reference-pinned tests + +| Test ID | Reference | What is pinned | +|---|---|---| +| `test_wrapper::test_zephyrus_loss_module_evaluates_the_kegerreis_law` | Kegerreis et al. (2020), ApJL 901, L31 (doi:10.3847/2041-8213/abb5fb), Eqn. 1 | The eroded atmosphere fraction the dispatch obtains from `zephyrus.collision.mass_loss` for an impact record of two identical Earth-like bodies head-on at their mutual escape speed, where the law collapses to `X = 0.64 * 0.5**0.325 = 0.510911`, pinned to `rel=1e-4`. Two asymmetric events (a half-radius impactor at one eighth the target mass, `b = 0.3`) pin the fraction on both sides of the target/impactor mass assignment (`0.2675` and `0.5258`, `rel=2e-3`), so a dispatch that interchanged the event's target and impactor fields would fail both absolute pins rather than survive as a permutation. | + +## Coverage + +The dispatch feeds the law entirely from the impact record, so the collision +speed, masses, radii, densities, and angle stay in the frame the dynamical +model produced them in; the record's `v_impact` is the speed at first contact +and its bodies carry no modelled atmosphere, matching the conventions of the +law (see the ZEPHYRUS validation page for the law's own anchors against the +paper's closed form and its Table 2 simulation suite). The dispatch-level +pins certify the record-to-argument mapping and the returned fraction's +bounds; the law's internal physics is certified in ZEPHYRUS. diff --git a/docs/Validation/index.md b/docs/Validation/index.md index c9f05b8d0..59439cf2d 100644 --- a/docs/Validation/index.md +++ b/docs/Validation/index.md @@ -12,6 +12,7 @@ test inventoried here. | Module | Source file | Page | |---|---|---| +| Accretion | `accretion/wrapper.py` | [Impact atmosphere-loss dispatch](accretion/wrapper.md) | | Interior structure | `interior_struct/zalmoxis.py` | [Liquidus-super IC anchor](interior_struct/zalmoxis.md) | | Orbit | `orbit/orbit.py` | [Orbital evolution](orbit/orbit.md) | | Orbit | `orbit/satellite.py` | [Satellite angular momentum](orbit/satellite.md) | diff --git a/input/all_options.toml b/input/all_options.toml index 21ac20f08..689cd2b6a 100644 --- a/input/all_options.toml +++ b/input/all_options.toml @@ -592,11 +592,14 @@ config_version = "3.0" impactor_S_ppmw = 0.0 # sulfur per impactor [ppmw of impactor mass] impactor_O_ppmw = 0.0 # oxygen per impactor [ppmw of impactor mass] - # Impact atmosphere loss. With a loss module active the target loses the - # configured fraction of its atmosphere at each impact and the impactor's - # own atmospheric volatiles are fully lost with the collision. - atmloss_module = "none" # none | constant - atmloss_frac = 0.0 # target atmosphere fraction removed per impact [0-1] + # Impact atmosphere loss. One fraction governs both bodies at each impact: + # the target loses that fraction of its atmosphere, and a volatile-bearing + # impactor loses the same fraction of its atmospheric part and delivers the + # remainder. The constant module applies the fixed fraction below; the + # zephyrus module evaluates the Kegerreis et al. (2020) erosion law from + # each impact's collision parameters. + atmloss_module = "none" # none | constant | zephyrus + atmloss_frac = 0.0 # atmosphere fraction removed per impact (constant module) [0-1] [accretion.morrigan] seed = 1 # Monte Carlo seed diff --git a/mkdocs.yml b/mkdocs.yml index 9393b6812..f667c28a6 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -76,6 +76,8 @@ nav: - Interior energetics wrapper: Reference/api/interior_energetics_wrapper.md - Validation: - Overview: Validation/index.md + - Accretion: + - Impact atmosphere-loss dispatch (wrapper.py): Validation/accretion/wrapper.md - Interior structure: - Liquidus-super IC anchor (zalmoxis.py): Validation/interior_struct/zalmoxis.md - Orbit: diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index ae60185db..a1a1fd098 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -131,9 +131,10 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: config.accretion.impactor_volatiles, config.accretion.atmloss_module or 'off', ) - strip = _target_strip_amounts(config, hf_row, event) + f_loss = _impact_loss_fraction(config, hf_row, event) + strip = _target_strip_amounts(config, hf_row, f_loss) content = _impactor_volatile_content(config, handler.hf_all, event) - delivered, impactor_lost = _partition_impactor_content(config, hf_row, content) + delivered, impactor_lost = _partition_impactor_content(config, hf_row, content, f_loss) # Snapshot the whole-planet volatile budgets before the structure re-solve. # solve_structure recomputes the ppmw-mode budgets against the grown mass, @@ -164,7 +165,7 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: # Apply the sized consequences to the whole-planet budgets and refresh # the tracked-element total the budgets aggregate into. - _apply_volatile_consequences(config, hf_row, strip, delivered, impactor_lost) + _apply_volatile_consequences(hf_row, strip, delivered, impactor_lost, f_loss) # Re-melt the mantle to its molten initial condition, so the interior # evolves from a fully molten state after the impact. @@ -194,7 +195,7 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: def _apply_volatile_consequences( - config, hf_row: dict, strip: dict, delivered: dict, impactor_lost: dict + hf_row: dict, strip: dict, delivered: dict, impactor_lost: dict, f_loss: float ) -> None: """Apply an impact's sized volatile changes to the whole-planet budgets. @@ -207,12 +208,12 @@ def _apply_volatile_consequences( Parameters ---------- - config : Config - Model configuration, read for the strip-percentage log line. hf_row : dict Current helpfile row, mutated in place. strip, delivered, impactor_lost : dict Per-element masses [kg] sized from the pre-impact state. + f_loss : float + Collision loss fraction in [0, 1], reported in the strip log line. """ for e, removed in strip.items(): key = f'{e}_kg_total' @@ -224,7 +225,7 @@ def _apply_volatile_consequences( ) log.info( ' impact stripped %.1f%% of the atmosphere: %.3e kg removed', - 100.0 * float(config.accretion.atmloss_frac), + 100.0 * f_loss, stripped_total, ) for e, added in delivered.items(): @@ -337,28 +338,41 @@ def _impactor_volatile_content(config, hf_all, event: ImpactEvent) -> dict: return content -def _partition_impactor_content(config, hf_row: dict, content: dict) -> tuple[dict, dict]: +def _partition_impactor_content( + config, hf_row: dict, content: dict, f_loss: float +) -> tuple[dict, dict]: """Split the impactor's volatiles into a delivered and a lost part [kg]. The impactor's internal partitioning is unknowable, so the planet's own atmosphere-versus-interior split per element at impact time is mirrored - onto it. With impact atmosphere loss active the impactor's atmospheric - part is lost with the collision (the impactor is disrupted and its - gravity is lower than the target's) and only the dissolved part is - delivered; with loss disabled the whole content is delivered. The mirror - understates a smaller body's atmospheric fraction (it equilibrates at - lower surface pressure), so delivery is somewhat overestimated; the - coming ZEPHYRUS collision law replaces this convention. + onto it. The impactor's atmospheric part is then lost with the same + collision loss fraction that strips the target's atmosphere, and the + remainder of its content is delivered: a fast head-on impact loses + nearly all of it, a slow grazing one delivers most of it, and with loss + disabled the whole content arrives. The mirror understates a smaller + body's atmospheric fraction (it equilibrates at lower surface + pressure), so delivery is somewhat overestimated. For an element the planet no longer holds, the per-element mirror is undefined and the planet's bulk atmospheric fraction is used instead. + Parameters + ---------- + config : Config + Model configuration; read for the loss-module switch. + hf_row : dict + Current helpfile row, supplying the partitioning mirror. + content : dict + Per-element volatile mass the impactor carries [kg]. + f_loss : float + Collision loss fraction in [0, 1] applied to the atmospheric part. + Returns ------- (delivered, lost) : tuple of dict Per-element masses delivered into the planet and lost to space [kg]. """ - if config.accretion.atmloss_module is None: + if config.accretion.atmloss_module is None or f_loss <= 0.0: return dict(content), {} # Bulk atmospheric fraction as the fallback mirror for elements the @@ -378,7 +392,7 @@ def _partition_impactor_content(config, hf_row: dict, content: dict) -> tuple[di f_atm = f_atm_bulk f_atm = min(max(f_atm, 0.0), 1.0) mirror[e] = f_atm - lost_e = mass * f_atm + lost_e = mass * f_atm * f_loss if lost_e > 0.0: lost[e] = lost_e if mass - lost_e > 0.0: @@ -392,7 +406,7 @@ def _partition_impactor_content(config, hf_row: dict, content: dict) -> tuple[di return delivered, lost -def _target_strip_amounts(config, hf_row: dict, event: ImpactEvent) -> dict: +def _target_strip_amounts(config, hf_row: dict, f_loss: float) -> dict: """Mass the impact strips from the target's atmosphere, per element [kg]. Sizes the debit from the pre-impact state without mutating it: the loss @@ -402,10 +416,18 @@ def _target_strip_amounts(config, hf_row: dict, event: ImpactEvent) -> dict: holds and the dissolved interior inventory is untouched. An atmosphere below the outgassing mass threshold is treated as nothing to strip, the same convention continuous escape applies to it. + + Parameters + ---------- + config : Config + Model configuration; read for the outgassing mass threshold. + hf_row : dict + Current helpfile row, read only. + f_loss : float + Collision loss fraction in [0, 1] from :func:`_impact_loss_fraction`. """ from proteus.escape.wrapper import calc_new_elements - f_loss = _impact_loss_fraction(config, hf_row, event) if f_loss <= 0.0: return {} @@ -431,14 +453,32 @@ def _target_strip_amounts(config, hf_row: dict, event: ImpactEvent) -> dict: return strip +# Atmosphere mass fraction above which the Kegerreis et al. (2020) erosion +# law leaves its fitted thin-atmosphere regime (of order 1 percent of the +# planet mass) far enough to warrant a warning. +_ATMLOSS_THIN_ATM_WARN = 0.03 + + def _impact_loss_fraction(config, hf_row: dict, event: ImpactEvent) -> float: """Fraction of the atmosphere removed by this impact [0-1]. Dispatches on ``accretion.atmloss_module``. The constant module returns - the configured fixed fraction and stands in for the coming ZEPHYRUS - collision-loss law, which will compute the fraction from the impact - parameters this function already receives; PROTEUS itself deliberately - ships no impact loss physics. + the configured fixed fraction; the zephyrus module evaluates the + giant-impact erosion scaling law of Kegerreis et al. (2020) through + ``zephyrus.collision.mass_loss``, fed entirely from the impact record so + the speed, masses, radii, densities, and angle stay in the one frame the + dynamical model produced them in (Morrigan bodies carry no modelled + atmosphere, matching the law's atmosphere-excluded mass and radius + convention, and its ``v_impact`` is the speed at first contact). The + returned fraction applies to the target's atmosphere and to a + volatile-bearing impactor's atmospheric part alike. PROTEUS itself ships + no impact loss physics. + + When the zephyrus law is selected and the planet's atmosphere exceeds a + few percent of its mass, the fitted thin-atmosphere regime no longer + covers the impact and a warning is logged; the fraction is still + returned, since staying inside the fitted domain is the run + configuration's responsibility. Parameters ---------- @@ -446,9 +486,9 @@ def _impact_loss_fraction(config, hf_row: dict, event: ImpactEvent) -> float: Model configuration; reads ``accretion.atmloss_module`` and ``accretion.atmloss_frac``. hf_row : dict - Current helpfile row (the planet state a loss law reads). + Current helpfile row (the planet state the domain check reads). event : ImpactEvent - The impact being applied (the collision parameters a loss law reads). + The impact being applied (the collision parameters the law reads). Returns ------- @@ -461,6 +501,9 @@ def _impact_loss_fraction(config, hf_row: dict, event: ImpactEvent) -> float: If a loss module returns a fraction outside [0, 1]. The debit partitioning is only meaningful on that interval, so a provider violating it is a contract error, not a value to clamp silently. + ImportError + If the zephyrus module is selected but the installed fwl-zephyrus + does not provide the collision law. """ module = config.accretion.atmloss_module if module is None: @@ -469,6 +512,39 @@ def _impact_loss_fraction(config, hf_row: dict, event: ImpactEvent) -> float: match module: case 'constant': f_loss = float(config.accretion.atmloss_frac) + case 'zephyrus': + try: + from zephyrus.collision import mass_loss + except ImportError as exc: + raise ImportError( + "accretion.atmloss_module = 'zephyrus' needs a fwl-zephyrus " + 'installation that provides zephyrus.collision; upgrade the ' + 'fwl-zephyrus package.' + ) from exc + + m_atm = sum(float(hf_row.get(f'{e}_kg_atm', 0.0)) for e in element_list) + m_planet = float(hf_row.get('M_planet', 0.0)) + if m_planet > 0.0 and m_atm / m_planet > _ATMLOSS_THIN_ATM_WARN: + log.warning( + ' the atmosphere is %.1f%% of the planet mass, beyond the ' + 'thin-atmosphere regime (about 1%%) the impact erosion law is ' + 'fitted for; the eroded fraction is extrapolated', + 100.0 * m_atm / m_planet, + ) + + f_loss = float( + mass_loss( + v_c=event.v_impact, + M_i=event.M_impactor, + M_t=event.M_target_before, + rho_i=event.rho_impactor, + rho_t=event.rho_target, + R_i=event.R_impactor, + R_t=event.R_target_before, + b=event.impact_parameter, + ) + ) + log.info(' impact erosion law: loss fraction %.3f', f_loss) case _: raise ValueError(f"Invalid accretion.atmloss_module: '{module}'") diff --git a/src/proteus/config/_accretion.py b/src/proteus/config/_accretion.py index 53f5f2103..822ef032d 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -181,9 +181,9 @@ class Accretion: all embryos co-formed from the same disk material), "ppmw" (the per-element ``impactor__ppmw`` budgets below). The content is split into an atmospheric and a dissolved part by mirroring the - planet's own partitioning at impact time; with impact atmosphere - loss active the atmospheric part is lost with the collision and - only the dissolved part is delivered. + planet's own partitioning at impact time; the atmospheric part + loses the same collision fraction that strips the target's + atmosphere and the remainder of the content is delivered. impactor_H_ppmw: float Hydrogen carried by each impactor [ppmw of impactor mass]. impactor_C_ppmw: float @@ -198,15 +198,18 @@ class Accretion: How impact atmosphere loss is computed. Choices: None (no impact atmosphere loss at all: the target keeps its atmosphere and a volatile-bearing impactor delivers its whole content), "constant" - (the fixed target fraction below). Whenever a loss module is - active, the impactor's own atmospheric volatiles are fully lost - with the collision, independently of the fraction below. A - ZEPHYRUS collision-loss law will become a further choice when - available; PROTEUS itself ships no impact loss physics. + (the fixed fraction below), "zephyrus" (the giant-impact erosion + scaling law of Kegerreis et al. 2020, evaluated by + ``zephyrus.collision.mass_loss`` from each impact's collision + parameters). One fraction governs both bodies at each impact: the + target loses that fraction of its atmosphere, and a + volatile-bearing impactor loses the same fraction of its + atmospheric part and delivers the remainder. PROTEUS itself ships + no impact loss physics. atmloss_frac: float - Fraction of the TARGET's atmosphere removed by each impact when - ``atmloss_module = "constant"`` [0-1]. Does not scale the - impactor-side loss. + Fraction of the atmosphere removed by each impact when + ``atmloss_module = "constant"`` [0-1]. Applies to the target's + atmosphere and to the impactor's atmospheric part alike. """ module: str | None = field( @@ -239,11 +242,12 @@ class Accretion: # every budget it guards populated. impactor_O_ppmw: float = field(default=0.0, validator=[ge(0), valid_impactor_volatiles]) - # Impact atmosphere loss. Disabled by default; the constant module is a - # placeholder with the call shape of the coming ZEPHYRUS collision law. + # Impact atmosphere loss. Disabled by default; the constant module + # applies a fixed fraction, the zephyrus module the Kegerreis et al. + # (2020) scaling law from each impact's collision parameters. atmloss_module: str | None = field( default='none', - validator=in_((None, 'constant')), + validator=in_((None, 'constant', 'zephyrus')), converter=none_if_none, ) atmloss_frac: float = field(default=0.0, validator=[ge(0), le(1)]) diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 7cd34b6c2..f96af0f24 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -742,7 +742,9 @@ def test_impact_mass_closure_counts_each_volatile_channel_once(monkeypatch): apply_impact(handler, event) content = (4.0e22 / m_planet_0) * m_imp - delivered = 0.5 * content + # Half the content is exposed by the mirror and half of that is lost + # with the collision, so three quarters arrive. + delivered = (1.0 - 0.5 * 0.5) * content stripped = 0.5 * 2.0e22 rock = m_imp - content @@ -837,7 +839,7 @@ def test_match_planet_partition_mirror_and_fallback(monkeypatch): m_planet_0 = 6.0e24 handler = _impact_handler( accretion=_impact_accretion( - impactor_volatiles='match_planet', atmloss_module='constant', atmloss_frac=0.0 + impactor_volatiles='match_planet', atmloss_module='constant', atmloss_frac=0.5 ) ) handler.config.outgas = SimpleNamespace(mass_thresh=1.0e10) @@ -853,7 +855,9 @@ def test_match_planet_partition_mirror_and_fallback(monkeypatch): ] ) # Today: H half atmospheric, N fully dissolved, C fully escaped (no - # budget left to mirror). Bulk atm fraction = 2e21/6e21 = 1/3. + # budget left to mirror). Bulk atm fraction = 2e21/6e21 = 1/3. The + # half-strength collision also strips half the target atmosphere, which + # the H expectation below accounts for. _atm_state(handler.hf_row, H=(2.0e21, 4.0e21), N=(0.0, 2.0e21)) handler.hf_row['C_kg_total'] = 0.0 m_imp = 0.5 * M_earth @@ -862,15 +866,22 @@ def test_match_planet_partition_mirror_and_fallback(monkeypatch): h_content = (4.0e22 / m_planet_0) * m_imp n_content = (2.0e21 / m_planet_0) * m_imp c_content = (1.0e21 / m_planet_0) * m_imp - # H: half lost (mirrors the planet's 50% atmospheric hydrogen). - assert handler.hf_row['H_kg_total'] == pytest.approx(4.0e21 + 0.5 * h_content, rel=1e-9) + # H: the target strip removes half its atmospheric hydrogen (1e21 kg), + # and the impactor's content, half exposed by the mirror, loses half of + # that exposed part, delivering three quarters. + assert handler.hf_row['H_kg_total'] == pytest.approx( + 4.0e21 - 0.5 * 2.0e21 + (1.0 - 0.5 * 0.5) * h_content, rel=1e-9 + ) # N: fully dissolved on the planet, so the impactor's N all arrives. assert handler.hf_row['N_kg_total'] == pytest.approx(2.0e21 + n_content, rel=1e-9) - # C: fallback to the bulk atm fraction (1/3 lost, 2/3 delivered). - assert handler.hf_row['C_kg_total'] == pytest.approx((2.0 / 3.0) * c_content, rel=1e-9) - # Discrimination: a fallback of "deliver everything" would land a full - # third of the C content higher, resolvable at these magnitudes. - assert abs(handler.hf_row['C_kg_total'] - c_content) > 0.3 * c_content + # C: fallback to the bulk atm fraction (1/3 exposed, half of that lost). + assert handler.hf_row['C_kg_total'] == pytest.approx( + (1.0 - (1.0 / 3.0) * 0.5) * c_content, rel=1e-9 + ) + # Discrimination: losing the whole exposed part (the fully-lost + # convention) would land the C budget at 2/3 of the content, a sixth of + # the content away, resolvable at these magnitudes. + assert abs(handler.hf_row['C_kg_total'] - (2.0 / 3.0) * c_content) > 0.1 * c_content @pytest.mark.unit @@ -996,14 +1007,13 @@ def test_two_sequential_impacts_compose_their_consequences(monkeypatch): @pytest.mark.unit @pytest.mark.physics_invariant def test_impact_loss_composes_with_delivery_and_a_broken_provider_raises(monkeypatch): - """With loss active, only the impactor's dissolved part arrives. - - One impact carries three volatile channels: the shock strips part of the - target's atmosphere, the impactor's own atmospheric volatiles are lost - with the collision, and its dissolved volatiles are delivered. The - impactor's split mirrors the planet's atmosphere fraction per element at - impact time (here exactly one third), and the planet's mass grows by the - merger mass net of everything lost. A loss module returning a fraction + """With loss active, one collision fraction governs both bodies. + + One impact carries three volatile channels: the shock strips the loss + fraction of the target's atmosphere, the impactor's atmospheric part + (mirrored from the planet, here exactly one third) loses the same + fraction, and everything else is delivered. The interior anchor grows + by the impactor's rock alone. A loss module returning a fraction outside [0, 1] violates the partitioning contract and must raise rather than be clamped in silence. """ @@ -1030,13 +1040,19 @@ def test_impact_loss_composes_with_delivery_and_a_broken_provider_raises(monkeyp content = m_impactor * 1000.0 / 1.0e6 stripped = 0.5 * 2.0e20 - delivered = content * 2.0 / 3.0 + # A third of the content is exposed by the mirror and half of that is + # lost with the collision, so five sixths arrive. + delivered = content * (1.0 - (1.0 / 3.0) * 0.5) expected = 6.0e20 - stripped + delivered assert handler.hf_row['H_kg_total'] == pytest.approx(expected, rel=1e-9) assert handler.hf_row['M_ele'] == pytest.approx(expected, rel=1e-9) - # Discrimination: delivering the FULL content (no impactor-side loss) - # would land one third of the content higher, ~1e21 kg away. - assert abs(handler.hf_row['H_kg_total'] - (6.0e20 - stripped + content)) > 5.0e20 + # Discrimination: both neighbouring conventions sit far outside + # tolerance, full delivery by half a sixth of the content (~5e20 kg) + # and a fully-lost exposed part by a further sixth. + assert abs(handler.hf_row['H_kg_total'] - (6.0e20 - stripped + content)) > 4.0e20 + assert ( + abs(handler.hf_row['H_kg_total'] - (6.0e20 - stripped + content * 2.0 / 3.0)) > 4.0e20 + ) # Only the target's stripped mass enters the planet's escape ledger; the # impactor's lost volatiles never belonged to the planet's inventory. assert handler.hf_row['esc_kg_cumulative'] == pytest.approx(stripped, rel=1e-9) @@ -1058,6 +1074,157 @@ def test_impact_loss_composes_with_delivery_and_a_broken_provider_raises(monkeyp _impact_loss_fraction(bad.config, bad.hf_row, _impact_event()) +@pytest.mark.unit +@pytest.mark.physics_invariant +@pytest.mark.reference_pinned +def test_zephyrus_loss_module_evaluates_the_kegerreis_law(monkeypatch): + """The zephyrus module turns the impact record into the erosion fraction. + + For two identical Earth-like bodies colliding head-on at their mutual + escape speed, Eqn. 1 of Kegerreis et al. (2020), ApJL 901, L31 collapses + to X = 0.64 * 0.5**0.325 = 0.510911, so the dispatch is pinned against + the published closed form through the real ZEPHYRUS implementation. The + twin pin cannot see the target/impactor mapping (every ratio is + symmetric there), so two asymmetric follow-up events pin the fraction + on BOTH sides of the mass assignment to their absolute values: a + dispatch that swapped the target and impactor masses would return + 0.526 where 0.267 is pinned and the reverse, failing both. (Radii + cannot discriminate here: at equal densities the interacting mass and + the mutual escape speed are both symmetric under a radius swap.) + """ + import numpy as np + + pytest.importorskip('zephyrus.collision') + from proteus.accretion.wrapper import _impact_loss_fraction + + m_e, r_e = 5.972e24, 6.371e6 + rho_e = m_e / (4.0 / 3.0 * np.pi * r_e**3) + v_esc = np.sqrt(2.0 * 6.6743e-11 * 2.0 * m_e / (2.0 * r_e)) + cfg = SimpleNamespace( + accretion=_impact_accretion(atmloss_module='zephyrus'), + ) + twins = _impact_event( + M_target_before=m_e, + M_impactor=m_e, + M_merged_after=2.0 * m_e, + v_impact=v_esc, + v_esc=v_esc, + impact_parameter=0.0, + R_target_before=r_e, + R_impactor=r_e, + rho_target=rho_e, + rho_impactor=rho_e, + ) + hf_row = {'M_planet': 6.3e24, 'H_kg_atm': 1.0e22} + + f = _impact_loss_fraction(cfg, hf_row, twins) + assert f == pytest.approx(0.510911, rel=1e-4) + assert 0.0 < f < 1.0 + + # Asymmetric event: a half-radius impactor at one eighth the mass. The + # mass-ratio term is the only tie-breaker, so pinning the fraction on + # both sides of the mass assignment fixes the dispatch's mapping. + r_i = 0.5 * r_e + m_i = rho_e * 4.0 / 3.0 * np.pi * r_i**3 + asym = _impact_event( + M_target_before=m_e, + M_impactor=m_i, + M_merged_after=m_e + m_i, + v_impact=v_esc, + impact_parameter=0.3, + R_target_before=r_e, + R_impactor=r_i, + rho_target=rho_e, + rho_impactor=rho_e, + ) + f_asym = _impact_loss_fraction(cfg, hf_row, asym) + swapped = _impact_event( + M_target_before=m_i, + M_impactor=m_e, + M_merged_after=m_e + m_i, + v_impact=v_esc, + impact_parameter=0.3, + R_target_before=r_e, + R_impactor=r_i, + rho_target=rho_e, + rho_impactor=rho_e, + ) + f_swapped = _impact_loss_fraction(cfg, hf_row, swapped) + # Absolute pins on both sides of the mass assignment: a dispatch with + # the target and impactor masses interchanged returns these two values + # permuted, failing both pins, where a difference-only check would + # survive the permutation unchanged. + assert f_asym == pytest.approx(0.2675, rel=2e-3) + assert f_swapped == pytest.approx(0.5258, rel=2e-3) + assert f_asym < f_swapped # the lighter impactor erodes less + + +@pytest.mark.unit +def test_zephyrus_loss_module_warns_outside_the_thin_atmosphere_regime(caplog): + """A thick atmosphere triggers the fitted-domain warning, a thin one not. + + The erosion law is fitted for atmospheres of order 1 percent of the + planet mass. The dispatch warns when the live atmosphere fraction is + beyond a few percent, and stays quiet inside the regime, so a + volatile-rich run cannot silently consume extrapolated fractions. The + fraction is still returned in both cases. + """ + import logging + + import numpy as np + + pytest.importorskip('zephyrus.collision') + from proteus.accretion.wrapper import _impact_loss_fraction + + m_e, r_e = 5.972e24, 6.371e6 + rho_e = m_e / (4.0 / 3.0 * np.pi * r_e**3) + cfg = SimpleNamespace(accretion=_impact_accretion(atmloss_module='zephyrus')) + event = _impact_event( + M_target_before=m_e, + M_impactor=m_e, + M_merged_after=2.0 * m_e, + v_impact=1.2e4, + R_target_before=r_e, + R_impactor=r_e, + rho_target=rho_e, + rho_impactor=rho_e, + ) + + # Just above the 3% threshold: the warning fires. Straddling the + # boundary pins the cutoff itself, not merely the warning's existence. + thick = {'M_planet': 6.0e24, 'H_kg_atm': 0.031 * 6.0e24} + with caplog.at_level(logging.WARNING, logger='fwl.proteus.accretion.wrapper'): + f_thick = _impact_loss_fraction(cfg, thick, event) + assert 0.0 <= f_thick <= 1.0 + assert 'thin-atmosphere regime' in '\n'.join(r.getMessage() for r in caplog.records) + + # Just below the threshold: no warning. + caplog.clear() + thin = {'M_planet': 6.0e24, 'H_kg_atm': 0.029 * 6.0e24} + with caplog.at_level(logging.WARNING, logger='fwl.proteus.accretion.wrapper'): + f_thin = _impact_loss_fraction(cfg, thin, event) + assert 0.0 <= f_thin <= 1.0 + assert 'thin-atmosphere regime' not in '\n'.join(r.getMessage() for r in caplog.records) + + +@pytest.mark.unit +def test_zephyrus_loss_module_without_the_law_fails_loudly(monkeypatch): + """A fwl-zephyrus lacking the collision law is an actionable error. + + The zephyrus loss module needs zephyrus.collision; an installation + predating it must produce an upgrade instruction at the first impact, + not an AttributeError from deep inside the dispatch. + """ + import sys + + from proteus.accretion.wrapper import _impact_loss_fraction + + cfg = SimpleNamespace(accretion=_impact_accretion(atmloss_module='zephyrus')) + monkeypatch.setitem(sys.modules, 'zephyrus.collision', None) + with pytest.raises(ImportError, match='fwl-zephyrus'): + _impact_loss_fraction(cfg, {'M_planet': 6.0e24}, _impact_event()) + + def _rescaling_solve_structure(factor): """Mock of solve_structure that rescales the volatile budgets by ``factor``. diff --git a/tests/config/test_accretion.py b/tests/config/test_accretion.py index 4c0e1f9b9..e17495f19 100644 --- a/tests/config/test_accretion.py +++ b/tests/config/test_accretion.py @@ -237,10 +237,11 @@ def test_atmloss_config_bounds_and_module_selection_bind_at_load(): with pytest.raises(ValueError): Accretion(atmloss_frac=-0.1) - # Unregistered loss modules are rejected at load; 'zephyrus' is the - # realistic future name and must fail until the law actually exists. - with pytest.raises(ValueError): - Accretion(atmloss_module='zephyrus') + # Both registered loss modules load; the zephyrus module needs no + # fraction because the law computes one per impact. + assert Accretion(atmloss_module='zephyrus').atmloss_module == 'zephyrus' + # Unregistered loss modules are rejected at load; the paper's author + # name is the realistic typo for the law's module. with pytest.raises(ValueError): Accretion(atmloss_module='kegerreis') From 0172c2a00bdfb8b3ac2f7af7902615da17f4b796 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 24 Jul 2026 22:13:19 +0200 Subject: [PATCH 16/71] Require the fwl-zephyrus release that provides the collision law The accretion coupling's zephyrus atmosphere-loss module imports zephyrus.collision, which ships from fwl-zephyrus 26.7.24 on, so the dependency floor moves there. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6c7ad0b84..45baae7ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ dependencies = [ "fwl-janus>=24.11.05", "fwl-mors>=26.01.02", "fwl-calliope>=26.06.01", - "fwl-zephyrus>=25.03.11", + "fwl-zephyrus>=26.7.24", "fwl-aragog>=26.07.04", # 26.07.17 reads the liquid EOS table for fully molten entropy # lookups, which the super-liquidus interior initial condition From f66d7d2268805ca3a43fab8b62907a2527622ccc Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sat, 25 Jul 2026 06:09:49 +0200 Subject: [PATCH 17/71] Install Morrigan from a pinned commit like the other optional modules Selecting the giant-impact accretion module told the user to clone Morrigan by hand, which leaves the checkout on whatever main happens to be that day. Every other external module PROTEUS pulls outside of pip resolves its URL and commit from a single table in pyproject.toml, and Morrigan now does too. Adds tools/get_morrigan.sh, which clones into Morrigan/, checks out the pinned commit, and installs it editable. It carries the same guard as the other installers, so a checkout with uncommitted work or unpushed commits is never deleted without --force. Morrigan stays out of the mandatory dependencies: only accretion.module = "morrigan" runs need it, and the package is not on PyPI. The two install hints, the one raised when the package is missing and the one in the module dependency check, now name the installer instead of a bare clone. --- pyproject.toml | 9 ++ src/proteus/accretion/morrigan.py | 3 +- src/proteus/config/_config.py | 6 +- tests/accretion/test_morrigan.py | 5 ++ tests/tools/test_install_scripts.py | 127 ++++++++++++++++++++++++++-- tools/get_morrigan.sh | 88 +++++++++++++++++++ 6 files changed, 228 insertions(+), 10 deletions(-) create mode 100755 tools/get_morrigan.sh diff --git a/pyproject.toml b/pyproject.toml index 45baae7ed..54abff586 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -287,6 +287,15 @@ ref = "c9a3fd4301c7008291d4f4921506d36b6288f8ca" url = "https://github.com/ExoInteriors/BOREAS.git" ref = "0174edb04558a92a8f0b47cbd994964787f495aa" +[tool.proteus.modules.morrigan] +# Giant-impact accretion module (Python). Optional: installed explicitly +# by tools/get_morrigan.sh, not pulled in by `pip install fwl-proteus`. +# The package is not published on PyPI, so the pin lives here rather than +# in [project] dependencies. Only `accretion.module = "morrigan"` runs +# need it. +url = "https://github.com/FormingWorlds/Morrigan.git" +ref = "f44671b6b6d17d7320f05f578641b61431e9a7ad" + [tool.proteus.modules.lovepy] # Multi-phase tidal heating module (Julia). Installed via Julia # Pkg.add(url=..., rev=...) by tools/get_lovepy.sh. Optional. diff --git a/src/proteus/accretion/morrigan.py b/src/proteus/accretion/morrigan.py index c8e1c10cf..258ff40f9 100644 --- a/src/proteus/accretion/morrigan.py +++ b/src/proteus/accretion/morrigan.py @@ -26,8 +26,7 @@ INSTALL_HINT = ( "accretion.module = 'morrigan' requires the morrigan package. " - 'Install it with: git clone git@github.com:FormingWorlds/Morrigan && ' - 'pip install -e Morrigan/.' + 'Install it with: bash tools/get_morrigan.sh' ) diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index c22e39aee..060f1ac44 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -109,9 +109,9 @@ def check_module_dependencies(instance, attribute, value): 'morrigan': ( instance.accretion.module == 'morrigan', 'morrigan', - 'accretion.module = "morrigan" requires the morrigan package, which ' - 'runs the giant-impact model. Install it with: ' - 'git clone git@github.com:FormingWorlds/Morrigan && pip install -e Morrigan/.', + 'accretion.module = "morrigan" requires the optional morrigan package, ' + 'which runs the giant-impact model. Morrigan is not needed for a ' + 'standard PROTEUS run. Install it with: bash tools/get_morrigan.sh', ), } diff --git a/tests/accretion/test_morrigan.py b/tests/accretion/test_morrigan.py index 7b4cad892..2549b648c 100644 --- a/tests/accretion/test_morrigan.py +++ b/tests/accretion/test_morrigan.py @@ -88,6 +88,11 @@ def test_missing_package_is_reported_with_an_install_hint(monkeypatch): with pytest.raises(ImportError, match='requires the morrigan package'): backend.require_morrigan() + # The message must name the installer that resolves the pinned commit, + # not a bare clone: an unpinned checkout is the failure this replaced. + assert 'tools/get_morrigan.sh' in backend.INSTALL_HINT + assert 'git clone' not in backend.INSTALL_HINT + # Installed but without the entry point: a different, specific message. monkeypatch.setattr(backend, 'morrigan', SimpleNamespace(), raising=False) with pytest.raises(ImportError, match='does not expose'): diff --git a/tests/tools/test_install_scripts.py b/tests/tools/test_install_scripts.py index f876704f6..e121b7d13 100644 --- a/tests/tools/test_install_scripts.py +++ b/tests/tools/test_install_scripts.py @@ -697,8 +697,8 @@ def test_ci_setup_installs_every_declared_extra(): # --------------------------------------------------------------------------- -def _extract_guard_block() -> str: - """Extract the shipped dirty-checkout guard from tools/get_aragog.sh. +def _extract_guard_block(script_name: str = 'get_aragog.sh') -> str: + """Extract the shipped dirty-checkout guard from a ``tools/get_*.sh``. Reading the block from the script under test (rather than copying it into the test) pins the exact shipped lines: any rewording or logic @@ -707,15 +707,19 @@ def _extract_guard_block() -> str: from pathlib import Path tools_dir = Path(__file__).resolve().parents[2] / 'tools' - script = (tools_dir / 'get_aragog.sh').read_text().splitlines() + script = (tools_dir / script_name).read_text().splitlines() start = next(i for i, ln in enumerate(script) if 'Refuse to delete a checkout' in ln) end = next(i for i, ln in enumerate(script) if ln.startswith('rm -rf')) return '\n'.join(script[start:end]) -def _run_guard(tmp_path, *args: str) -> subprocess.CompletedProcess: +def _run_guard( + tmp_path, *args: str, script_name: str = 'get_aragog.sh' +) -> subprocess.CompletedProcess: """Run the extracted guard with ``root`` pointing at ``tmp_path``.""" - snippet = 'root="$GUARD_ROOT"\n' + _extract_guard_block() + '\necho GUARD_PASSED\n' + snippet = ( + 'root="$GUARD_ROOT"\n' + _extract_guard_block(script_name) + '\necho GUARD_PASSED\n' + ) return subprocess.run( ['bash', '-c', snippet, 'guard', *args], capture_output=True, @@ -802,6 +806,119 @@ def test_guard_passes_clean_remote_backed_checkout(tmp_path): assert 'GUARD_PASSED' in res.stdout +def test_morrigan_guard_protects_its_own_checkout(tmp_path): + """The accretion installer guards the ``Morrigan/`` checkout it deletes. + + ``tools/get_morrigan.sh`` refreshes a sibling clone that a developer + may also be working in, so it carries the shared guard rather than + relying on the copy in another script. The cases run against the + block lifted out of the shipped file: a clean, remote-backed clone is + refreshed; a commit that exists on no remote blocks; ``--force`` + discards deliberately. The directory name is the discriminating part + here, since a guard copied verbatim from another installer would + inspect the wrong path and silently pass on a dirty Morrigan tree. + """ + block = _extract_guard_block('get_morrigan.sh') + assert 'Morrigan/' in block, 'the guard must inspect the Morrigan checkout' + assert 'get_morrigan.sh --force' in block, 'the recovery hint must name its own script' + # Discrimination: a block copied from the escape installer would still + # contain the guard logic but would point at the wrong tree. + assert 'BOREAS/' not in block and 'aragog/' not in block + + upstream = tmp_path / 'upstream' + upstream.mkdir() + _git(upstream, 'init', '-q') + (upstream / 'f.py').write_text('a = 1\n') + _git(upstream, 'add', 'f.py') + _git(upstream, 'commit', '-q', '-m', 'c1') + + workdir = tmp_path / 'Morrigan' + _git(tmp_path, 'clone', '-q', str(upstream), str(workdir)) + _git(workdir, 'checkout', '-q', '--detach', 'HEAD') + (workdir / 'morrigan.egg-info').write_text('') # untracked: must not block + + res = _run_guard(tmp_path, script_name='get_morrigan.sh') + assert res.returncode == 0 + assert 'GUARD_PASSED' in res.stdout + + # A local-only commit is exactly the state of a developer branch that + # has not been pushed; refreshing would destroy it. + (workdir / 'f.py').write_text('a = 2\n') + _git(workdir, 'add', 'f.py') + _git(workdir, 'commit', '-q', '-m', 'local work') + res = _run_guard(tmp_path, script_name='get_morrigan.sh') + assert res.returncode == 1 + assert 'not on a remote' in res.stderr + assert 'GUARD_PASSED' not in res.stdout + + res = _run_guard(tmp_path, '--force', script_name='get_morrigan.sh') + assert res.returncode == 0 + assert 'GUARD_PASSED' in res.stdout + + # Every installer that wipes a sibling git checkout carries the guard. + # Discovered from the shipped scripts so a newly added installer is + # covered without editing a list here. Scripts that unpack a download + # into the same variable (the PETSc archive) hold no local work and + # are correctly outside the sweep, which is why cloning is part of the + # predicate rather than deletion alone. + tools_dir = Path(__file__).resolve().parents[2] / 'tools' + sources = {p: p.read_text() for p in sorted(tools_dir.glob('get_*.sh'))} + refreshing = [ + p for p, src in sources.items() if 'rm -rf "$workpath"' in src and 'git clone' in src + ] + assert {p.name for p in refreshing} >= {'get_morrigan.sh', 'get_boreas.sh'}, ( + f'expected the sibling-checkout installers to be discovered, got {refreshing!r}' + ) + assert 'get_petsc.sh' not in {p.name for p in refreshing}, ( + 'the archive installer holds no git history and must stay out of the sweep' + ) + unguarded = [p.name for p in refreshing if 'Refuse to delete a checkout' not in sources[p]] + assert unguarded == [], f'installers wipe a git checkout with no guard: {unguarded!r}' + + +@pytest.mark.unit +def test_pyproject_keeps_morrigan_out_of_mandatory_dependencies(): + """Morrigan is installed only explicitly, via ``bash tools/get_morrigan.sh``. + + Two clauses: + 1. ``[project] dependencies`` must not list morrigan. The giant-impact + model is needed only by ``accretion.module = "morrigan"`` runs, and + the package is not published on PyPI, so a version pin there could + not resolve and a direct git URL would block PyPI uploads of + fwl-proteus. + 2. The pin lives in ``[tool.proteus.modules.morrigan]`` with the + FormingWorlds GitHub URL and a full 40-character commit SHA, which + tools/get_morrigan.sh resolves through tools/_module_pins.py. + """ + repo_root = Path(__file__).resolve().parents[2] + data = tomllib.loads((repo_root / 'pyproject.toml').read_text(encoding='utf-8')) + + deps = data['project']['dependencies'] + morrigan_deps = [d for d in deps if 'morrigan' in d.lower()] + assert morrigan_deps == [], ( + f'morrigan must not be a mandatory dependency of fwl-proteus: {morrigan_deps!r}' + ) + # Discrimination: an empty dependencies list would also pass the check + # above; pin a known-mandatory package as evidence the list is intact. + assert any('fwl-calliope' in d for d in deps), 'mandatory dependency list is intact' + + spec = data['tool']['proteus']['modules']['morrigan'] + assert spec['url'].startswith('https://github.com/FormingWorlds/Morrigan'), ( + f'morrigan pin must point at the FormingWorlds repo, got {spec["url"]!r}' + ) + # Full-SHA pin: reproducible clone, short refs are ambiguous and + # mutable upstream. + assert re.fullmatch(r'[0-9a-f]{40}', spec['ref']), ( + f'morrigan ref must be a full commit SHA, got {spec["ref"]!r}' + ) + # The URL must be clonable over both transports the installer offers; + # the https form is rewritten to SSH in-script, which only works when + # the pin is stored in https form. + assert not spec['url'].startswith('git@'), ( + 'store the pin as an https URL; the installer rewrites it to SSH' + ) + + # --------------------------------------------------------------------------- # Portable-flag rewrite and guards (tools/get_socrates.sh) # --------------------------------------------------------------------------- diff --git a/tools/get_morrigan.sh b/tools/get_morrigan.sh new file mode 100755 index 000000000..5ee1b7889 --- /dev/null +++ b/tools/get_morrigan.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Download and setup Morrigan (optional giant-impact accretion module) as +# an editable sibling checkout. +# +# Clones FormingWorlds/Morrigan into ./Morrigan/ inside the PROTEUS root, +# checks out the commit pinned in pyproject.toml +# ([tool.proteus.modules.morrigan]), and installs it editable into the +# active Python environment. Morrigan is not published on PyPI, so the +# pin is resolved from pyproject.toml rather than a version floor. + +set -euo pipefail + +echo "Set up Morrigan..." + +portable_realpath() { + if command -v realpath >/dev/null 2>&1; then + realpath "$1" + else + python3 -c "import os,sys; print(os.path.realpath(sys.argv[1]))" "$1" + fi +} + +# Path to PROTEUS folder +root=$(dirname "$(portable_realpath "$0")") +root=$(portable_realpath "$root/..") + +# Refuse to delete a checkout holding local work unless --force is given. +# Keep this guard in sync across the get_* scripts that refresh checkouts. +# Guarded states: modified tracked files, and commits not on any remote. +# Untracked files (build artifacts, egg-info) do not block the refresh. +force=false +for arg in "$@"; do + [ "$arg" = "--force" ] && force=true +done +workpath="$root/Morrigan/" +if [ -d "$workpath/.git" ] && [ "$force" != true ]; then + dirty=$(git -C "$workpath" status --porcelain --untracked-files=no 2>/dev/null | head -1) + unpushed=$(git -C "$workpath" log HEAD --not --remotes --oneline 2>/dev/null | head -1) + if [ -n "$dirty" ] || [ -n "$unpushed" ]; then + echo "ERROR: $workpath has uncommitted changes or commits not on a remote." >&2 + echo " Refusing to delete it. Commit and push your work, or run" >&2 + echo " bash tools/get_morrigan.sh --force to discard the checkout." >&2 + exit 1 + fi +fi + +# Make room +rm -rf "$workpath" + +# Detect SSH access to GitHub. `ssh -T git@github.com` exits 1 when +# authentication succeeds (GitHub refuses the shell), so a plain call +# would trip `set -e`; keeping it as the `if` condition keeps it in +# scope where a non-zero exit is expected rather than fatal. +if ssh -T git@github.com; then + use_ssh=false +else + if [ $? -eq 1 ]; then + use_ssh=true + else + use_ssh=false + fi +fi + +# Resolve the pinned URL + ref from pyproject.toml. +m_url=$(python "$root/tools/_module_pins.py" morrigan url) +m_ref=$(python "$root/tools/_module_pins.py" morrigan ref) +if [ -z "$m_url" ] || [ -z "$m_ref" ]; then + echo "ERROR: could not resolve morrigan url/ref from pyproject.toml" >&2 + exit 1 +fi + +echo "Cloning from GitHub" +if [ "$use_ssh" = true ]; then + # Rewrite https://github.com/ -> git@github.com: for SSH transport. + uri=${m_url/https:\/\/github.com\//git@github.com:} +else + uri="$m_url" +fi +echo " $uri @ $m_ref -> $workpath" +git clone "$uri" "$workpath" || { echo "ERROR: git clone failed" >&2; exit 1; } +git -C "$workpath" checkout --quiet "$m_ref" \ + || { echo "ERROR: cannot checkout $m_ref" >&2; exit 1; } + +# Install morrigan package as editable +pip install -U -e "$workpath" || { echo "ERROR: editable install failed" >&2; exit 1; } + +# Done +echo "Done!" From bffb273d34f9cb85cfbd7d7826d7006fbd386196 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sat, 25 Jul 2026 22:30:43 +0200 Subject: [PATCH 18/71] Correct the pre-run impact behaviour in the time_offset description Impacts that still land at or before the start of a run are discarded with a warning and their mass is not applied anywhere; the field description said they were folded into the initial condition, which would let a reader believe the planet starts already grown by them. --- src/proteus/config/_accretion.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/proteus/config/_accretion.py b/src/proteus/config/_accretion.py index 822ef032d..6724aca11 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -171,8 +171,10 @@ class Accretion: Offset applied to every impact time when mapping the timeline onto the PROTEUS time axis [yr]. A dynamical model measures time from disk dispersal, while PROTEUS measures it from the start of its - own evolution. Impacts landing before the start of the run are - folded into the initial condition. + own evolution. Impacts that still land at or before the start of + the run are discarded with a warning, and their mass is not + applied anywhere: the configured planet mass and orbit define the + initial state on their own. impactor_volatiles: str Where each impactor's volatile content comes from. Choices: "dry" (impactors carry rock and iron only), "match_planet" (every From f991e5c36eb2e36ec52b2fb10740cc5200368a5e Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sat, 25 Jul 2026 23:50:47 +0200 Subject: [PATCH 19/71] Depend on the published fwl-morrigan release instead of a commit Morrigan is on PyPI now, so it follows the same arrangement as VULCAN, Aragog and Zalmoxis: one version floor in the morrigan extra, and no second git pin that could drift from the published release. A plain `pip install "fwl-proteus[morrigan]"` is enough for a coupled accretion run, and both places that report the package missing now say so. This moves the model as well as the packaging. The old pin sat five commits behind the release, and one of those commits corrects the embryo layout, the ejection eccentricity and the per-system seeding. Accretion timelines produced on this branch before this change are therefore not reproducible after it: the same configuration and seed now give a different impact schedule. The capstone run was repeated against the release to confirm the coupled chain still behaves, and its schedule differs from the earlier one exactly as that correction predicts. The floor is written zero-padded, 26.07.25, because get_morrigan.sh checks out that string as a tag for an editable checkout; PEP 440 treats it as the same version as PyPI's normalised 26.7.25, so one string serves both. The floor is read with comments stripped, since the pin carries a rationale comment above it and a version named in prose would otherwise be picked up first and checked out as a tag that does not exist. Embryo spacing also gains an upper bound. It is measured in mutual Hill radii, and a value large enough to be a typo previously travelled all the way into the dynamical run before anything complained. Fifty is a typo guard and nothing more: the layout condition's pole moves with the embryo masses and with the cube root of the stellar mass, so a compact system around a low-mass host reaches it well below fifty, and the model's own check is what refuses such a layout by name. Morrigan also joins the optional-module install page and the module version table, which are generated from a list it was missing from. --- .gitignore | 3 + docs/How-to/optionalmodules_installation.md | 22 ++++ docs/Reference/module_versions.md | 13 +-- input/all_options.toml | 2 +- pyproject.toml | 18 ++-- src/proteus/accretion/morrigan.py | 3 +- src/proteus/config/_accretion.py | 18 +++- src/proteus/config/_config.py | 4 +- tests/config/test_accretion.py | 55 ++++++++++ tests/tools/test_install_scripts.py | 108 +++++++++++++++----- tools/generate_version_badges.py | 10 ++ tools/get_morrigan.sh | 49 +++++---- 12 files changed, 246 insertions(+), 59 deletions(-) diff --git a/.gitignore b/.gitignore index 5ebbb54a7..5fffd5ef4 100644 --- a/.gitignore +++ b/.gitignore @@ -314,3 +314,6 @@ src/proteus/_version.py # Local scratch directories (not part of the project) /.playwright-mcp/ /platon/ + +# Diagnostic dumps written by `proteus update` into the repo root. +/proteus_update_*.log diff --git a/docs/How-to/optionalmodules_installation.md b/docs/How-to/optionalmodules_installation.md index 781d1d779..ea7f5528b 100644 --- a/docs/How-to/optionalmodules_installation.md +++ b/docs/How-to/optionalmodules_installation.md @@ -88,3 +88,25 @@ bash tools/get_vulcan.sh !!! warning "License" VULCAN is distributed under the GPL-3.0 license; review its terms before installing. + +## Protoplanet accretion (Morrigan) + +Morrigan is an optional giant-impact accretion module, selected with +`accretion.module = "morrigan"`. It evolves a system of planetary embryos +through orbital crossings and collisions and returns the impact history +that the coupled run replays. It is not required for a standard PROTEUS +run. Install it from PyPI: + +```console +pip install "fwl-proteus[morrigan]" +``` + +For local development, install as an editable checkout instead: + +```console +bash tools/get_morrigan.sh +``` + +An accretion run needs an interior module that can re-melt the mantle +after an impact. Aragog is the production choice; SPIDER is refused at +configuration load because it has no re-melt path. diff --git a/docs/Reference/module_versions.md b/docs/Reference/module_versions.md index f3377303d..b976c63a3 100644 --- a/docs/Reference/module_versions.md +++ b/docs/Reference/module_versions.md @@ -21,9 +21,9 @@ in `[project] dependencies`. Click a badge to view the pinned release. | fwl-janus | 1D convective atmosphere | [![fwl-janus](https://img.shields.io/badge/fwl--janus-%3E%3D24.11.05-blue)](https://pypi.org/project/fwl-janus/24.11.05/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/JANUS/) | | fwl-mors | Stellar evolution | [![fwl-mors](https://img.shields.io/badge/fwl--mors-%3E%3D26.01.02-blue)](https://pypi.org/project/fwl-mors/26.01.02/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/MORS/) | | fwl-calliope | Volatile outgassing | [![fwl-calliope](https://img.shields.io/badge/fwl--calliope-%3E%3D26.06.01-blue)](https://pypi.org/project/fwl-calliope/26.06.01/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/CALLIOPE/) | -| fwl-zephyrus | Atmospheric escape | [![fwl-zephyrus](https://img.shields.io/badge/fwl--zephyrus-%3E%3D25.03.11-blue)](https://pypi.org/project/fwl-zephyrus/25.03.11/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/ZEPHYRUS) | -| fwl-aragog | Interior thermal evolution | [![fwl-aragog](https://img.shields.io/badge/fwl--aragog-%3E%3D26.05.13-blue)](https://pypi.org/project/fwl-aragog/26.05.13/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/aragog/) | -| fwl-zalmoxis | Interior structure | [![fwl-zalmoxis](https://img.shields.io/badge/fwl--zalmoxis-%3E%3D26.05.13-blue)](https://pypi.org/project/fwl-zalmoxis/26.05.13/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/Zalmoxis/) | +| fwl-zephyrus | Atmospheric escape | [![fwl-zephyrus](https://img.shields.io/badge/fwl--zephyrus-%3E%3D26.7.24-blue)](https://pypi.org/project/fwl-zephyrus/26.7.24/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/ZEPHYRUS) | +| fwl-aragog | Interior thermal evolution | [![fwl-aragog](https://img.shields.io/badge/fwl--aragog-%3E%3D26.07.04-blue)](https://pypi.org/project/fwl-aragog/26.07.04/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/aragog/) | +| fwl-zalmoxis | Interior structure | [![fwl-zalmoxis](https://img.shields.io/badge/fwl--zalmoxis-%3E%3D26.07.17-blue)](https://pypi.org/project/fwl-zalmoxis/26.07.17/){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/Zalmoxis/) | ### Git-pinned modules (non-PyPI) @@ -35,8 +35,8 @@ pinned commit. | Module | Role | Pin | Docs | |--------|------|-----|------| -| AGNI | Radiative-convective atmosphere (Julia) | [![AGNI](https://img.shields.io/badge/AGNI-179472b3-green)](https://github.com/nichollsh/AGNI/commit/179472b36b14e15bb125666cd8c9c6f231a2e907){target="_blank" rel="noopener"} | [Docs](https://www.h-nicholls.space/AGNI/) | -| SOCRATES | Spectral radiative transfer (Fortran) | [![SOCRATES](https://img.shields.io/badge/SOCRATES-fe0c4a48-green)](https://github.com/FormingWorlds/SOCRATES/commit/fe0c4a486f1dbf9addf6f01a03c644d6bd5e1d1e){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/SOCRATES/) | +| AGNI | Radiative-convective atmosphere (Julia) | [![AGNI](https://img.shields.io/badge/AGNI-59ce1d4c-green)](https://github.com/nichollsh/AGNI/commit/59ce1d4cda84f2043a8055682fa49c2f9c27c3c4){target="_blank" rel="noopener"} | [Docs](https://www.h-nicholls.space/AGNI/) | +| SOCRATES | Spectral radiative transfer (Fortran) | [![SOCRATES](https://img.shields.io/badge/SOCRATES-f42ddec0-green)](https://github.com/FormingWorlds/SOCRATES/commit/f42ddec0356b3811b260cfe21a7f2c06033b39bd){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/SOCRATES/) | | SPIDER | Interior evolution (C, requires PETSc) | [![SPIDER](https://img.shields.io/badge/SPIDER-c9a3fd43-green)](https://github.com/FormingWorlds/SPIDER/commit/c9a3fd4301c7008291d4f4921506d36b6288f8ca){target="_blank" rel="noopener"} | [Docs](https://proteus-framework.org/SPIDER/) | @@ -46,8 +46,9 @@ pinned commit. | Module | Role | Pin | Docs | |--------|------|-----|------| | LovePy | Multi-phase tidal heating (Julia) | [![LovePy](https://img.shields.io/badge/LovePy-main-lightgrey)](https://github.com/nichollsh/LovePy){target="_blank" rel="noopener"} | [GitHub](https://github.com/nichollsh/LovePy) | -| atmodeller | Alternative outgassing backend (GPL-3.0) | [![atmodeller](https://img.shields.io/badge/atmodeller-%3E%3D1.0.0-blue)](https://pypi.org/project/atmodeller/1.0.0/){target="_blank" rel="noopener"} | [GitHub](https://github.com/djbower/atmodeller) | +| atmodeller | Alternative outgassing backend (GPL-3.0) | [![atmodeller](https://img.shields.io/badge/atmodeller-%3E%3D1.0.2-blue)](https://pypi.org/project/atmodeller/1.0.2/){target="_blank" rel="noopener"} | [GitHub](https://github.com/djbower/atmodeller) | | VULCAN | Atmospheric chemistry (GPL-3.0) | [![VULCAN](https://img.shields.io/badge/VULCAN-%3E%3D26.04.22-blue)](https://pypi.org/project/fwl-vulcan/26.04.22/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/VULCAN) | +| Morrigan | Protoplanet accretion via giant impacts | [![Morrigan](https://img.shields.io/badge/Morrigan-%3E%3D26.07.25-blue)](https://pypi.org/project/fwl-morrigan/26.07.25/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/Morrigan) | | Obliqua | Orbital evolution and tides (Julia) | n/a | [GitHub](https://github.com/FormingWorlds/Obliqua) | diff --git a/input/all_options.toml b/input/all_options.toml index 689cd2b6a..dc365741a 100644 --- a/input/all_options.toml +++ b/input/all_options.toml @@ -608,7 +608,7 @@ config_version = "3.0" mass_equal = 0.5 # embryo mass when masses is empty [M_earth] eccentricity_init = 0.01 # initial eccentricity of every embryo inner_edge = 0.1 # orbit of the innermost embryo [AU] - spacing = 10.0 # initial embryo separation [mutual Hill radii] + spacing = 10.0 # initial embryo separation [mutual Hill radii]; max 50 density = 5500.0 # bulk density for mass to radius [kg m-3] impact_angle = 45.0 # impact angle [deg] evolution_time = 1.0 # duration of the dynamical evolution [Gyr] diff --git a/pyproject.toml b/pyproject.toml index 54abff586..acbe6a582 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,11 @@ atmodeller = ["atmodeller>=1.0.2"] # atmos_chem.module = "vulcan". Also installable editable via # tools/get_vulcan.sh. vulcan = ["fwl-vulcan>=26.04.22"] +# morrigan: protoplanet accretion module, selected via +# accretion.module = "morrigan". Supplies the giant-impact timeline the +# accretion coupling replays. Also installable editable via +# tools/get_morrigan.sh. +morrigan = ["fwl-morrigan>=26.07.25"] develop = [ # coverage[toml] enables standalone coverage tool with TOML config support (used by ratcheting script) @@ -287,14 +292,11 @@ ref = "c9a3fd4301c7008291d4f4921506d36b6288f8ca" url = "https://github.com/ExoInteriors/BOREAS.git" ref = "0174edb04558a92a8f0b47cbd994964787f495aa" -[tool.proteus.modules.morrigan] -# Giant-impact accretion module (Python). Optional: installed explicitly -# by tools/get_morrigan.sh, not pulled in by `pip install fwl-proteus`. -# The package is not published on PyPI, so the pin lives here rather than -# in [project] dependencies. Only `accretion.module = "morrigan"` runs -# need it. -url = "https://github.com/FormingWorlds/Morrigan.git" -ref = "f44671b6b6d17d7320f05f578641b61431e9a7ad" +# Morrigan has no entry here on purpose: like fwl-vulcan, fwl-aragog and +# fwl-zalmoxis, it is a single-source PyPI package. tools/get_morrigan.sh +# checks out the git tag matching the fwl-morrigan floor in +# [project.optional-dependencies], so the editable checkout and the PyPI +# release cannot diverge. [tool.proteus.modules.lovepy] # Multi-phase tidal heating module (Julia). Installed via Julia diff --git a/src/proteus/accretion/morrigan.py b/src/proteus/accretion/morrigan.py index 258ff40f9..a15900869 100644 --- a/src/proteus/accretion/morrigan.py +++ b/src/proteus/accretion/morrigan.py @@ -26,7 +26,8 @@ INSTALL_HINT = ( "accretion.module = 'morrigan' requires the morrigan package. " - 'Install it with: bash tools/get_morrigan.sh' + 'Install it with: pip install "fwl-proteus[morrigan]" ' + '(or bash tools/get_morrigan.sh for an editable checkout).' ) diff --git a/src/proteus/config/_accretion.py b/src/proteus/config/_accretion.py index 6724aca11..480254de3 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -63,6 +63,22 @@ class Morrigan: Semi-major axis of the innermost embryo [AU]. spacing: float Initial separation between adjacent embryos, in mutual Hill radii. + Typical values are 5 to 15; beyond roughly 30 the system does not + go unstable within any useful evolution time, so the run finishes + with no impacts. Capped at 50 purely to catch an + order-of-magnitude mistake at configuration load. + + The cap is not the physical limit and does not track it. The + layout condition has a pole where the requested gap approaches + the span it is measured across, and its position scales with the + embryo masses and with the cube root of the stellar mass: near 74 + mutual Hill radii for a pair of ten-Earth-mass embryos around a + solar-mass star, but near 34 for the same pair around a + 0.1-solar-mass host. A spacing this validator accepts can + therefore still be too wide for a compact, low-mass-host system. + The dynamical model applies the exact condition and refuses such + a layout by name, so that check, not this cap, is what guarantees + a valid layout. density: float Uniform bulk density used to convert embryo mass to radius [kg m-3]. impact_angle: float @@ -91,7 +107,7 @@ class Morrigan: eccentricity_init: float = field(default=0.01, validator=ge(0)) inner_edge: float = field(default=0.1, validator=gt(0)) - spacing: float = field(default=10.0, validator=gt(0)) + spacing: float = field(default=10.0, validator=[gt(0), le(50.0)]) density: float = field(default=5500.0, validator=gt(0)) impact_angle: float = field(default=45.0, validator=ge(0)) diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index 060f1ac44..b7a4ed38f 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -111,7 +111,9 @@ def check_module_dependencies(instance, attribute, value): 'morrigan', 'accretion.module = "morrigan" requires the optional morrigan package, ' 'which runs the giant-impact model. Morrigan is not needed for a ' - 'standard PROTEUS run. Install it with: bash tools/get_morrigan.sh', + 'standard PROTEUS run. Install it with: pip install ' + '"fwl-proteus[morrigan]" (or bash tools/get_morrigan.sh for an ' + 'editable checkout).', ), } diff --git a/tests/config/test_accretion.py b/tests/config/test_accretion.py index e17495f19..0cf2e8e4d 100644 --- a/tests/config/test_accretion.py +++ b/tests/config/test_accretion.py @@ -328,3 +328,58 @@ def test_accretion_on_spider_is_refused_at_config_load(): ) # No accretion: SPIDER is fine, the check does not fire. check_accretion_interior_compatibility(_compat_instance(None, 'spider'), None, None) + + +def test_embryo_spacing_is_bounded_on_both_sides(): + """Embryo spacing is refused at zero and above the sanity ceiling. + + Spacing is measured in mutual Hill radii, so it must be strictly + positive, and a value large enough to be an order-of-magnitude + mistake is refused at configuration load rather than after the + dynamical run has started. + + The ceiling is a typo guard, not the physical limit. The layout + condition's pole sits where ``spacing * ((M1+M2)/(3 M*))**(1/3)`` + reaches 2, so it moves with the embryo masses and with the cube root + of the stellar mass; for a compact system around a low-mass host it + drops below this ceiling. The test pins that scaling rather than + asserting the ceiling is universally conservative, because it is not. + """ + from proteus.config._accretion import Morrigan + + # The documented working range and the inclusive ceiling are accepted. + for value in (0.5, 10.0, 30.0, 50.0): + assert Morrigan(spacing=value).spacing == pytest.approx(value) + + # Just past the ceiling, and an order-of-magnitude typo, are refused. + for value in (50.000001, 1e3): + with pytest.raises(ValueError): + Morrigan(spacing=value) + + # Non-positive spacing has no geometric meaning and is refused. + for value in (0.0, -10.0): + with pytest.raises(ValueError): + Morrigan(spacing=value) + + # The pole the dynamical model enforces, for reference. Around a + # solar-mass star the ceiling does sit below it, and the pole moves + # outward as the embryos get lighter. + m_earth, m_sun = 5.972e24, 1.988e30 + + def pole(mass_earth, stellar_mass_sun): + """Spacing at which the layout condition's denominator vanishes.""" + return 2.0 / ((2 * mass_earth * m_earth) / (3 * stellar_mass_sun * m_sun)) ** (1 / 3) + + assert 50.0 < pole(10.0, 1.0) == pytest.approx(73.6, rel=1e-2) + assert pole(10.0, 1.0) < pole(1.0, 1.0) + + # But the ceiling is NOT universally conservative: the pole scales as + # the cube root of the stellar mass, so a compact system around a + # 0.1-solar-mass host reaches it below 50 and the model, not this + # validator, is what refuses the layout. Guarding this keeps the + # docstring honest if someone later raises the ceiling on the + # assumption that it bounds the pole. + assert pole(10.0, 0.1) == pytest.approx(34.2, rel=1e-2) + assert pole(10.0, 0.1) < 50.0 + # The scaling itself: an eighth of the stellar mass halves the pole. + assert pole(10.0, 0.125) == pytest.approx(0.5 * pole(10.0, 1.0), rel=1e-9) diff --git a/tests/tools/test_install_scripts.py b/tests/tools/test_install_scripts.py index e121b7d13..ea37d1d78 100644 --- a/tests/tools/test_install_scripts.py +++ b/tests/tools/test_install_scripts.py @@ -495,6 +495,7 @@ def test_spider_lib_check_fails_on_empty_dir(tmp_path): import re # noqa: E402 +import tempfile # noqa: E402 import tomllib # noqa: E402 from pathlib import Path # noqa: E402 @@ -878,17 +879,20 @@ def test_morrigan_guard_protects_its_own_checkout(tmp_path): @pytest.mark.unit def test_pyproject_keeps_morrigan_out_of_mandatory_dependencies(): - """Morrigan is installed only explicitly, via ``bash tools/get_morrigan.sh``. - - Two clauses: - 1. ``[project] dependencies`` must not list morrigan. The giant-impact - model is needed only by ``accretion.module = "morrigan"`` runs, and - the package is not published on PyPI, so a version pin there could - not resolve and a direct git URL would block PyPI uploads of - fwl-proteus. - 2. The pin lives in ``[tool.proteus.modules.morrigan]`` with the - FormingWorlds GitHub URL and a full 40-character commit SHA, which - tools/get_morrigan.sh resolves through tools/_module_pins.py. + """Morrigan is an optional extra, pinned once by version. + + The giant-impact model is needed only by ``accretion.module = + "morrigan"`` runs, so it must not be a mandatory dependency of + fwl-proteus. It lives in ``[project.optional-dependencies]`` under its + own extra, carrying a published version floor, and must NOT also carry + a ``[tool.proteus.modules]`` SHA pin: a second pin can drift from the + PyPI release, which is the dual-pin trap fwl-vulcan, fwl-aragog and + fwl-zalmoxis are all kept out of. + + The floor is written zero-padded to match the release tag, because + tools/get_morrigan.sh checks out ``tags/`` for an editable + checkout. PEP 440 treats the padded and normalised forms as the same + version, so one string serves the resolver and the tag lookup. """ repo_root = Path(__file__).resolve().parents[2] data = tomllib.loads((repo_root / 'pyproject.toml').read_text(encoding='utf-8')) @@ -902,20 +906,78 @@ def test_pyproject_keeps_morrigan_out_of_mandatory_dependencies(): # above; pin a known-mandatory package as evidence the list is intact. assert any('fwl-calliope' in d for d in deps), 'mandatory dependency list is intact' - spec = data['tool']['proteus']['modules']['morrigan'] - assert spec['url'].startswith('https://github.com/FormingWorlds/Morrigan'), ( - f'morrigan pin must point at the FormingWorlds repo, got {spec["url"]!r}' + extras = data['project']['optional-dependencies'] + morrigan_extra = extras.get('morrigan', []) + assert any(r.startswith('fwl-morrigan>=') for r in morrigan_extra), ( + f'morrigan extra must keep its version floor, got {morrigan_extra!r}' ) - # Full-SHA pin: reproducible clone, short refs are ambiguous and - # mutable upstream. - assert re.fullmatch(r'[0-9a-f]{40}', spec['ref']), ( - f'morrigan ref must be a full commit SHA, got {spec["ref"]!r}' + + # Single pin: a git SHA alongside the version floor could drift from the + # published release, so the module table must not carry morrigan. + git_modules = data['tool']['proteus']['modules'] + assert 'morrigan' not in git_modules, ( + 'morrigan must not have a [tool.proteus.modules] git pin; it is pinned ' + 'once via the fwl-morrigan extra and the matching git tag, like ' + f'fwl-vulcan/fwl-aragog/fwl-zalmoxis. Found: {sorted(git_modules)}' ) - # The URL must be clonable over both transports the installer offers; - # the https form is rewritten to SSH in-script, which only works when - # the pin is stored in https form. - assert not spec['url'].startswith('git@'), ( - 'store the pin as an https URL; the installer rewrites it to SSH' + + # The floor must be tag-shaped (zero-padded CalVer), because the installer + # checks out `tags/`. A normalised floor such as 26.7.25 resolves + # against PyPI but names no tag, so the editable install would break. + floor = next(r for r in morrigan_extra if r.startswith('fwl-morrigan>=')).split('>=')[1] + assert re.fullmatch(r'\d{2}\.\d{2}\.\d{2}', floor), ( + f'morrigan floor must be zero-padded CalVer to match the release tag, got {floor!r}' + ) + + # The installer reads the floor with this exact pattern; keep the two in + # step so a reformatted pin cannot silently fall back to HEAD. + script = (repo_root / 'tools' / 'get_morrigan.sh').read_text(encoding='utf-8') + assert 'fwl-morrigan>=' in script and 'tags/$floor' in script, ( + 'tools/get_morrigan.sh must pin the checkout to the fwl-morrigan floor tag' + ) + + # The extraction must read the pin, not a comment mentioning the package. + # The pin already carries a rationale comment above it, and the repo's + # house style puts such comments on the preceding lines, so a plain + # first-match grep would take a version named in prose. Run the script's + # own pipeline against a poisoned copy and require it to still pick the + # real floor. + # Run the script's OWN assignment, lifted verbatim, so a regression in the + # script is what fails here rather than a copy of it kept in the test. + assignment = re.search(r'^floor=\$\(.*?\)$', script, re.MULTILINE | re.DOTALL) + assert assignment, 'could not find the floor assignment in tools/get_morrigan.sh' + + poisoned = ( + (repo_root / 'pyproject.toml') + .read_text(encoding='utf-8') + .replace( + f'morrigan = ["fwl-morrigan>={floor}"]', + f'# later: needs fwl-morrigan>=99.99.99\nmorrigan = ["fwl-morrigan>={floor}"]', + ) + ) + with tempfile.TemporaryDirectory() as tmp: + probe = Path(tmp) / 'pyproject.toml' + probe.write_text(poisoned, encoding='utf-8') + extracted = subprocess.run( + [ + 'bash', + '-c', + f'set -euo pipefail; root={tmp}\n{assignment.group(0)}\necho "$floor"', + ], + capture_output=True, + text=True, + ).stdout.strip() + assert extracted == floor, ( + f'floor extraction picked {extracted!r} from a commented version instead of ' + f'the pin {floor!r}; get_morrigan.sh would check out a tag that does not exist' + ) + + # A missing pin must reach the warning branch rather than aborting the + # script under `set -e`, which would leave an uninstalled clone behind + # with no diagnostic. + assert '|| true' in script, ( + 'floor extraction must not abort the script; the warning branch is the ' + 'documented behaviour when the pin cannot be read' ) diff --git a/tools/generate_version_badges.py b/tools/generate_version_badges.py index 143b90c0c..5fc428fcd 100644 --- a/tools/generate_version_badges.py +++ b/tools/generate_version_badges.py @@ -100,6 +100,16 @@ 'GitHub', ('vulcan', 'fwl-vulcan'), ), + ( + 'Morrigan', + 'Protoplanet accretion via giant impacts', + None, + 'blue', + None, + 'https://github.com/FormingWorlds/Morrigan', + 'GitHub', + ('morrigan', 'fwl-morrigan'), + ), ( 'Obliqua', 'Orbital evolution and tides (Julia)', diff --git a/tools/get_morrigan.sh b/tools/get_morrigan.sh index 5ee1b7889..e79bfd122 100755 --- a/tools/get_morrigan.sh +++ b/tools/get_morrigan.sh @@ -3,10 +3,13 @@ # an editable sibling checkout. # # Clones FormingWorlds/Morrigan into ./Morrigan/ inside the PROTEUS root, -# checks out the commit pinned in pyproject.toml -# ([tool.proteus.modules.morrigan]), and installs it editable into the -# active Python environment. Morrigan is not published on PyPI, so the -# pin is resolved from pyproject.toml rather than a version floor. +# checks out the git tag matching the fwl-morrigan version floor pinned in +# pyproject.toml ([project.optional-dependencies].morrigan), and installs it +# editable. Pinning to the floor tag keeps the editable checkout and the PyPI +# fwl-morrigan release in lock-step instead of tracking the default branch. +# +# For a plain (non-editable) install, `pip install "fwl-proteus[morrigan]"` +# is enough; this script is for developing against a Morrigan checkout. set -euo pipefail @@ -61,25 +64,35 @@ else fi fi -# Resolve the pinned URL + ref from pyproject.toml. -m_url=$(python "$root/tools/_module_pins.py" morrigan url) -m_ref=$(python "$root/tools/_module_pins.py" morrigan ref) -if [ -z "$m_url" ] || [ -z "$m_ref" ]; then - echo "ERROR: could not resolve morrigan url/ref from pyproject.toml" >&2 - exit 1 -fi - echo "Cloning from GitHub" if [ "$use_ssh" = true ]; then - # Rewrite https://github.com/ -> git@github.com: for SSH transport. - uri=${m_url/https:\/\/github.com\//git@github.com:} + uri="git@github.com:FormingWorlds/Morrigan.git" else - uri="$m_url" + uri="https://github.com/FormingWorlds/Morrigan.git" fi -echo " $uri @ $m_ref -> $workpath" +echo " $uri -> $workpath" git clone "$uri" "$workpath" || { echo "ERROR: git clone failed" >&2; exit 1; } -git -C "$workpath" checkout --quiet "$m_ref" \ - || { echo "ERROR: cannot checkout $m_ref" >&2; exit 1; } + +# Pin the checkout to the fwl-morrigan version floor declared in PROTEUS's +# pyproject.toml, so the editable install matches the PyPI release across +# machines and CI instead of tracking whatever the default branch points at. +# The floor is written zero-padded (26.07.25) to match the release tag; PEP +# 440 treats that as equal to the normalised PyPI version (26.7.25), so the +# same string serves both the dependency resolver and this checkout. +# Comments are stripped before matching: the pin carries a rationale comment +# above it, and a future comment naming a different version would otherwise be +# picked up first and checked out instead of the real floor. The `|| true` +# keeps a missing pin from aborting under `set -e` before the warning below +# can explain what went wrong. +floor=$(sed 's/#.*//' "$root/pyproject.toml" \ + | grep -oE 'fwl-morrigan>=[0-9][0-9.]*' | head -1 | sed 's/.*>=//' || true) +if [ -n "$floor" ]; then + echo "Pinning to fwl-morrigan floor: $floor" + git -C "$workpath" checkout --quiet "tags/$floor" \ + || { echo "ERROR: cannot checkout tag $floor" >&2; exit 1; } +else + echo "WARNING: could not read fwl-morrigan floor from pyproject.toml; using HEAD" >&2 +fi # Install morrigan package as editable pip install -U -e "$workpath" || { echo "ERROR: editable install failed" >&2; exit 1; } From 0ab04b438401b19d22e857b16f8e26a3282e54d1 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 26 Jul 2026 09:55:59 +0200 Subject: [PATCH 20/71] Guard the giant-impact re-melt against unphysical heat The mantle re-melt applied at each giant impact books the heat it injects into the energy budget, but that column is added to both sides of the budget, so the conservation residual stays closed for any value and cannot tell a correct injection from a wrong one. Three problems followed from that. An initial condition sitting below the mantle's current state would make the "re-melt" cool the mantle and book negative impact heat, which no collision can do. The re-melt now compares the molten profile against the cooled one and stops the run with a message naming the temperature mode, before it rewrites any solver state, so a refused re-melt leaves nothing half-applied. Two impacts can fall inside a single timestep, since the timestep clamp is floored at the minimum step and the scheduler deliberately sweeps up every impact in the overshot window. The second re-melt measures an already-molten mantle and injects almost nothing, so assigning the booked heat rather than accumulating it replaced the first impact's real injection with that near-zero value. The column now accumulates over the step. The magnitude itself is the harder issue. The re-melt re-applies the temperature-mode initial condition to the whole mantle, so its cost scales with the mantle rather than with the impactor, and it can sit far above or far below the energy the collision carried. Each impact now reports the injection as a fraction of its kinetic energy and warns when that fraction leaves a physically plausible band, which is the only runtime signal that can catch it. Only liquidus_super guarantees a molten initial condition for any planet mass and melting curve, so the advisory at model start now fires for every other mode rather than treating two conditional ones as guarantees. --- .../accretion/{dummy.py => timeline.py} | 0 src/proteus/interior_energetics/wrapper.py | 91 +++++++- .../{test_dummy.py => test_timeline.py} | 0 tests/interior_energetics/test_wrapper.py | 196 +++++++++++++++++- 4 files changed, 277 insertions(+), 10 deletions(-) rename src/proteus/accretion/{dummy.py => timeline.py} (100%) rename tests/accretion/{test_dummy.py => test_timeline.py} (100%) diff --git a/src/proteus/accretion/dummy.py b/src/proteus/accretion/timeline.py similarity index 100% rename from src/proteus/accretion/dummy.py rename to src/proteus/accretion/timeline.py diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 5e1d90ced..c2a948615 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -86,6 +86,22 @@ # counter resets on each successful Aragog call. _ARAGOG_MAX_CONSECUTIVE_FAILS = 3 +# Slack on the "a giant-impact re-melt cannot cool the mantle" guard, as a +# fraction of the pre-impact mean entropy. Sized to absorb the round-off of the +# EOS lookup that builds the molten profile while still rejecting a mode whose +# initial condition genuinely sits below the current state, which lands orders +# of magnitude below this bound rather than just inside it. +_REMELT_ENTROPY_RTOL = 1e-6 + +# Band the giant-impact re-melt injection is expected to occupy as a fraction +# of the collision's kinetic energy. Giant-impact studies retain of order tens +# of percent of the impact energy as mantle heat, the rest leaving as ejecta +# and radiation, so a value spanning a percent to unity covers the physical +# range with margin. Outside it the re-melt is being set by the initial +# condition rather than by the collision, which the energy residual cannot +# reveal because the injection enters both of its sides. +_REMELT_RETAINED_BAND = (0.01, 1.0) + # Resume-settling guard for the dynamic structure re-solve. After a resume the # interior relaxes thermally over the first loops, swinging T_magma enough to # fire the dT/T structure-re-solve trigger every loop. The structure radius, @@ -1893,6 +1909,30 @@ def _remelt_aragog(config: Config, dirs: dict, hf_row: dict, interior_o) -> None # holds no valid trajectory now and would in any case lag the reset. S_molten = AragogRunner._set_entropy_ic(config, interior_o, dirs['output'], hf_row) S_molten = np.asarray(S_molten, dtype=float).ravel() + + # An impact deposits energy, so the re-melt cannot leave the mantle cooler + # than it found it. A temperature mode whose initial condition sits below + # the current state would do exactly that and book negative impact heat, + # which is unphysical and would corrupt the budget. The test is on the mean + # entropy rather than node by node, so a single-node EOS wiggle at a + # boundary cannot trip it, and relative because entropy here is + # positive-definite and of order 1e3 J kg-1 K-1. Checked before the carrier + # is rewritten, so a refused re-melt leaves no half-applied state behind. + if ( + S_cooled is not None + and S_cooled.size > 0 + and float(S_molten.mean()) < float(S_cooled.mean()) * (1.0 - _REMELT_ENTROPY_RTOL) + ): + raise RuntimeError( + 'Giant-impact re-melt would cool the mantle: the ' + f"temperature_mode='{config.planet.temperature_mode}' initial " + 'condition sits below the current thermal state, so the impact ' + 'would remove heat instead of adding it. Use ' + "temperature_mode='liquidus_super', which is molten for any " + 'planet mass and melting curve, or raise the initial state this ' + 'mode is anchored to.' + ) + interior_o._last_entropy = S_molten.copy() # Book the injected heat over the cooled-to-molten entropy jump, in the @@ -1902,13 +1942,18 @@ def _remelt_aragog(config: Config, dirs: dict, hf_row: dict, interior_o) -> None # state integral carries it; this column is how it enters the budget. if S_cooled is not None and S_cooled.size > 0: dE_impact = float(solver._step_heat_content(S_cooled, S_molten)) - hf_row['step_dE_impact_J'] = dE_impact + # Accumulate rather than assign: when two impacts fall inside one + # timestep the second re-melt measures an already-molten mantle and + # contributes almost nothing, and assigning would discard the first + # impact's injection from the row. The next solve re-zeros the column. + hf_row['step_dE_impact_J'] = float(hf_row.get('step_dE_impact_J') or 0.0) + dE_impact log.info(' re-melt heat injection %.3e J booked into the energy budget', dE_impact) else: # No prior profile to measure the jump from (no completed solve has # stored one). The injection cannot be quantified, so it is left - # unbooked and said so, rather than booking a silent zero. - hf_row['step_dE_impact_J'] = 0.0 + # unbooked and said so, rather than booking a silent zero. An earlier + # impact in the same step may already have booked one; leave it. + hf_row['step_dE_impact_J'] = float(hf_row.get('step_dE_impact_J') or 0.0) log.warning( ' re-melt heat injection not booked: no pre-impact entropy ' 'profile is available to measure the jump from' @@ -1961,6 +2006,11 @@ def remelt_mantle(dirs: dict, config: Config, hf_row: dict, interior_o, event=No """ module = config.interior_energetics.module + # The column accumulates over a step, so a second impact inside one + # timestep would otherwise be weighed against the running total instead of + # against its own injection. + booked_before = float(hf_row.get('step_dE_impact_J') or 0.0) + match module: case 'dummy' | 'boundary': _remelt_scalar_backend(config, hf_row, interior_o) @@ -1982,9 +2032,15 @@ def remelt_mantle(dirs: dict, config: Config, hf_row: dict, interior_o, event=No # deliberate impact re-melt, not a solver anomaly to be clipped away. interior_o.impact_reset = True - # Log the impact kinetic energy for context. A full re-melt injects mantle- - # scale enthalpy with no source term, so leave a line the reader can weigh - # against the impact energy the event carries. + # Weigh the booked injection against the energy the collision actually + # carried. The re-melt is a thermodynamic reset: it re-applies the run's + # initial condition to the whole mantle, so the enthalpy it injects scales + # with the mantle, not with the impactor, and the coupler adds it to both + # sides of the energy budget. The residual is therefore invariant across an + # impact for any booked value and cannot detect a wrong magnitude. This + # ratio is the only diagnostic that can, so it is always reported, and a + # value outside the physical band is called out rather than left for a + # reader to notice in a log they may never open. if event is not None: reduced = ( event.M_target_before @@ -1992,9 +2048,26 @@ def remelt_mantle(dirs: dict, config: Config, hf_row: dict, interior_o, event=No / (event.M_target_before + event.M_impactor) ) e_impact = 0.5 * reduced * event.v_impact**2 - log.info( - ' impact kinetic energy %.3e J (re-melt injects mantle-scale enthalpy)', e_impact - ) + dE_impact = float(hf_row.get('step_dE_impact_J') or 0.0) - booked_before + log.info(' impact kinetic energy %.3e J', e_impact) + + if e_impact > 0.0 and dE_impact > 0.0: + retained = dE_impact / e_impact + log.info(' re-melt injection is %.3f of the impact kinetic energy', retained) + if not _REMELT_RETAINED_BAND[0] <= retained <= _REMELT_RETAINED_BAND[1]: + log.warning( + ' re-melt injection is %.3g of the impact kinetic energy, outside ' + 'the physically expected band [%.2g, %.2g]. The re-melt re-applies the ' + 'temperature-mode initial condition to the whole mantle, so its cost ' + 'is set by the mantle rather than by this collision: a cool mantle ' + 'struck by a small impactor absorbs far more than the impact carried, ' + 'and a mantle already near the initial condition absorbs far less. ' + 'Treat the thermal response to this impact as a property of the ' + 'initial condition, not of the collision.', + retained, + _REMELT_RETAINED_BAND[0], + _REMELT_RETAINED_BAND[1], + ) def solve_structure( diff --git a/tests/accretion/test_dummy.py b/tests/accretion/test_timeline.py similarity index 100% rename from tests/accretion/test_dummy.py rename to tests/accretion/test_timeline.py diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index 33ab01932..e763337ad 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -19,6 +19,7 @@ from __future__ import annotations import logging +import math import os from pathlib import Path from types import SimpleNamespace @@ -5661,7 +5662,7 @@ def _remelt_config( dummy=SimpleNamespace(mantle_tliq=mantle_tliq, mantle_tsol=mantle_tsol), boundary=SimpleNamespace(T_solidus=b_tsol, T_liquidus=b_tliq), ), - planet=SimpleNamespace(tsurf_init=tsurf_init), + planet=SimpleNamespace(tsurf_init=tsurf_init, temperature_mode='liquidus_super'), interior_struct=SimpleNamespace(core_frac=0.55), ) @@ -5946,3 +5947,196 @@ def test_remelt_refuses_spider_and_rejects_an_unknown_backend(): with pytest.raises(ValueError, match='unknown interior module'): remelt_mantle(dirs, _remelt_config('nonsense'), hf_row={}, interior_o=None) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_a_remelt_that_would_cool_the_mantle_is_refused(): + """An impact adds energy, so the re-melt cannot lower the mantle entropy. + + The re-melt re-applies the run's temperature-mode initial condition. Only + 'liquidus_super' guarantees that condition is molten for any planet mass + and melting curve; a mode anchored on a user-supplied temperature can sit + below the current thermal state, in which case the "re-melt" would cool the + mantle and book negative impact heat. Negative impact heat is unphysical + and enters both sides of the energy budget, so it would corrupt the ledger + silently rather than fail. The run must stop instead, naming the mode. + """ + cooled = np.full(6, 3900.0) # already hotter than the IC below + solver = _FakeAragogSolver(cooled_profile=cooled) + interior_o = SimpleNamespace( + aragog_solver=solver, _last_entropy=cooled.copy(), impact_reset=False + ) + config = _remelt_config('aragog') + config.planet.temperature_mode = 'adiabatic_from_cmb' + + colder_ic = np.full(6, 2400.0) + + hf_row = {} + with patch( + 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', + side_effect=lambda cfg, io, outdir, row: colder_ic, + ): + with pytest.raises(RuntimeError, match='cool the mantle'): + remelt_mantle({'output': '/tmp/out'}, config, hf_row=hf_row, interior_o=interior_o) + + # The offending mode is named, so the message is actionable rather than + # just reporting that something went wrong. + with patch( + 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', + side_effect=lambda cfg, io, outdir, row: colder_ic, + ): + with pytest.raises(RuntimeError, match='adiabatic_from_cmb'): + remelt_mantle({'output': '/tmp/out'}, config, hf_row=hf_row, interior_o=interior_o) + + # No heat was booked: the guard fires before the quadrature, so a corrupt + # value cannot reach the row even transiently. + assert hf_row.get('step_dE_impact_J', 0.0) == 0.0 + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_two_impacts_in_one_step_accumulate_their_booked_heat(): + """A second impact in the same step must not erase the first one's heat. + + The timestep clamp is floored at the minimum step, so two impacts can fall + inside one iteration, and the scheduler deliberately sweeps up every impact + in the overshot window. Each one re-melts, but the second measures a mantle + the first already made molten, so its own quadrature is near zero. Assigning + the booked heat rather than accumulating it would therefore replace a real + injection with that near-zero value and drop it from the row. + """ + cooled = np.full(6, 2400.0) + molten = np.full(6, 3900.0) + solver = _FakeAragogSolver(cooled_profile=cooled) + interior_o = SimpleNamespace( + aragog_solver=solver, _last_entropy=cooled.copy(), impact_reset=False + ) + config = _remelt_config('aragog') + + hf_row = {} + with patch( + 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', + side_effect=lambda cfg, io, outdir, row: molten, + ): + remelt_mantle({'output': '/tmp/out'}, config, hf_row=hf_row, interior_o=interior_o) + first = hf_row['step_dE_impact_J'] + + # The second impact of the same step: the carrier now holds the molten + # profile, so this re-melt injects nothing further. + solver._solution = _FakeAragogSolver._Solution(molten) + remelt_mantle({'output': '/tmp/out'}, config, hf_row=hf_row, interior_o=interior_o) + + expected = 6 * (3900.0 - 2400.0) * _FakeAragogSolver._HEAT_PER_ENTROPY + assert first == pytest.approx(expected, rel=1e-12) + # The first impact's injection survives the second re-melt. + assert hf_row['step_dE_impact_J'] == pytest.approx(expected, rel=1e-12) + # Discrimination: assigning instead of accumulating would leave 0.0 here, + # which differs from the correct value by the whole injection. + assert abs(hf_row['step_dE_impact_J']) > 0.5 * expected + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_the_remelt_injection_is_weighed_against_the_impact_energy(caplog): + """The booked heat is reported as a fraction of the collision energy. + + The coupler adds the re-melt injection to both sides of the energy budget, + so the conservation residual is invariant across an impact for any booked + value and cannot detect a wrong magnitude. This ratio is the only runtime + diagnostic that can. A re-melt costing far more than the collision carried + means the mantle, not the impact, set the thermal response, and that has to + be visible rather than left implicit in a log line nobody reads. + """ + import logging + + from proteus.accretion.common import ImpactEvent + + cooled = np.full(6, 2400.0) + molten = np.full(6, 3900.0) + solver = _FakeAragogSolver(cooled_profile=cooled) + interior_o = SimpleNamespace( + aragog_solver=solver, _last_entropy=cooled.copy(), impact_reset=False + ) + config = _remelt_config('aragog') + + # The fake books 6 * 1500 * 2e27 = 1.8e31 J. An impactor carrying far less + # kinetic energy than that is the diagnostic's whole point: a small body + # cannot supply a mantle-scale re-melt. + booked = 6 * (3900.0 - 2400.0) * _FakeAragogSolver._HEAT_PER_ENTROPY + tiny = ImpactEvent( + time=1.0e5, + M_target_before=6.0e24, + M_impactor=6.0e21, + M_merged_after=6.006e24, + v_impact=1.0e4, + v_esc=9.0e3, + impact_parameter=0.5, + R_target_before=6.371e6, + R_impactor=8.0e5, + rho_target=5510.0, + rho_impactor=3930.0, + a_before=1.496e11, + a_after=1.4e11, + e_after=0.05, + ) + reduced = tiny.M_target_before * tiny.M_impactor / (tiny.M_target_before + tiny.M_impactor) + e_impact = 0.5 * reduced * tiny.v_impact**2 + + # The booked injection is far above the energy the collision carried, so + # the ratio is well outside the band and must be flagged. + assert booked / e_impact > 1.0 + + hf_row = {} + with caplog.at_level(logging.WARNING, logger='fwl.proteus.interior_energetics.wrapper'): + with patch( + 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', + side_effect=lambda cfg, io, outdir, row: molten, + ): + remelt_mantle( + {'output': '/tmp/out'}, config, hf_row=hf_row, interior_o=interior_o, event=tiny + ) + + assert 'outside' in caplog.text + assert hf_row['step_dE_impact_J'] == pytest.approx(booked, rel=1e-12) + + # Discrimination: an impactor whose kinetic energy sits inside the band + # draws no warning, so the check discriminates rather than always firing. + caplog.clear() + solver2 = _FakeAragogSolver(cooled_profile=cooled) + interior_o2 = SimpleNamespace( + aragog_solver=solver2, _last_entropy=cooled.copy(), impact_reset=False + ) + # v chosen so the reduced-mass kinetic energy is about twice the booked + # heat, putting the retained fraction near 0.5, inside [0.01, 1]. + big = ImpactEvent( + time=1.0e5, + M_target_before=6.0e24, + M_impactor=6.0e24, + M_merged_after=1.2e25, + v_impact=math.sqrt(2.0 * (2.0 * booked) / (6.0e24 / 2.0)), + v_esc=9.0e3, + impact_parameter=0.5, + R_target_before=6.371e6, + R_impactor=6.371e6, + rho_target=5510.0, + rho_impactor=5510.0, + a_before=1.496e11, + a_after=1.4e11, + e_after=0.05, + ) + hf_row2 = {} + with caplog.at_level(logging.WARNING, logger='fwl.proteus.interior_energetics.wrapper'): + with patch( + 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', + side_effect=lambda cfg, io, outdir, row: molten, + ): + remelt_mantle( + {'output': '/tmp/out'}, + config, + hf_row=hf_row2, + interior_o=interior_o2, + event=big, + ) + + assert 'outside' not in caplog.text From 6ccfeecfaeb3314aa5bd957dda1ee9c7b14f1b61 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 26 Jul 2026 09:56:25 +0200 Subject: [PATCH 21/71] Add an analytical accretion module and rename the file-driven one PROTEUS had one accretion module besides the dynamical model, called "dummy", which read a sequence of impacts from a file and replayed it. That name was wrong twice over. Every other dummy module in the codebase is a cheap approximation standing in for a heavy solver, whereas this one approximated nothing: it applied the full impact physics and only took the event list from disk. Replaying a timeline is also a capability people want in its own right, for reproducing a published impact history, driving a run from one computed elsewhere, or applying a hand-written sequence in a controlled experiment, and filing that under "dummy" hid it. The file-driven module is now accretion.module = "timeline", matching the vocabulary the code already uses everywhere, and "dummy" is a genuine analytical module. It grows the planet along an exponential approach to an asymptotic mass, the standard picture of an accretion rate that decays as the feeding zone empties. Impacts are placed at evenly spaced times and each delivers the mass the law accretes over its interval, so the increments decay and the first impact is the largest; the increments are rescaled to sum to the configured budget exactly. Radii come from the Noack & Lasbleis (2020) scaling already used by the dummy interior structure, collision speeds combine the pair's mutual escape velocity with an encounter velocity set by the eccentricity, and each merged orbit follows from conserving linear momentum through the collision. Nothing is random, so a configuration always produces the same history, and it needs no optional dependency. That also closes a gap in the test suite: no test at any tier ran an enabled accretion module through the coupled loop, so the timestep clamp, the impact handler, the ordering against escape and outgassing, and the runtime mass-closure assertion were exercised only through mocks. A smoke test now runs a real impact through the loop, built on the analytical module so it needs neither a fixture file nor the dynamical model. Alongside this, four defects in the impact accounting. A resumed run rewound the planet. Each impact accumulates its growth into the configuration, which is rebuilt from file on every start, so all growth and orbital migration before a resume point was discarded: the structure was re-solved against the original mass and, with tides off, the first resumed step snapped the orbit back to its configured value. The rock each impact adds is now recorded in the helpfile and the configuration is rebuilt from it on resume. The mass is restored from that ledger rather than from the whole-planet mass, which also carries the volatile budgets and would fold them into the rock anchor. The resolved timeline is written to the output directory and replayed on resume as well, so the impact history is a property of the run rather than of a dynamical model being bit-reproducible. The atmospheric strip could delete dissolved mantle inventory. It was sized through the continuous-escape path, whose desiccation floor zeroes an element's whole-planet total once it falls below the outgassing threshold. That is a reasonable convention for an element ground down over many steps and wrong for a single collision: it removed mass the impact never touched and booked it as lost to space. The strip is now computed directly, each element losing that fraction of its own atmospheric mass and no more. The whole-planet mass was left disagreeing with its parts. The handler refreshed the tracked-element total but not the planet mass, and escape runs later in the same iteration and reads it, so escape on an impact step was sized against a planet that did not exist. Both are now refreshed together, which also removes a third copy of the same aggregation. Impact records from the dynamical model were unpacked wholesale into the event type. The dependency is pinned by a version floor, so a later release adding a field would have turned into a fatal argument error at the first impact of a run. The fields are now selected by name, as the file reader already did, and a missing one reports which. --- docs/How-to/optionalmodules_installation.md | 6 + docs/Reference/output.md | 3 +- docs/Validation/accretion/dummy.md | 33 +++ docs/Validation/index.md | 1 + input/all_options.toml | 16 +- mkdocs.yml | 1 + src/proteus/accretion/common.py | 22 ++ src/proteus/accretion/dummy.py | 259 ++++++++++++++++ src/proteus/accretion/morrigan.py | 44 ++- src/proteus/accretion/timeline.py | 11 +- src/proteus/accretion/wrapper.py | 190 ++++++++---- src/proteus/config/_accretion.py | 100 ++++++- src/proteus/escape/wrapper.py | 17 +- src/proteus/proteus.py | 7 +- src/proteus/utils/coupler.py | 8 + tests/accretion/test_dummy.py | 308 ++++++++++++++++++++ tests/accretion/test_morrigan.py | 95 ++++++ tests/accretion/test_timeline.py | 13 +- tests/accretion/test_wrapper.py | 260 ++++++++++++++++- tests/config/test_accretion.py | 52 ++-- tests/escape/test_wrapper.py | 60 ---- tests/integration/test_smoke_accretion.py | 149 ++++++++++ tests/tools/test_migrate_config_v2_to_v3.py | 8 +- 23 files changed, 1476 insertions(+), 187 deletions(-) create mode 100644 docs/Validation/accretion/dummy.md create mode 100644 src/proteus/accretion/dummy.py create mode 100644 tests/accretion/test_dummy.py create mode 100644 tests/integration/test_smoke_accretion.py diff --git a/docs/How-to/optionalmodules_installation.md b/docs/How-to/optionalmodules_installation.md index ea7f5528b..53c90e0b0 100644 --- a/docs/How-to/optionalmodules_installation.md +++ b/docs/How-to/optionalmodules_installation.md @@ -110,3 +110,9 @@ bash tools/get_morrigan.sh An accretion run needs an interior module that can re-melt the mantle after an impact. Aragog is the production choice; SPIDER is refused at configuration load because it has no re-melt path. + +Two accretion modules need no installation at all. `accretion.module = +"dummy"` builds an impact history from scaling laws, and `accretion.module += "timeline"` replays one from a file, which is how a published or +externally computed impact history drives a run. Both apply the same +impact physics as the dynamical model. diff --git a/docs/Reference/output.md b/docs/Reference/output.md index 001f9973b..62c62dbe6 100644 --- a/docs/Reference/output.md +++ b/docs/Reference/output.md @@ -212,8 +212,9 @@ For each element (H, C, N, O, S): | `esc_rate_N` | kg s$^{-1}$ | Nitrogen escape rate | | `esc_rate_O` | kg s$^{-1}$ | Oxygen escape rate | | `esc_rate_S` | kg s$^{-1}$ | Sulfur escape rate | -| `esc_kg_cumulative` | kg | Cumulative escaped mass | +| `esc_kg_cumulative` | kg | Cumulative mass lost to space, from escape and impact stripping | | `M_vol_initial` | kg | Initial volatile inventory baseline | +| `M_accreted_rock` | kg | Cumulative rock mass added by giant impacts | | `p_xuv` | bar | XUV absorption pressure level | | `R_xuv` | m | XUV absorption radius | diff --git a/docs/Validation/accretion/dummy.md b/docs/Validation/accretion/dummy.md new file mode 100644 index 000000000..af54714c3 --- /dev/null +++ b/docs/Validation/accretion/dummy.md @@ -0,0 +1,33 @@ +# dummy.py Validation + +## Source under test +`src/proteus/accretion/dummy.py` (the analytical accretion module: the +exponential growth law, the Noack & Lasbleis mass-radius scaling, the +gravitationally focused collision speed, and the momentum-conserving merger). + +## Reference-pinned tests + +| Test ID | Reference | What is pinned | +|---|---|---| +| `test_dummy::test_collision_velocity_never_falls_below_the_mutual_escape_velocity` | Analytical limit: the two-body mutual escape speed, $v_\mathrm{esc} = \sqrt{2G(M_1+M_2)/(R_1+R_2)}$ | The collision speed for a circular encounter, where the approach velocity vanishes and the focused speed collapses exactly onto the mutual escape speed. Pinned to `rel=1e-12` against the value computed from the masses and radii the record itself carries, with a discrimination guard showing that dropping the factor of two moves the result by 29%. | +| `test_dummy::test_a_circular_encounter_leaves_the_orbit_untouched` | Analytical limit: a perfect merger of two bodies sharing one circular orbit | The merged orbit for $e = 0$, where both bodies have identical velocities and the mass-weighted mean is that velocity, so the semi-major-axis ratio is exactly one and the eccentricity exactly zero. This is the limit that a sign error or an inverted mass weighting cannot reproduce. | +| `test_dummy::test_merged_orbit_conserves_angular_momentum_of_the_merged_body` | Analytical identity: $h = \sqrt{\mu a (1 - e^2)} = r v_\theta$ | Internal consistency of the returned orbit. The semi-major axis and eccentricity are two numbers derived from one velocity vector, so they are only consistent if the angular momentum they imply equals the radius times the tangential velocity that produced them, pinned to `rel=1e-9`. | + +## Coverage + +The module derives an impact chain rather than integrating one, so what it +must certify is that the chain is physically admissible, not that it +reproduces any particular system. Mass closes at every merger and over the +whole timeline; the collision speed satisfies the floor the timeline validator +enforces, with equality in the circular limit; and the merged orbit is bound, +interior to the target's, and internally consistent in angular momentum. + +The growth law itself is a modelling choice rather than a measured quantity, +so it is pinned by its own structure: consecutive impactor masses differ by +exactly $\exp(-\Delta t / \tau)$, which fixes both the sign and the presence of +the exponential, and the delivered mass sums to the configured budget exactly, +which fixes the renormalisation. + +Radii come from the Noack & Lasbleis (2020) scaling laws through +`utils.structure_estimate`, whose own anchors are certified with the dummy +interior structure; this module inherits them rather than restating them. diff --git a/docs/Validation/index.md b/docs/Validation/index.md index 59439cf2d..80b685a6e 100644 --- a/docs/Validation/index.md +++ b/docs/Validation/index.md @@ -12,6 +12,7 @@ test inventoried here. | Module | Source file | Page | |---|---|---| +| Accretion | `accretion/dummy.py` | [Analytical accretion](accretion/dummy.md) | | Accretion | `accretion/wrapper.py` | [Impact atmosphere-loss dispatch](accretion/wrapper.md) | | Interior structure | `interior_struct/zalmoxis.py` | [Liquidus-super IC anchor](interior_struct/zalmoxis.md) | | Orbit | `orbit/orbit.py` | [Orbital evolution](orbit/orbit.md) | diff --git a/input/all_options.toml b/input/all_options.toml index dc365741a..1f19db58d 100644 --- a/input/all_options.toml +++ b/input/all_options.toml @@ -578,7 +578,7 @@ config_version = "3.0" # Giant-impact accretion and delivery [accretion] - module = "none" # none | dummy | morrigan + module = "none" # none | dummy | timeline | morrigan time_offset = 0.0 # shift of impact times onto the PROTEUS time axis [yr] # Impactor volatile content source: dry impactors add silicate and iron @@ -617,6 +617,20 @@ config_version = "3.0" selector_value = "none" # target orbit [AU] or embryo id, per selector [accretion.dummy] + # Analytical accretion: the planet approaches an asymptotic mass + # exponentially, impacts are placed at evenly spaced times, and each + # delivers the mass the law accretes over its interval, so the + # increments decay and the first impact is the largest. Radii follow + # the Noack & Lasbleis (2020) scaling and each merged orbit follows + # from conserving momentum through the collision. + mass_accreted = 0.1 # total mass delivered over the timeline [M_earth] + num_impacts = 3 # number of impacts + timescale = 1.0e6 # e-folding time of the accretion law [yr] + time_last = 5.0e6 # time of the final impact [yr] + eccentricity = 0.05 # encounter eccentricity [0-1) + impact_parameter = 0.5 # sine of the impact angle [0-1] + + [accretion.timeline] timeline_path = "none" # impact timeline file to replay # Atmospheric chemistry (post-processing) diff --git a/mkdocs.yml b/mkdocs.yml index f667c28a6..1e68e96b5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -77,6 +77,7 @@ nav: - Validation: - Overview: Validation/index.md - Accretion: + - Analytical accretion (dummy.py): Validation/accretion/dummy.md - Impact atmosphere-loss dispatch (wrapper.py): Validation/accretion/wrapper.md - Interior structure: - Liquidus-super IC anchor (zalmoxis.py): Validation/interior_struct/zalmoxis.md diff --git a/src/proteus/accretion/common.py b/src/proteus/accretion/common.py index 1cf216b3f..9a85e3a43 100644 --- a/src/proteus/accretion/common.py +++ b/src/proteus/accretion/common.py @@ -347,6 +347,28 @@ def read_timeline(path: str, time_offset: float = 0.0) -> list[ImpactEvent]: return events +def write_timeline(events: Sequence[ImpactEvent], path: str) -> None: + """Write an impact timeline to file, in the format :func:`read_timeline` reads. + + Times are written on the PROTEUS axis, with any offset between the + dynamical model's zero point and the run already applied, so the file + round-trips through ``read_timeline(path, time_offset=0.0)``. + + Parameters + ---------- + events : sequence of ImpactEvent + Timeline to write, in time order. + path : str + Destination file. + """ + table = pd.DataFrame( + [[getattr(event, column) for column in TIMELINE_COLUMNS] for event in events], + columns=list(TIMELINE_COLUMNS), + ) + table.to_csv(path, index=False) + log.debug('Wrote %d impacts to %s', len(events), path) + + def next_event(events: Sequence[ImpactEvent], time: float) -> ImpactEvent | None: """Return the first impact strictly after the given time. diff --git a/src/proteus/accretion/dummy.py b/src/proteus/accretion/dummy.py new file mode 100644 index 000000000..8f4a5743a --- /dev/null +++ b/src/proteus/accretion/dummy.py @@ -0,0 +1,259 @@ +# Analytical giant-impact accretion module +from __future__ import annotations + +import logging +import math +from typing import TYPE_CHECKING + +from proteus.accretion.common import ImpactEvent, validate_timeline +from proteus.utils.constants import AU, M_earth, M_sun, const_G +from proteus.utils.structure_estimate import iron_fractions, nl20_planet_radius_km + +if TYPE_CHECKING: + from proteus.config import Config + +log = logging.getLogger('fwl.' + __name__) + + +def _body_radius(config: Config, mass: float) -> float: + """Radius of a rocky body of the given mass [m]. + + Uses the Noack & Lasbleis (2020) mass-radius scaling, the same + parameterization the dummy interior structure uses, evaluated at the + planet's configured core fraction so an impactor and its target share a + composition. + + Parameters + ---------- + config : Config + Model configuration; read for the core fraction. + mass : float + Body mass [kg]. + + Returns + ------- + radius : float + Body radius [m]. + """ + m_ratio = mass / M_earth + _, x_fe, _ = iron_fractions( + config.interior_struct.core_frac, + config.interior_struct.core_frac_mode, + mass_tot_M_earth=m_ratio, + ) + return nl20_planet_radius_km(x_fe, m_ratio) * 1.0e3 + + +def _impact_masses(config: Config) -> list[float]: + """Mass each impact delivers [kg], in time order. + + The planet approaches its asymptotic mass exponentially, so the mass + accreted between two times is the difference of the law evaluated at them. + With impacts spaced evenly in time the increments therefore decay, and the + first impact is the largest. The increments are then rescaled to sum to the + configured total, which makes the delivered mass exactly what was asked for + while leaving the law in charge of the distribution. + + Parameters + ---------- + config : Config + Model configuration. + + Returns + ------- + masses : list of float + Impactor masses [kg], one per impact, in time order. + """ + dummy = config.accretion.dummy + n_impacts = int(dummy.num_impacts) + tau = float(dummy.timescale) + + times = _impact_times(config) + edges = [0.0, *times] + weights = [ + math.exp(-edges[k] / tau) - math.exp(-edges[k + 1] / tau) for k in range(n_impacts) + ] + + # A timescale far from the impact spacing makes the law unusable in one of + # two ways, and both have to be caught here rather than surfacing later as + # a zero-mass impactor. Too short and the law completes inside the first + # interval, so every later weight underflows to zero; too long and the + # accreted fraction over the whole timeline underflows, so they all do. + total = sum(weights) + if total <= 0.0 or min(weights) <= 0.0: + raise ValueError( + f'accretion.dummy.timescale = {tau:.3e} yr cannot distribute mass over ' + f'{n_impacts} impacts ending at time_last = {float(dummy.time_last):.3e} yr: ' + 'the accretion law is either finished or has barely begun by the time the ' + 'impacts are spaced, leaving at least one of them with no mass to deliver. ' + 'Bring timescale closer to the impact spacing, ' + f'{float(dummy.time_last) / n_impacts:.3e} yr.' + ) + + delivered = float(dummy.mass_accreted) * M_earth + return [delivered * w / total for w in weights] + + +def _impact_times(config: Config) -> list[float]: + """Time of each impact [yr], evenly spaced up to the configured last one.""" + dummy = config.accretion.dummy + n_impacts = int(dummy.num_impacts) + t_last = float(dummy.time_last) + return [(k + 1) * t_last / n_impacts for k in range(n_impacts)] + + +def _merged_orbit( + m_target: float, m_impactor: float, a_target: float, eccentricity: float, m_star: float +) -> tuple[float, float, float]: + """Orbit and encounter velocity produced by a perfect merger. + + A collision conserves linear momentum, not energy, so the merged body + leaves the collision point with the mass-weighted mean of the two + velocities, and its orbit follows from that velocity at that radius. + + The geometry is coplanar and fully determined by one parameter: the target + is on a circular orbit of radius ``a_target``, and the impactor is on an + orbit of the same semi-major axis with eccentricity ``eccentricity``, + evaluated where it crosses the target. At that radius the impactor's speed + equals the circular speed while its velocity is tilted, which is what + supplies the relative velocity at contact. In the small-eccentricity limit + that relative velocity reduces to ``eccentricity * v_kep``. + + Parameters + ---------- + m_target, m_impactor : float + Masses of the two bodies [kg]. + a_target : float + Semi-major axis of the target's circular orbit [m]. + eccentricity : float + Eccentricity of the impactor's orbit [1]. + m_star : float + Mass of the host star [kg]. + + Returns + ------- + a_after : float + Semi-major axis of the merged body [m]. + e_after : float + Eccentricity of the merged body [1]. + v_encounter : float + Relative velocity of the two bodies at contact [m s-1], before the + gravitational focusing that the mutual escape velocity adds. + """ + mu = const_G * m_star + v_kep = math.sqrt(mu / a_target) + + # Velocity components at the crossing radius, (radial, tangential). The + # target is circular, so it is purely tangential. The impactor shares the + # semi-major axis, so it shares the speed, but carries the angular momentum + # of an eccentric orbit and makes up the rest radially. + v_target = (0.0, v_kep) + v_impactor = (v_kep * eccentricity, v_kep * math.sqrt(1.0 - eccentricity**2)) + + v_encounter = math.hypot( + v_impactor[0] - v_target[0], + v_impactor[1] - v_target[1], + ) + + m_merged = m_target + m_impactor + v_merged = ( + (m_target * v_target[0] + m_impactor * v_impactor[0]) / m_merged, + (m_target * v_target[1] + m_impactor * v_impactor[1]) / m_merged, + ) + + # Vis-viva at the collision radius, then the angular momentum fixes the + # eccentricity. Averaging two bound velocities at one radius can only lower + # the specific energy, so the merged orbit is always bound and interior to + # the target's. + speed_sq = v_merged[0] ** 2 + v_merged[1] ** 2 + a_after = 1.0 / (2.0 / a_target - speed_sq / mu) + + h = a_target * v_merged[1] + e_after = math.sqrt(max(0.0, 1.0 - h * h / (mu * a_after))) + + return a_after, e_after, v_encounter + + +def get_timeline(config: Config) -> list[ImpactEvent]: + """Build an impact timeline from the analytical accretion law. + + Grows the configured planet by the configured mass through a chain of + perfect mergers, deriving each impact's masses, radii, velocities and orbit + change from scaling laws rather than from a dynamical model. The result is + deterministic: the same configuration always produces the same history. + + Parameters + ---------- + config : Config + Model configuration. + + Returns + ------- + events : list of ImpactEvent + Impacts to apply during the run, in time order. + """ + dummy = config.accretion.dummy + + times = _impact_times(config) + masses = _impact_masses(config) + + m_star = float(config.star.mass) * M_sun + eccentricity = float(dummy.eccentricity) + impact_parameter = float(dummy.impact_parameter) + offset = float(config.accretion.time_offset) + + m_target = float(config.planet.mass_tot) * M_earth + a_target = float(config.orbit.semimajoraxis) * AU + + events = [] + for index, (time, m_impactor) in enumerate(zip(times, masses)): + m_merged = m_target + m_impactor + + r_target = _body_radius(config, m_target) + r_impactor = _body_radius(config, m_impactor) + + a_after, e_after, v_encounter = _merged_orbit( + m_target, m_impactor, a_target, eccentricity, m_star + ) + + # Contact speed: the encounter velocity, focused by the pair's mutual + # gravity. This is the convention the collision erosion law expects and + # it puts the collision velocity at or above the escape velocity for + # any encounter, including a strictly circular one. + v_esc = math.sqrt(2.0 * const_G * m_merged / (r_target + r_impactor)) + v_impact = math.hypot(v_encounter, v_esc) + + events.append( + ImpactEvent( + time=time + offset, + M_target_before=m_target, + M_impactor=m_impactor, + M_merged_after=m_merged, + v_impact=v_impact, + v_esc=v_esc, + impact_parameter=impact_parameter, + R_target_before=r_target, + R_impactor=r_impactor, + rho_target=m_target / (4.0 / 3.0 * math.pi * r_target**3), + rho_impactor=m_impactor / (4.0 / 3.0 * math.pi * r_impactor**3), + a_before=a_target, + a_after=a_after, + e_after=e_after, + id_target=0, + id_impactor=index + 1, + ) + ) + + m_target = m_merged + a_target = a_after + + validate_timeline(events) + + log.info( + 'Generated %d impacts: %.4f -> %.4f M_earth over %.3e yr', + len(events), + float(config.planet.mass_tot), + m_target / M_earth, + times[-1], + ) + return events diff --git a/src/proteus/accretion/morrigan.py b/src/proteus/accretion/morrigan.py index a15900869..5720ebfd5 100644 --- a/src/proteus/accretion/morrigan.py +++ b/src/proteus/accretion/morrigan.py @@ -6,7 +6,7 @@ import numpy as np -from proteus.accretion.common import ImpactEvent, validate_timeline +from proteus.accretion.common import TIMELINE_COLUMNS, ImpactEvent, validate_timeline from proteus.utils.constants import AU, M_earth if TYPE_CHECKING: @@ -24,6 +24,10 @@ # Entry point Morrigan must expose for PROTEUS to drive it. MORRIGAN_ENTRY_POINT = 'run_system' +# Timeline fields that identify bodies rather than measure them, so they are +# read as integers while every other field is a physical quantity. +_ID_COLUMNS = ('id_target', 'id_impactor') + INSTALL_HINT = ( "accretion.module = 'morrigan' requires the morrigan package. " 'Install it with: pip install "fwl-proteus[morrigan]" ' @@ -200,6 +204,9 @@ def get_timeline(config: Config) -> list[ImpactEvent]: If the morrigan package is unavailable. KeyError If the run reports no impact history for the selected body. + ValueError + If the model's outcome or its impact records do not carry the fields + the coupling requires. """ package = require_morrigan() @@ -208,13 +215,42 @@ def get_timeline(config: Config) -> list[ImpactEvent]: outcome = getattr(package, MORRIGAN_ENTRY_POINT)(**params) + for key in ('survivors', 'impacts'): + if key not in outcome: + raise ValueError( + f"The giant-impact model returned no '{key}' entry. Expected a mapping " + f'carrying {sorted(("survivors", "impacts"))}, got ' + f'{sorted(outcome.keys())}. This usually means the installed ' + 'fwl-morrigan is newer than the coupling expects.' + ) + chosen = select_planet(outcome['survivors'], config) records = outcome['impacts'][chosen['id']] offset = config.accretion.time_offset - events = [ - ImpactEvent(**{**record, 'time': float(record['time']) + offset}) for record in records - ] + # Select the fields explicitly rather than splatting each record, the same + # way the file reader does. The dependency is pinned by a version floor, so + # a later release may add fields to its records; ignoring the ones the + # coupling does not consume keeps that from becoming a fatal argument error, + # and a missing field still reports which one by name. + events = [] + for index, record in enumerate(records): + missing = [column for column in TIMELINE_COLUMNS if column not in record] + if missing: + raise ValueError( + f'Impact record {index} from the giant-impact model is missing ' + f'required fields: {missing}. Expected all of: ' + f'{list(TIMELINE_COLUMNS)}' + ) + fields = { + column: float(record[column]) + for column in TIMELINE_COLUMNS + if column not in _ID_COLUMNS + } + fields['time'] += offset + for column in _ID_COLUMNS: + fields[column] = int(record[column]) + events.append(ImpactEvent(**fields)) events.sort(key=lambda e: e.time) validate_timeline(events) diff --git a/src/proteus/accretion/timeline.py b/src/proteus/accretion/timeline.py index 3b61e3e0c..4000c9f18 100644 --- a/src/proteus/accretion/timeline.py +++ b/src/proteus/accretion/timeline.py @@ -1,4 +1,4 @@ -# Timeline-replay accretion module +# Impact timeline replayed from file from __future__ import annotations import logging @@ -16,9 +16,10 @@ def get_timeline(config: Config) -> list[ImpactEvent]: """Read a pre-written impact timeline. - Replays a timeline produced earlier instead of running a dynamical - model, so impact consequences can be driven from a known event - sequence. + Replays a sequence of impacts produced elsewhere instead of deriving one + from a dynamical model. Every consequence is applied exactly as it is for a + model-derived timeline, so a run reproduces a published impact history, an + externally computed one, or a hand-written sequence. Parameters ---------- @@ -30,7 +31,7 @@ def get_timeline(config: Config) -> list[ImpactEvent]: events : list of ImpactEvent Impacts to apply during the run, in time order. """ - path = config.accretion.dummy.timeline_path + path = config.accretion.timeline.timeline_path log.info('Reading impact timeline from file') return read_timeline(path, time_offset=config.accretion.time_offset) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index a1a1fd098..13783259a 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -2,9 +2,10 @@ from __future__ import annotations import logging +import os from typing import TYPE_CHECKING -from proteus.utils.constants import M_earth, element_list +from proteus.utils.constants import AU, M_earth, element_list if TYPE_CHECKING: from proteus.accretion.common import ImpactEvent @@ -28,6 +29,11 @@ # set above, noble gases included. _PPMW_ELEMENTS = ('H', 'C', 'N', 'S', 'O') +# Where the run records the impact timeline it resolved at initialisation, in +# its own output directory. A resumed run replays this file instead of asking +# the module for a timeline again. +_RESOLVED_TIMELINE_FILE = 'impact_timeline.csv' + def init_accretion(handler: Proteus) -> list[ImpactEvent]: """Prepare the impact timeline for a run. @@ -58,13 +64,16 @@ def init_accretion(handler: Proteus) -> list[ImpactEvent]: # Advise when the Aragog re-melt initial condition is not guaranteed molten. # The re-melt re-applies the run's temperature-mode initial condition, and - # only some modes guarantee it is fully molten; the others are only as molten - # as the user's temperature or entropy value. Emitted here, after the file - # logger exists, rather than in the config validator, which runs before it. - _MOLTEN_MODES = ('liquidus_super', 'accretion', 'adiabatic_from_cmb') + # only 'liquidus_super' guarantees it is fully molten for any planet mass and + # melting curve; every other mode is only as molten as the user's temperature + # or entropy value makes it. 'adiabatic_from_cmb' is commonly chosen to force + # a molten state, but whether it reaches one depends on tcmb_init, so it draws + # the advisory too. Emitted here, after the file logger exists, rather than in + # the config validator, which runs before it. + _GUARANTEED_MOLTEN_MODES = ('liquidus_super',) if ( config.interior_energetics.module == 'aragog' - and config.planet.temperature_mode not in _MOLTEN_MODES + and config.planet.temperature_mode not in _GUARANTEED_MOLTEN_MODES ): log.warning( "Accretion on Aragog with temperature_mode='%s': each impact re-melts " @@ -75,19 +84,94 @@ def init_accretion(handler: Proteus) -> list[ImpactEvent]: ) log.info('') - match module: - case 'dummy': - from proteus.accretion.dummy import get_timeline - case 'morrigan': - from proteus.accretion.morrigan import get_timeline - case _: - raise ValueError(f"Invalid accretion module: '{module}'") - - events = get_timeline(config) + from proteus.accretion.common import read_timeline, write_timeline + + resolved_path = os.path.join(handler.directories['output'], _RESOLVED_TIMELINE_FILE) + + # A resumed run replays the timeline the first session resolved rather than + # deriving it again. Re-deriving would repeat a dynamical model's whole + # evolution at every restart, and would only reproduce the original history + # if that model is bit-reproducible at a fixed seed, which is not something + # PROTEUS can check. Reading the file makes the impact history a property of + # the run rather than of the model's determinism. + if config.params.resume and os.path.exists(resolved_path): + # Written on the PROTEUS axis with the offset already applied, so it + # must not be offset a second time. + events = read_timeline(resolved_path, time_offset=0.0) + log.info('Replaying the impact timeline resolved at the start of this run') + else: + match module: + case 'dummy': + from proteus.accretion.dummy import get_timeline + case 'timeline': + from proteus.accretion.timeline import get_timeline + case 'morrigan': + from proteus.accretion.morrigan import get_timeline + case _: + raise ValueError(f"Invalid accretion module: '{module}'") + + events = get_timeline(config) + write_timeline(events, resolved_path) return _drop_events_before_start(events, handler.hf_row.get('Time', 0.0)) +def restore_accretion_state(handler: Proteus) -> None: + """Rebuild the accretion state a resumed run cannot read from its TOML. + + Each impact grows ``config.planet.mass_tot`` and moves + ``config.orbit.semimajoraxis`` and ``config.orbit.eccentricity``. The + configuration is the run's specification, rebuilt from file on every start, + so none of that survives a restart: without this, a resumed run would solve + the structure against the planet's original mass, discarding the growth of + every impact before the resume point, and would snap the orbit back to its + configured value on the first step whenever tides are off, because that + path re-pins the row from the configuration each iteration. + + The mass is rebuilt from ``M_accreted_rock``, the cumulative rock the + impacts added, on top of the configured mass rather than from ``M_planet``: + the anchor carries rock alone, while ``M_planet`` also carries the volatile + budgets, so anchoring on it would fold the volatiles into the rock and + drift further on every subsequent resume. + + Call after :func:`init_accretion`, so the timeline is still resolved + against the configured mass and orbit and a re-run dynamical model selects + the same body it selected originally. + + Parameters + ---------- + handler : Proteus + Proteus object instance, whose configuration is updated in place. + """ + config = handler.config + + if config.accretion.module is None or not config.params.resume: + return + + hf_row = handler.hf_row + + accreted = float(hf_row.get('M_accreted_rock') or 0.0) + if accreted <= 0.0: + return + + config.planet.mass_tot += accreted / M_earth + + semimajoraxis = float(hf_row.get('semimajorax') or 0.0) + eccentricity = float(hf_row.get('eccentricity') or 0.0) + if semimajoraxis > 0.0: + config.orbit.semimajoraxis = semimajoraxis / AU + config.orbit.eccentricity = eccentricity + + log.info( + 'Restored accretion state: %.4f M_earth at %.5f AU, e = %.4f ' + '(%.3e kg of rock accreted before the resume)', + config.planet.mass_tot, + config.orbit.semimajoraxis, + config.orbit.eccentricity, + accreted, + ) + + def apply_impact(handler: Proteus, event: ImpactEvent) -> None: """Apply one giant impact's consequences to the running planet. @@ -155,6 +239,14 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: # mass_tot is in Earth masses; the amounts are in kg. impactor_rock = event.mass_delta - sum(content.values()) config.planet.mass_tot += impactor_rock / M_earth + + # Record the growth in the helpfile as well as in the configuration. The + # configuration is rebuilt from the TOML on every start, so it cannot carry + # state across a resume; this column is what lets a resumed run rebuild the + # anchor. It holds rock only, matching what the anchor accumulates, so it + # must not be confused with the whole-planet mass, which also carries the + # volatile budgets. + hf_row['M_accreted_rock'] = float(hf_row.get('M_accreted_rock') or 0.0) + impactor_rock solve_structure( handler.directories, config, handler.hf_all, hf_row, handler.directories['output'] ) @@ -243,10 +335,15 @@ def _apply_volatile_consequences( sum(impactor_lost.values()), ) - # Refresh the tracked-element total from the conserved budgets plus the - # strip and delivery. solve_structure set M_ele from the mass-scaled - # values it computed, which the updates above have overridden. - _refresh_tracked_element_total(hf_row) + # Refresh the tracked-element total AND the whole-planet mass from the + # conserved budgets plus the strip and delivery. solve_structure set both + # from the mass-scaled values it computed, which the updates above have + # overridden; refreshing M_ele alone would leave M_planet disagreeing with + # M_int + M_ele for the rest of the iteration, and escape runs inside that + # window and reads M_planet. + from proteus.interior_energetics.wrapper import update_planet_mass + + update_planet_mass(hf_row) def _primordial_mass_fractions(hf_all) -> dict: @@ -409,13 +506,22 @@ def _partition_impactor_content( def _target_strip_amounts(config, hf_row: dict, f_loss: float) -> dict: """Mass the impact strips from the target's atmosphere, per element [kg]. - Sizes the debit from the pre-impact state without mutating it: the loss - fraction times the atmospheric reservoir, partitioned over the elements in - proportion to their atmospheric masses through the same path continuous - escape uses, so the per-element loss can never exceed what the atmosphere - holds and the dissolved interior inventory is untouched. An atmosphere - below the outgassing mass threshold is treated as nothing to strip, the - same convention continuous escape applies to it. + Sizes the debit from the pre-impact state without mutating it: each element + loses the loss fraction of its own atmospheric mass, which is what + partitioning the total stripped mass in proportion to the atmospheric + abundances amounts to. The collision reaches only the atmosphere, so the + per-element loss is capped at the whole-planet total as well, and the + dissolved interior inventory is left intact. + + The debit is deliberately NOT routed through the continuous-escape path. + That path applies a desiccation floor which zeroes an element's + whole-planet total once it falls below the outgassing mass threshold, a + reasonable convention for an element being ground down over many steps but + wrong for a single collision: it would delete dissolved mantle inventory + the impact never touched and book it as mass lost to space. + + An atmosphere below the outgassing mass threshold is treated as nothing to + strip, the same convention continuous escape applies to it. Parameters ---------- @@ -426,8 +532,6 @@ def _target_strip_amounts(config, hf_row: dict, f_loss: float) -> dict: f_loss : float Collision loss fraction in [0, 1] from :func:`_impact_loss_fraction`. """ - from proteus.escape.wrapper import calc_new_elements - if f_loss <= 0.0: return {} @@ -438,16 +542,10 @@ def _target_strip_amounts(config, hf_row: dict, f_loss: float) -> dict: ) return {} - tgt = calc_new_elements( - hf_row, - dt=0.0, - reservoir='outgas', - min_thresh=config.outgas.mass_thresh, - esc_mass=f_loss * m_atm, - ) strip = {} - for e, new_total in tgt.items(): - removed = float(hf_row.get(f'{e}_kg_total', 0.0)) - float(new_total) + for e in element_list: + atm_e = float(hf_row.get(f'{e}_kg_atm', 0.0)) + removed = min(f_loss * atm_e, float(hf_row.get(f'{e}_kg_total', 0.0))) if removed > 0.0: strip[e] = removed return strip @@ -594,24 +692,6 @@ def _restore_volatile_budgets(hf_row: dict, budgets: dict) -> None: hf_row[f'{element}_kg_total'] = kg -def _refresh_tracked_element_total(hf_row: dict) -> None: - """Recompute the total tracked-element mass ``M_ele`` [kg]. - - Mirrors the aggregation in - :func:`proteus.outgas.wrapper.calc_target_elemental_inventories`, summing - every tracked element's ``_kg_total``. Called after the volatile budgets - are restored and the impactor delivery is added, so ``M_ele`` reflects the - conserved-plus-delivered inventory rather than the mass-scaled values the - structure solve produced. - - Parameters - ---------- - hf_row : dict - Current helpfile row, whose ``M_ele`` is updated in place. - """ - hf_row['M_ele'] = sum(float(hf_row.get(f'{e}_kg_total', 0.0)) for e in element_list) - - def _drop_events_before_start( events: list[ImpactEvent], time_start: float ) -> list[ImpactEvent]: diff --git a/src/proteus/config/_accretion.py b/src/proteus/config/_accretion.py index 480254de3..71ce8519d 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -1,6 +1,6 @@ from __future__ import annotations -from attr.validators import ge, gt, in_, le +from attr.validators import ge, gt, in_, le, lt from attrs import define, field from ._converters import none_if_none @@ -118,23 +118,26 @@ class Morrigan: selector_value: float | str | None = field(default=None, converter=none_if_none) -def valid_accretiondummy(instance, attribute, value): - if instance.module != 'dummy': +def valid_accretiontimeline(instance, attribute, value): + if instance.module != 'timeline': return - if instance.dummy.timeline_path is None: + if instance.timeline.timeline_path is None: raise ValueError( - '`accretion.dummy.timeline_path` must point at an impact timeline file ' - "when accretion.module = 'dummy'" + '`accretion.timeline.timeline_path` must point at an impact timeline file ' + "when accretion.module = 'timeline'" ) @define -class AccretionDummy: - """Dummy accretion module, driven by a pre-written impact timeline. +class AccretionTimeline: + """Impact timeline replayed from a file. - Reads a timeline file instead of running a dynamical model, so impact - consequences can be exercised against a known event sequence. + Applies a pre-written sequence of impacts instead of deriving one from a + dynamical model. Every impact consequence is computed exactly as it is for + a model-derived timeline, so this reproduces a published impact history, + drives PROTEUS from a history computed elsewhere, or applies a hand-written + sequence for a controlled experiment. Attributes ---------- @@ -146,6 +149,55 @@ class AccretionDummy: timeline_path: str | None = field(default=None, converter=none_if_none) +@define +class AccretionDummy: + """Analytical stand-in for a dynamical giant-impact model. + + Builds a self-consistent accretion history from scaling laws rather than + integrating a system of embryos, in the same spirit as the other dummy + modules in PROTEUS: fast, deterministic, and dependency-free, at the cost + of the dynamics. + + The planet approaches an asymptotic mass exponentially, the standard + picture of an accretion rate that decays as the feeding zone empties. + Impacts are placed at evenly spaced times and each one delivers the mass + the law accretes over its interval, so the increments decay with time and + the largest impact is the first. Radii follow the Noack & Lasbleis (2020) + mass-radius scaling, collision velocities combine the pair's mutual escape + velocity with an encounter velocity set by ``eccentricity``, and each + merged orbit follows from conserving linear momentum through the collision. + + Attributes + ---------- + mass_accreted: float + Total mass delivered over the whole timeline [M_earth]. The growth law + sets how this is distributed in time and between impacts; the + increments are scaled so they sum to exactly this value. + num_impacts: int + Number of impacts in the timeline. + timescale: float + E-folding time of the accretion law [yr]. Short compared with + ``time_last`` concentrates the mass in the first impacts; long + compared with it spreads the mass evenly. + time_last: float + Time of the final impact [yr]. Impacts are spaced evenly from + ``time_last / num_impacts`` up to this time. + eccentricity: float + Encounter eccentricity [1], setting both the approach velocity that + adds to the mutual escape velocity and the impactor's orbit. + impact_parameter: float + Impact parameter of every collision [1], the sine of the impact angle. + Zero is head-on, one is grazing. + """ + + mass_accreted: float = field(default=0.1, validator=gt(0)) + num_impacts: int = field(default=3, validator=ge(1)) + timescale: float = field(default=1.0e6, validator=gt(0)) + time_last: float = field(default=5.0e6, validator=gt(0)) + eccentricity: float = field(default=0.05, validator=[ge(0), lt(1)]) + impact_parameter: float = field(default=0.5, validator=[ge(0), le(1)]) + + def valid_impactor_volatiles(instance, attribute, value): """Refuse ppmw budgets that the selected content mode would ignore.""" if instance.impactor_volatiles == 'ppmw': @@ -175,14 +227,31 @@ class Accretion: impactors carry the planet's own formation composition, and "ppmw" impactors carry the per-element budgets configured below. + The mantle re-melt is a thermodynamic reset, not an energy deposition. + It re-applies the run's ``planet.temperature_mode`` initial condition to + the whole mantle, so the heat it injects is set by the mantle's own state + and mass rather than by the energy the collision carried. The two agree in + order of magnitude for a large impact onto a mantle that has cooled + appreciably, which is the regime this coupling targets, and diverge outside + it: a mantle already near the initial condition absorbs almost nothing, and + a cool mantle struck by a small impactor absorbs far more than the impact + supplied. The run reports the ratio of the two at every impact and warns + when it leaves the physically expected band, because the conservation + residual cannot detect the discrepancy: the injection is added to both of + its sides, so it stays closed for any injected value. Interpret the thermal + response to an impact as a property of the chosen initial condition, and + check that ratio before reading it as a consequence of the collision. + Attributes ---------- module: str or None - Accretion module to use. Choices: None, "dummy", "morrigan". + Accretion module to use. Choices: None, "dummy", "timeline", "morrigan". morrigan: Morrigan Parameters for the Morrigan giant-impact module. dummy: AccretionDummy - Parameters for the timeline-driven dummy module. + Parameters for the analytical dummy module. + timeline: AccretionTimeline + Parameters for replaying an impact timeline from file. time_offset: float Offset applied to every impact time when mapping the timeline onto the PROTEUS time axis [yr]. A dynamical model measures time from @@ -232,12 +301,15 @@ class Accretion: module: str | None = field( default='none', - validator=in_((None, 'dummy', 'morrigan')), + validator=in_((None, 'dummy', 'timeline', 'morrigan')), converter=none_if_none, ) morrigan: Morrigan = field(factory=Morrigan, validator=valid_morrigan) - dummy: AccretionDummy = field(factory=AccretionDummy, validator=valid_accretiondummy) + dummy: AccretionDummy = field(factory=AccretionDummy) + timeline: AccretionTimeline = field( + factory=AccretionTimeline, validator=valid_accretiontimeline + ) time_offset: float = field(default=0.0) diff --git a/src/proteus/escape/wrapper.py b/src/proteus/escape/wrapper.py index ab04c4b67..20bf062e7 100644 --- a/src/proteus/escape/wrapper.py +++ b/src/proteus/escape/wrapper.py @@ -261,7 +261,6 @@ def calc_new_elements( dt: float, reservoir: str, min_thresh: float = 1e10, - esc_mass: float | None = None, ): """Calculate new elemental inventory based on escape rate. @@ -270,15 +269,9 @@ def calc_new_elements( hf_row : dict Dictionary of helpfile variables, at this iteration only dt : float - Time-step length [years]. Ignored when ``esc_mass`` is given. + Time-step length [years] min_thresh: float Minimum threshold for element mass [kg]. Inventories below this are set to zero. - esc_mass : float, optional - Total mass to remove [kg]. When given, this mass is partitioned - over the reservoir instead of the rate-times-timestep integral; - an impulsive loss (a giant impact stripping the atmosphere) is - debited through the same proportional partitioning, desiccation - floor, and noble-gas exemption as continuous escape. Returns ------- @@ -296,9 +289,6 @@ def calc_new_elements( case _: raise ValueError(f"Invalid escape reservoir '{reservoir}'") - if esc_mass is not None and esc_mass < 0.0: - raise ValueError(f'esc_mass must be non-negative, got {esc_mass!r}') - # Calculate mass of elements in the reservoir. Issue #677 fix: # include O so the per-element subtraction sums to esc_mass and # the planetary O budget responds to escape (CALLIOPE's next call @@ -321,9 +311,8 @@ def calc_new_elements( # compute mass ratios in escaping reservoir emr = {e: (res[e] / M_vols if M_vols > 0 else 0.0) for e in res} - # total escaped mass [kg]: explicit when given, else the rate integral over dt - if esc_mass is None: - esc_mass = float(hf_row.get('esc_rate_total', 0.0)) * secs_per_year * float(dt) + # total escaped mass over dt [kg] + esc_mass = float(hf_row.get('esc_rate_total', 0.0)) * secs_per_year * float(dt) # compute new TOTAL inventories tgt: dict[str, float] = {} diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index 84c42a003..fa6311db4 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -274,7 +274,7 @@ def start(self, *, resume: bool = False, offline: bool = False): # atmospheric chemistry # giant-impact accretion from proteus.accretion.common import next_event - from proteus.accretion.wrapper import init_accretion + from proteus.accretion.wrapper import init_accretion, restore_accretion_state from proteus.atmos_chem.wrapper import run_chemistry # atmosphere solver @@ -715,6 +715,11 @@ def start(self, *, resume: bool = False, offline: bool = False): # consulted on every step, like the stellar evolution track. self.impact_events = init_accretion(self) + # Rebuild the mass and orbit that impacts before a resume point already + # applied. Runs after the timeline is resolved, so a re-run dynamical + # model still selects its body against the configured planet. + restore_accretion_state(self) + # Track the last simulation time at which data was written to disk, # so that dt_write_rel can suppress high-frequency writes during # rapid early evolution. Initialised to -inf so the first eligible diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index bcb48674a..87c0f4ecc 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -808,6 +808,14 @@ def GetHelpfileKeys(): # gate's state. 'M_vol_initial', # bulk volatile inventory baseline [kg] 'esc_kg_cumulative', # cumulative mass lost to space [kg] (escape + impact stripping) + + # Giant-impact accretion ledger. The rock each impact adds to the + # interior mass anchor, summed over the run. The anchor itself lives in + # the configuration, which is rebuilt from file on every start, so this + # column is what lets a resumed run reconstruct how far the planet had + # already grown. Rock only: the volatile budgets are tracked separately + # in the per-element columns, so this is not the whole-planet mass. + 'M_accreted_rock', # cumulative rock mass added by giant impacts [kg] ] # quantities for each gas, from outgassing diff --git a/tests/accretion/test_dummy.py b/tests/accretion/test_dummy.py new file mode 100644 index 000000000..a8775bff4 --- /dev/null +++ b/tests/accretion/test_dummy.py @@ -0,0 +1,308 @@ +"""Tests for the analytical accretion module. + +This file targets accretion/dummy.py (get_timeline and its helpers), which +builds a giant-impact history from scaling laws instead of integrating a +system of embryos. What it must guarantee is that the chain it produces is +physically self-consistent: mass closes across every merger and over the whole +timeline, the collision velocity never falls below the pair's mutual escape +velocity, the merged orbit follows from conserving momentum through the +collision, and the whole thing satisfies the same validator a model-derived or +file-read timeline must satisfy. + +See testing standards in docs/How-to/testing.md and +docs/Explanations/test_framework.md for required structure, speed, and +physics validity. +""" + +from __future__ import annotations + +import math +from types import SimpleNamespace + +import pytest + +from proteus.accretion.dummy import _merged_orbit, get_timeline +from proteus.utils.constants import AU, M_earth, M_sun, const_G + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + + +def _config( + mass_accreted=1.0, + num_impacts=4, + timescale=2.0e6, + time_last=8.0e6, + eccentricity=0.05, + impact_parameter=0.5, + mass_tot=1.0, + semimajoraxis=1.0, + star_mass=1.0, + time_offset=0.0, +): + """Build the minimal config shape the analytical module reads.""" + return SimpleNamespace( + accretion=SimpleNamespace( + module='dummy', + time_offset=time_offset, + dummy=SimpleNamespace( + mass_accreted=mass_accreted, + num_impacts=num_impacts, + timescale=timescale, + time_last=time_last, + eccentricity=eccentricity, + impact_parameter=impact_parameter, + ), + ), + planet=SimpleNamespace(mass_tot=mass_tot), + orbit=SimpleNamespace(semimajoraxis=semimajoraxis), + star=SimpleNamespace(mass=star_mass), + interior_struct=SimpleNamespace(core_frac=0.55, core_frac_mode='radius'), + ) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_the_timeline_conserves_mass_across_every_merger(): + """Mass closes per impact and over the whole timeline. + + Two separate closures matter and can fail independently. Each merger is a + perfect merger, so the merged mass must equal the sum of the two bodies, + and the next impact must inherit exactly that mass; a chain that re-derived + the target mass from the growth law instead of from the previous merger + would drift. Across the whole timeline the delivered mass must equal the + configured budget exactly, because the increments are renormalised: a + missing renormalisation would land short of it by the exponential tail. + """ + events = get_timeline(_config(mass_accreted=1.0, mass_tot=1.0)) + + for event in events: + assert event.M_merged_after == pytest.approx( + event.M_target_before + event.M_impactor, rel=1e-12 + ) + + for previous, current in zip(events, events[1:]): + assert current.M_target_before == pytest.approx(previous.M_merged_after, rel=1e-12) + + delivered = sum(event.M_impactor for event in events) + assert delivered == pytest.approx(1.0 * M_earth, rel=1e-12) + assert events[-1].M_merged_after == pytest.approx(2.0 * M_earth, rel=1e-12) + + # Discrimination: without the renormalisation the law only reaches + # 1 - exp(-t_last/tau) of the budget, which for these settings is 98.17%. + # That shortfall is 1.8e-2 relative, four orders above the tolerance above. + unnormalised = 1.0 - math.exp(-8.0e6 / 2.0e6) + assert abs(unnormalised - 1.0) > 1.0e-2 + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_impactor_masses_decay_and_the_first_impact_is_the_largest(): + """A decaying accretion rate delivers its mass front-loaded. + + The growth law is an exponential approach to an asymptote, so the mass + accreted per unit time falls monotonically. With impacts spaced evenly in + time the increments must therefore decrease, which is the ordering that + distinguishes this law from a uniform delivery. The ratio of consecutive + increments is exp(-dt/tau), a value the test pins directly, so an + implementation that dropped the exponential or inverted its sign fails. + """ + events = get_timeline(_config(num_impacts=4, timescale=2.0e6, time_last=8.0e6)) + + masses = [event.M_impactor for event in events] + assert masses == sorted(masses, reverse=True) + + # Consecutive weights differ by exactly exp(-dt/tau) with dt = 2 Myr and + # tau = 2 Myr, so the ratio is 1/e. Renormalisation is a common factor and + # cancels out of the ratio. + expected_ratio = math.exp(-1.0) + for previous, current in zip(masses, masses[1:]): + assert current / previous == pytest.approx(expected_ratio, rel=1e-9) + + # Discrimination: a uniform delivery would give a ratio of 1, and a sign + # error in the exponent would give e. Both are far outside the tolerance. + assert abs(expected_ratio - 1.0) > 0.5 + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_collision_velocity_never_falls_below_the_mutual_escape_velocity(): + """v_impact = sqrt(v_encounter^2 + v_esc^2) holds, including at e = 0. + + The timeline validator rejects any impact whose collision velocity is below + the pair's mutual escape velocity, since that is unreachable for any + approach. The equality case is the discriminating one: on a circular + encounter the two bodies meet with no relative velocity, so the collision + velocity must equal the escape velocity exactly rather than fall below it, + which is where a formula that forgot the gravitational focusing would fail. + """ + events = get_timeline(_config(eccentricity=0.05)) + for event in events: + assert event.v_impact >= event.v_esc + # The focused speed is the quadrature sum, so the encounter term is + # recoverable and must be positive for a non-circular encounter. + v_encounter_sq = event.v_impact**2 - event.v_esc**2 + assert v_encounter_sq > 0.0 + + circular = get_timeline(_config(eccentricity=0.0)) + for event in circular: + assert event.v_impact == pytest.approx(event.v_esc, rel=1e-12) + + # A hand-computed escape velocity for the first pair, from the masses and + # radii the record itself carries, pins the formula rather than the code. + first = circular[0] + expected = math.sqrt( + 2.0 + * const_G + * (first.M_target_before + first.M_impactor) + / (first.R_target_before + first.R_impactor) + ) + assert first.v_esc == pytest.approx(expected, rel=1e-12) + # Discrimination: dropping the factor of two, the single most plausible + # slip, changes the value by 29%, far outside the tolerance. + assert abs(expected / math.sqrt(2.0) - expected) > 0.2 * expected + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_a_circular_encounter_leaves_the_orbit_untouched(): + """With no encounter eccentricity the merger cannot move the orbit. + + Two bodies on the same circular orbit have identical velocities, so the + mass-weighted mean is that velocity and the merged body stays on the same + orbit. This is the analytic limit of the momentum-conserving merger, and it + is the case that catches a sign error or a mass weighting applied the wrong + way round, both of which move the orbit even here. + """ + events = get_timeline(_config(eccentricity=0.0, semimajoraxis=1.0)) + + for event in events: + assert event.semimajoraxis_ratio == pytest.approx(1.0, rel=1e-12) + assert event.e_after == pytest.approx(0.0, abs=1e-12) + assert event.a_before == pytest.approx(1.0 * AU, rel=1e-12) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_a_merger_shrinks_the_orbit_and_leaves_it_bound(): + """Conserving momentum through a collision can only lower the orbit. + + Averaging two bound velocities at one radius lowers the specific orbital + energy, so the merged semi-major axis must be smaller than the target's and + the orbit must stay bound. An implementation that conserved energy instead + of momentum, the plausible alternative, would not shrink the orbit at all. + The merged orbit is also checked against vis-viva evaluated on the + independently computed merged velocity. + """ + events = get_timeline(_config(eccentricity=0.3, num_impacts=2)) + + for event in events: + assert event.a_after < event.a_before + assert 0.0 <= event.e_after < 1.0 + + # Cross-check the first merger against the closed-form momentum average. + first = events[0] + m_star = 1.0 * M_sun + mu = const_G * m_star + v_kep = math.sqrt(mu / first.a_before) + e_enc = 0.3 + v_t = (0.0, v_kep) + v_i = (v_kep * e_enc, v_kep * math.sqrt(1.0 - e_enc**2)) + m_merged = first.M_target_before + first.M_impactor + v_m = ( + (first.M_target_before * v_t[0] + first.M_impactor * v_i[0]) / m_merged, + (first.M_target_before * v_t[1] + first.M_impactor * v_i[1]) / m_merged, + ) + speed_sq = v_m[0] ** 2 + v_m[1] ** 2 + a_expected = 1.0 / (2.0 / first.a_before - speed_sq / mu) + assert first.a_after == pytest.approx(a_expected, rel=1e-12) + + +@pytest.mark.unit +def test_impact_times_are_evenly_spaced_and_carry_the_configured_offset(): + """Times run up to the configured last impact and shift with the offset. + + Even spacing is what makes the schedule finite: placing impacts at equal + mass increments instead would put the final one at infinite time, because + the growth law only approaches its asymptote. The offset maps the model's + zero point onto the PROTEUS clock and must shift every impact by exactly + the same amount without changing their spacing. + """ + events = get_timeline(_config(num_impacts=4, time_last=8.0e6)) + times = [event.time for event in events] + assert times == pytest.approx([2.0e6, 4.0e6, 6.0e6, 8.0e6], rel=1e-12) + + shifted = get_timeline(_config(num_impacts=4, time_last=8.0e6, time_offset=1.0e6)) + shifted_times = [event.time for event in shifted] + assert shifted_times == pytest.approx([3.0e6, 5.0e6, 7.0e6, 9.0e6], rel=1e-12) + + spacing = [b - a for a, b in zip(times, times[1:])] + shifted_spacing = [b - a for a, b in zip(shifted_times, shifted_times[1:])] + assert spacing == pytest.approx(shifted_spacing, rel=1e-12) + + +@pytest.mark.unit +def test_a_single_impact_delivers_the_whole_budget(): + """The edge case of one impact is a complete timeline, not a degenerate one. + + With num_impacts = 1 the renormalisation has a single weight to scale, so + that impact must carry the entire configured mass and land exactly at the + configured final time. A division that assumed at least two impacts, or an + off-by-one in the time spacing, fails here. + """ + events = get_timeline(_config(num_impacts=1, mass_accreted=0.5, time_last=3.0e6)) + + assert len(events) == 1 + assert events[0].M_impactor == pytest.approx(0.5 * M_earth, rel=1e-12) + assert events[0].time == pytest.approx(3.0e6, rel=1e-12) + assert events[0].M_merged_after == pytest.approx(1.5 * M_earth, rel=1e-12) + + +@pytest.mark.unit +def test_an_unusable_timescale_is_refused_with_an_actionable_message(): + """A timescale far below the first impact time cannot be silently divided by. + + If the whole accretion law completes before the first impact, every weight + underflows and the renormalisation would divide by zero, producing NaN + masses that only surface much later as an opaque solver failure. The module + must reject it at generation time and name both parameters involved. + """ + with pytest.raises(ValueError, match='timescale'): + get_timeline(_config(timescale=1.0e-3, time_last=1.0e9, num_impacts=2)) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_merged_orbit_conserves_angular_momentum_of_the_merged_body(): + """The returned orbit reproduces the angular momentum it was built from. + + a_after and e_after are two numbers derived from one velocity vector, so + they are only consistent if the specific angular momentum implied by the + pair, sqrt(mu * a * (1 - e^2)), equals r times the tangential velocity that + produced them. This is the internal consistency check that a mis-signed + eccentricity or a radius/semi-major-axis confusion breaks. + """ + m_star = 1.0 * M_sun + mu = const_G * m_star + a_target = 1.0 * AU + m_target = 1.0 * M_earth + m_impactor = 0.4 * M_earth + eccentricity = 0.2 + + a_after, e_after, v_encounter = _merged_orbit( + m_target, m_impactor, a_target, eccentricity, m_star + ) + + v_kep = math.sqrt(mu / a_target) + m_merged = m_target + m_impactor + v_theta = ( + m_target * v_kep + m_impactor * v_kep * math.sqrt(1.0 - eccentricity**2) + ) / m_merged + + h_from_orbit = math.sqrt(mu * a_after * (1.0 - e_after**2)) + assert h_from_orbit == pytest.approx(a_target * v_theta, rel=1e-9) + + # The encounter velocity reduces to e * v_kep for small eccentricity, which + # is the approximation the parameter is named for; at e = 0.2 it is within + # a percent of it, and it must not be zero. + assert v_encounter == pytest.approx(eccentricity * v_kep, rel=2e-2) + assert v_encounter > 0.0 diff --git a/tests/accretion/test_morrigan.py b/tests/accretion/test_morrigan.py index 2549b648c..7c2ef0b05 100644 --- a/tests/accretion/test_morrigan.py +++ b/tests/accretion/test_morrigan.py @@ -15,6 +15,7 @@ from __future__ import annotations +from functools import partial from types import SimpleNamespace import pytest @@ -318,3 +319,97 @@ def test_generated_timeline_is_selected_ordered_and_validated(monkeypatch): config.accretion.time_offset = 0.0 with pytest.raises(ValueError, match='does not close'): backend.get_timeline(config) + + +def _return_outcome(outcome, **kwargs): + """Stand in for the model entry point, returning a prepared outcome.""" + return outcome + + +def _one_impact_record(): + """A single physically consistent impact record, as the model reports it.""" + return { + 'time': 1.0e5, + 'M_target_before': 6.0e24, + 'M_impactor': 6.4e23, + 'M_merged_after': 6.64e24, + 'v_impact': 1.3e4, + 'v_esc': 1.15e4, + 'impact_parameter': 0.7, + 'R_target_before': 6.371e6, + 'R_impactor': 3.39e6, + 'rho_target': 5510.0, + 'rho_impactor': 3930.0, + 'a_before': 1.496e11, + 'a_after': 1.4e11, + 'e_after': 0.05, + 'id_target': 1, + 'id_impactor': 4, + } + + +@pytest.mark.unit +def test_an_unknown_field_in_a_model_record_is_ignored(monkeypatch): + """A newer model may report more than the coupling consumes. + + The dependency is pinned by a version floor, not an exact version, so a + later release is free to add fields to its impact records. Passing each + record straight into the event constructor would turn any such addition + into a fatal argument error at the first impact of a run, hours in. The + fields the coupling needs are selected by name instead, so an extra one is + simply not read. + """ + record = _one_impact_record() + record['fragmentation_regime'] = 'graze_and_merge' # a field a later release adds + record['n_fragments'] = 3 + + fake = SimpleNamespace( + run_system=lambda **kw: {'survivors': _SURVIVORS, 'impacts': {1: [record], 2: []}} + ) + monkeypatch.setattr(backend, 'morrigan', fake, raising=False) + + events = backend.get_timeline(_config(selector='mass')) + + assert len(events) == 1 + assert events[0].M_impactor == pytest.approx(6.4e23) + assert not hasattr(events[0], 'fragmentation_regime') + + +@pytest.mark.unit +def test_a_missing_field_names_itself_rather_than_failing_obscurely(monkeypatch): + """A record short of a required field reports which one, and stops. + + The alternative is a bare TypeError naming a constructor argument, which + tells a user nothing about which model version broke the contract or what + the contract is. The error must name the missing field and the required + set, matching the quality of the file reader's error for the same problem. + """ + record = _one_impact_record() + del record['v_esc'] + + fake = SimpleNamespace( + run_system=lambda **kw: {'survivors': _SURVIVORS, 'impacts': {1: [record], 2: []}} + ) + monkeypatch.setattr(backend, 'morrigan', fake, raising=False) + + with pytest.raises(ValueError, match='v_esc'): + backend.get_timeline(_config(selector='mass')) + + +@pytest.mark.unit +def test_an_outcome_missing_its_top_level_entries_is_refused(monkeypatch): + """A model result without survivors or impacts fails with a named cause. + + Indexing the outcome directly would raise a bare KeyError carrying only + the key name, with no indication that the installed model version is the + problem. Both required entries are checked, so a result shaped like + neither is rejected the same way. + """ + for absent in ('survivors', 'impacts'): + outcome = {'survivors': _SURVIVORS, 'impacts': {1: [_one_impact_record()], 2: []}} + del outcome[absent] + fake = SimpleNamespace(run_system=partial(_return_outcome, outcome)) + monkeypatch.setattr(backend, 'morrigan', fake, raising=False) + + with pytest.raises(ValueError, match=absent): + backend.get_timeline(_config(selector='mass')) diff --git a/tests/accretion/test_timeline.py b/tests/accretion/test_timeline.py index 62fe48790..14b118446 100644 --- a/tests/accretion/test_timeline.py +++ b/tests/accretion/test_timeline.py @@ -1,8 +1,7 @@ -"""Tests for the timeline-replay accretion module. +"""Tests for the file-replay accretion module. -This file targets accretion/dummy.py (get_timeline). The dummy module -replays a timeline written earlier, which is how impact consequences are -driven from a known event sequence, so what it must guarantee is that the +This file targets accretion/timeline.py (get_timeline). The module replays a +sequence of impacts computed elsewhere, so what it must guarantee is that the configured path is honoured, that path expansion happens, and that the configured time offset reaches the loaded events. @@ -18,7 +17,7 @@ import pytest from proteus.accretion.common import TIMELINE_COLUMNS -from proteus.accretion.dummy import get_timeline +from proteus.accretion.timeline import get_timeline pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] @@ -74,9 +73,9 @@ def _config(timeline_path, time_offset=0.0): """Build the minimal config shape get_timeline reads.""" return SimpleNamespace( accretion=SimpleNamespace( - module='dummy', + module='timeline', time_offset=time_offset, - dummy=SimpleNamespace(timeline_path=str(timeline_path)), + timeline=SimpleNamespace(timeline_path=str(timeline_path)), ) ) diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index f96af0f24..bbe4232e3 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -78,6 +78,8 @@ def _handler( time_start=0.0, interior_module='dummy', temperature_mode='liquidus_super', + output_dir=None, + resume=False, ): """Build the minimal Proteus handler shape init_accretion reads.""" return SimpleNamespace( @@ -85,13 +87,15 @@ def _handler( accretion=SimpleNamespace( module=module, time_offset=time_offset, - dummy=SimpleNamespace( + timeline=SimpleNamespace( timeline_path=None if timeline_path is None else str(timeline_path) ), ), interior_energetics=SimpleNamespace(module=interior_module), planet=SimpleNamespace(temperature_mode=temperature_mode), + params=SimpleNamespace(resume=resume), ), + directories={'output': str(output_dir) if output_dir is not None else '.'}, hf_row={'Time': time_start}, ) @@ -124,7 +128,11 @@ def test_enabled_backend_returns_the_scheduled_impacts(tmp_path): read on every step, so it has to arrive complete and in time order, with the physical content of each record preserved. """ - handler = _handler(module='dummy', timeline_path=_timeline_file(tmp_path / 't.csv')) + handler = _handler( + module='timeline', + timeline_path=_timeline_file(tmp_path / 't.csv'), + output_dir=tmp_path, + ) events = init_accretion(handler) @@ -150,7 +158,9 @@ def test_impacts_before_the_run_starts_are_reported_and_excluded(tmp_path, caplo path = _timeline_file(tmp_path / 't.csv') # Start the run after the first impact but before the second. - handler = _handler(module='dummy', timeline_path=path, time_start=2.0e5) + handler = _handler( + module='timeline', timeline_path=path, time_start=2.0e5, output_dir=tmp_path + ) with caplog.at_level(logging.WARNING, logger='fwl.proteus.accretion.wrapper'): events = init_accretion(handler) @@ -166,12 +176,20 @@ def test_impacts_before_the_run_starts_are_reported_and_excluded(tmp_path, caplo # An impact landing exactly on the start time is already accounted for # by the initial condition and is excluded too. - boundary = _handler(module='dummy', timeline_path=path, time_start=1.0e5) + boundary = _handler( + module='timeline', timeline_path=path, time_start=1.0e5, output_dir=tmp_path + ) assert [e.time for e in init_accretion(boundary)] == [5.0e5] # Shifting the timeline forward brings both impacts back into range, # which is the documented remedy. - shifted = _handler(module='dummy', timeline_path=path, time_offset=3.0e5, time_start=2.0e5) + shifted = _handler( + module='timeline', + timeline_path=path, + time_offset=3.0e5, + time_start=2.0e5, + output_dir=tmp_path, + ) assert len(init_accretion(shifted)) == 2 @@ -1330,3 +1348,235 @@ def test_mass_growth_conserves_then_delivery_adds_only_the_delivered_mass(monkey assert abs(handler.hf_row['H_kg_total'] - (4.0e22 * 1.5)) > 1.0e22 assert abs(handler.hf_row['H_kg_total'] - (4.0e22 * 1.5 + delivered)) > 1.0e22 assert handler.hf_row['M_ele'] == pytest.approx(expected, rel=1e-12) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_the_strip_never_reaches_the_dissolved_inventory(monkeypatch): + """A partial strip removes atmosphere only, whatever the threshold does. + + The collision reaches the atmosphere, not the mantle, so an element's + dissolved inventory must survive the impact even when the post-strip total + lands below the outgassing mass threshold. The continuous-escape path + treats an element that falls under that threshold as fully depleted and + zeroes its whole-planet total, a reasonable convention for an element + ground down over many steps but wrong for one collision: it would delete + dissolved mass the impact never touched and book it as lost to space. + + The threshold here is set so exactly that trap is sprung: H holds 1.0e16 kg + in the atmosphere and 0.2e16 kg dissolved, and stripping half the + atmosphere leaves 0.7e16 kg, below the 1.0e16 kg threshold. + """ + from proteus.accretion.wrapper import _target_strip_amounts + + config = SimpleNamespace(outgas=SimpleNamespace(mass_thresh=1.0e16)) + hf_row = {} + _atm_state(hf_row, H=(1.0e16, 1.2e16)) + + strip = _target_strip_amounts(config, hf_row, f_loss=0.5) + + # Exactly half the atmospheric mass, and not one kilogram of the 0.2e16 kg + # that is dissolved in the mantle. + assert strip['H'] == pytest.approx(0.5e16, rel=1e-12) + assert strip['H'] < hf_row['H_kg_total'] + + # Discrimination: routing this through the desiccation floor would remove + # the whole 1.2e16 kg budget, which is 2.4x the correct debit. + assert abs(1.2e16 - 0.5e16) > 0.5 * 0.5e16 + + # The strip can never exceed the atmosphere it is drawn from, at any loss + # fraction including a total one. + total_loss = _target_strip_amounts(config, hf_row, f_loss=1.0) + assert total_loss['H'] == pytest.approx(1.0e16, rel=1e-12) + assert total_loss['H'] <= hf_row['H_kg_atm'] + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_the_impact_leaves_the_planet_mass_consistent_with_its_parts(monkeypatch): + """M_planet equals M_int + M_ele when apply_impact returns. + + Escape runs later in the same iteration and reads M_planet, so leaving it + at the value the structure solve wrote, before the strip and the delivery + changed the volatile budgets, would size that iteration's escape against a + planet that does not exist. The structure solve is mocked to write a + deliberately stale M_planet, so a handler that failed to refresh it would + keep that value and fail here. + """ + import proteus.accretion.wrapper as accretion_wrapper + + handler = _impact_handler( + accretion=_impact_accretion(impactor_volatiles='ppmw', H_ppmw=1000.0) + ) + _atm_state(handler.hf_row, H=(2.0e20, 5.0e20)) + handler.hf_row['M_ele'] = 5.0e20 + handler.hf_row['M_planet'] = 0.0 # stale sentinel; must not survive + + def _solve(dirs, config, hf_all, hf_row, output): + hf_row['M_int'] = config.planet.mass_tot * 5.9736e24 + # Write the inconsistent pair a real structure solve would leave. + hf_row['M_ele'] = 9.9e21 + hf_row['M_planet'] = hf_row['M_int'] + 9.9e21 + + monkeypatch.setattr(accretion_wrapper, 'remelt_mantle', lambda *a, **k: None, raising=False) + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', _solve, raising=False + ) + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.remelt_mantle', lambda *a, **k: None, raising=False + ) + + accretion_wrapper.apply_impact(handler, _impact_event()) + + hf_row = handler.hf_row + assert hf_row['M_planet'] == pytest.approx(hf_row['M_int'] + hf_row['M_ele'], rel=1e-12) + # The stale value the solve wrote is gone, so the refresh genuinely ran. + assert hf_row['M_ele'] != pytest.approx(9.9e21, rel=1e-9) + + +@pytest.mark.unit +def test_a_resumed_run_rebuilds_the_mass_and_orbit_the_impacts_moved(): + """Growth applied before a resume point is restored, not discarded. + + The configuration is the run's specification and is rebuilt from file on + every start, so the mass and orbit that impacts moved live only in the + helpfile. Without the restore a resumed run would solve the structure + against the planet's original mass, throwing away every pre-resume impact, + and would snap the orbit back to its configured value on the first step. + + The rock ledger is the discriminating input: restoring from M_planet + instead would fold the volatile budgets into the rock anchor, which this + row makes visible by carrying a volatile mass far larger than the rounding + of the rock itself. + """ + from proteus.accretion.wrapper import restore_accretion_state + from proteus.utils.constants import AU, M_earth + + handler = SimpleNamespace( + config=SimpleNamespace( + accretion=SimpleNamespace(module='morrigan'), + params=SimpleNamespace(resume=True), + planet=SimpleNamespace(mass_tot=1.0), + orbit=SimpleNamespace(semimajoraxis=1.0, eccentricity=0.0), + ), + hf_row={ + 'M_accreted_rock': 0.5 * M_earth, + 'M_planet': 2.5 * M_earth, # carries volatiles too; must NOT be used + 'semimajorax': 1.25 * AU, + 'eccentricity': 0.04, + }, + ) + + restore_accretion_state(handler) + + assert handler.config.planet.mass_tot == pytest.approx(1.5, rel=1e-12) + assert handler.config.orbit.semimajoraxis == pytest.approx(1.25, rel=1e-12) + assert handler.config.orbit.eccentricity == pytest.approx(0.04, rel=1e-12) + + # Discrimination: anchoring on M_planet would have given 2.5 M_earth, which + # differs from the correct 1.5 by two thirds of the correct value. + assert abs(2.5 - 1.5) > 0.5 * 1.5 + + +@pytest.mark.unit +def test_the_accretion_restore_is_inert_outside_a_resume(): + """A fresh run, a disabled module, and an impact-free resume change nothing. + + The restore adds accreted rock on top of the configured mass, so running it + when the configuration already describes the current planet would double + the growth. It must therefore be a strict no-op unless the run is a resume + that has actually accreted something. + """ + from proteus.accretion.wrapper import restore_accretion_state + from proteus.utils.constants import M_earth + + def _handler_for(resume, module, accreted): + return SimpleNamespace( + config=SimpleNamespace( + accretion=SimpleNamespace(module=module), + params=SimpleNamespace(resume=resume), + planet=SimpleNamespace(mass_tot=1.0), + orbit=SimpleNamespace(semimajoraxis=1.0, eccentricity=0.0), + ), + hf_row={'M_accreted_rock': accreted, 'semimajorax': 9.9e11, 'eccentricity': 0.9}, + ) + + for resume, module, accreted in ( + (False, 'morrigan', 0.5 * M_earth), # fresh run + (True, None, 0.5 * M_earth), # module disabled + (True, 'morrigan', 0.0), # resumed before any impact landed + ): + handler = _handler_for(resume, module, accreted) + restore_accretion_state(handler) + assert handler.config.planet.mass_tot == pytest.approx(1.0, rel=1e-12) + assert handler.config.orbit.semimajoraxis == pytest.approx(1.0, rel=1e-12) + assert handler.config.orbit.eccentricity == pytest.approx(0.0, rel=1e-12) + + +@pytest.mark.unit +def test_a_resumed_run_replays_the_timeline_the_first_session_resolved(tmp_path): + """The impact history is a property of the run, not of model determinism. + + Re-deriving the timeline on resume would reproduce the original history + only if the dynamical model is bit-reproducible at a fixed seed, which + PROTEUS cannot check. The first session therefore records what it resolved + and a resume reads that file back. The recorded file is authoritative: this + test makes the module raise if it is consulted at all on the resume, so a + fallback to re-deriving would fail rather than pass by coincidence. + """ + from proteus.accretion.wrapper import init_accretion + + handler = _handler( + module='timeline', + timeline_path=_timeline_file(tmp_path / 't.csv'), + output_dir=tmp_path, + ) + first = init_accretion(handler) + assert (tmp_path / 'impact_timeline.csv').exists() + + resumed = _handler( + module='timeline', + timeline_path=tmp_path / 'absent.csv', # would raise if consulted + output_dir=tmp_path, + resume=True, + ) + replayed = init_accretion(resumed) + + assert [e.time for e in replayed] == [e.time for e in first] + assert [e.M_impactor for e in replayed] == pytest.approx( + [e.M_impactor for e in first], rel=1e-12 + ) + + +@pytest.mark.unit +def test_the_recorded_timeline_is_not_offset_a_second_time(tmp_path): + """Times are written on the PROTEUS axis and read back without the offset. + + The recorded file already carries the configured offset, so re-applying it + on resume would move every impact by that amount again. A non-zero offset + makes the double application unmissable: it would double the shift. + """ + from proteus.accretion.wrapper import init_accretion + + offset = 3.0e5 + handler = _handler( + module='timeline', + timeline_path=_timeline_file(tmp_path / 't.csv'), + time_offset=offset, + output_dir=tmp_path, + ) + first = init_accretion(handler) + assert first[0].time == pytest.approx(1.0e5 + offset) + + resumed = _handler( + module='timeline', + timeline_path=tmp_path / 'absent.csv', + time_offset=offset, + output_dir=tmp_path, + resume=True, + ) + replayed = init_accretion(resumed) + + assert replayed[0].time == pytest.approx(1.0e5 + offset) + # Discrimination: a second application would put it at 1.0e5 + 2 * offset. + assert abs((1.0e5 + 2 * offset) - replayed[0].time) > 0.5 * offset diff --git a/tests/config/test_accretion.py b/tests/config/test_accretion.py index 0cf2e8e4d..6699a8b2d 100644 --- a/tests/config/test_accretion.py +++ b/tests/config/test_accretion.py @@ -43,23 +43,30 @@ def test_accretion_defaults_leave_the_module_disabled(): # Sub-configs exist even when unused, so downstream attribute access # never needs a None check before reading a backend parameter. - assert a.dummy.timeline_path is None + assert a.timeline.timeline_path is None assert a.morrigan.selector == 'match_config' + # The analytical backend's own defaults are a runnable timeline, so a + # user who selects it without tuning anything still gets impacts. + assert a.dummy.num_impacts >= 1 + assert a.dummy.mass_accreted > 0.0 + @pytest.mark.unit def test_module_validator_admits_only_registered_backends(): - """Module selection accepts the two backends and rejects anything else. + """Module selection accepts the three backends and rejects anything else. 'kimura' and 'formation_model' are the names this model was known by before, so they are the realistic typo cases and must fail loudly rather than fall through to a silent no-op. """ - from proteus.config._accretion import Accretion, AccretionDummy + from proteus.config._accretion import Accretion, AccretionTimeline assert Accretion(module='morrigan').module == 'morrigan' + assert Accretion(module='dummy').module == 'dummy' assert ( - Accretion(module='dummy', dummy=AccretionDummy(timeline_path='x.csv')).module == 'dummy' + Accretion(module='timeline', timeline=AccretionTimeline(timeline_path='x.csv')).module + == 'timeline' ) assert Accretion(module='none').module is None @@ -69,27 +76,33 @@ def test_module_validator_admits_only_registered_backends(): @pytest.mark.unit -def test_dummy_backend_requires_a_timeline_path(): - """The timeline-replay backend cannot run without a timeline to replay. - - Selecting the dummy backend with no path is a configuration error, not - a quiet no-op, because the user asked for impacts and would otherwise - get a run with none. The edge case in the other direction matters just - as much: the same missing path must be accepted while the module is - off, or every existing config would start failing validation. +def test_timeline_backend_requires_a_timeline_path(): + """The file-replay backend cannot run without a timeline to replay. + + Selecting it with no path is a configuration error, not a quiet no-op, + because the user asked for impacts and would otherwise get a run with + none. The edge case in the other direction matters just as much: the + same missing path must be accepted while the module is off, or every + existing config would start failing validation. """ - from proteus.config._accretion import Accretion, AccretionDummy + from proteus.config._accretion import Accretion, AccretionTimeline with pytest.raises(ValueError, match='timeline_path'): - Accretion(module='dummy') + Accretion(module='timeline') + + supplied = Accretion( + module='timeline', timeline=AccretionTimeline(timeline_path='/tmp/t.csv') + ) + assert supplied.timeline.timeline_path == '/tmp/t.csv' - supplied = Accretion(module='dummy', dummy=AccretionDummy(timeline_path='/tmp/t.csv')) - assert supplied.dummy.timeline_path == '/tmp/t.csv' + # The analytical backend derives its own timeline, so it must NOT be + # caught by the same requirement. + assert Accretion(module='dummy').timeline.timeline_path is None # Inert while the backend is unselected, including the explicit # 'none' sentinel that the reference TOML ships. - assert Accretion(module='none').dummy.timeline_path is None - assert Accretion().dummy.timeline_path is None + assert Accretion(module='none').timeline.timeline_path is None + assert Accretion().timeline.timeline_path is None @pytest.mark.unit @@ -370,7 +383,8 @@ def pole(mass_earth, stellar_mass_sun): """Spacing at which the layout condition's denominator vanishes.""" return 2.0 / ((2 * mass_earth * m_earth) / (3 * stellar_mass_sun * m_sun)) ** (1 / 3) - assert 50.0 < pole(10.0, 1.0) == pytest.approx(73.6, rel=1e-2) + assert pole(10.0, 1.0) == pytest.approx(73.6, rel=1e-2) + assert pole(10.0, 1.0) > 50.0 assert pole(10.0, 1.0) < pole(1.0, 1.0) # But the ceiling is NOT universally conservative: the pole scales as diff --git a/tests/escape/test_wrapper.py b/tests/escape/test_wrapper.py index a0ae7771f..3345f36aa 100644 --- a/tests/escape/test_wrapper.py +++ b/tests/escape/test_wrapper.py @@ -734,66 +734,6 @@ def test_calc_new_elements_outgas_reservoir(): assert tgt['S'] < hf_row['S_kg_total'] -@pytest.mark.unit -@pytest.mark.physics_invariant -def test_calc_new_elements_explicit_mass_overrides_the_rate_integral(): - """An explicit mass debits exactly that mass, ignoring rate and timestep. - - An impulsive loss (a giant impact stripping the atmosphere) hands the - total mass to remove directly. The rate-times-timestep integral must play - no part: the row carries an escape rate whose integral over dt is thirty - times smaller than the explicit mass, so any leakage of the rate path - into the debit is unmissable. The explicit mass partitions - proportionally, and omitting it (the default) must reproduce the - rate-integral behaviour unchanged. - """ - from proteus.escape.wrapper import calc_new_elements - from proteus.utils.constants import secs_per_year - - def _row(): - return { - 'esc_rate_total': 1e8, # absurd rate; must be IGNORED when mass is given - 'H_kg_total': 8.0e20, - 'C_kg_total': 2.0e20, - 'H_kg_atm': 4.0e20, - 'C_kg_atm': 1.0e20, - } - - dt = 1000.0 - explicit = 1.0e20 # 1/5 of the 5e20 kg atmosphere - - tgt = calc_new_elements(_row(), dt, 'outgas', min_thresh=1e10, esc_mass=explicit) - - # Exactly the explicit mass leaves, split 4:1 by atmospheric composition. - assert tgt['H'] == pytest.approx(8.0e20 - 0.8e20, rel=1e-9) - assert tgt['C'] == pytest.approx(2.0e20 - 0.2e20, rel=1e-9) - total_lost = (8.0e20 + 2.0e20) - (tgt['H'] + tgt['C']) - assert total_lost == pytest.approx(explicit, rel=1e-9) - # Discrimination: the rate integral over dt (1e8 kg/s * 3.156e7 s/yr * - # 1000 yr = 3.16e18 kg) is 30x smaller than the explicit mass, so the - # exact-equality checks above could not pass had the rate path leaked in. - rate_mass = 1e8 * secs_per_year * dt - assert abs(rate_mass - explicit) > 0.5 * explicit - - # Back-compat: with esc_mass omitted the rate integral governs as before. - row2 = _row() - row2['esc_rate_total'] = explicit / (secs_per_year * dt) # same mass via the rate - tgt2 = calc_new_elements(row2, dt, 'outgas', min_thresh=1e10) - assert tgt2['H'] == pytest.approx(tgt['H'], rel=1e-9) - assert tgt2['C'] == pytest.approx(tgt['C'], rel=1e-9) - - # An explicit zero is a real value, not "fall back to the rate": nothing - # is removed even though the rate integral would remove 3.16e18 kg. - tgt0 = calc_new_elements(_row(), dt, 'outgas', min_thresh=1e10, esc_mass=0.0) - assert tgt0['H'] == pytest.approx(8.0e20, rel=1e-12) - assert tgt0['C'] == pytest.approx(2.0e20, rel=1e-12) - - # A negative mass has no meaning in the partitioning and is rejected - # rather than silently adding mass to the planet. - with pytest.raises(ValueError, match='non-negative'): - calc_new_elements(_row(), dt, 'outgas', min_thresh=1e10, esc_mass=-1.0e19) - - @pytest.mark.unit def test_calc_new_elements_below_threshold(): """Test elemental inventory when mass falls below minimum threshold. diff --git a/tests/integration/test_smoke_accretion.py b/tests/integration/test_smoke_accretion.py new file mode 100644 index 000000000..71166b2d3 --- /dev/null +++ b/tests/integration/test_smoke_accretion.py @@ -0,0 +1,149 @@ +"""Smoke test: a giant impact applied inside the coupled loop. + +Every other accretion test runs a helper in isolation with the structure solve, +the interior solver and the impact timeline mocked. This one enables the +accretion module and runs the real loop across a scheduled impact, so the +wiring those tests cannot reach is exercised: the timestep clamp landing on the +impact time, the handler firing once at that time, the ordering that puts the +atmospheric strip before escape and outgassing, and the runtime mass-closure +assertion seeing the grown planet. + +The analytical accretion module is used rather than a timeline file or the +dynamical model, so the test needs no fixture data and no optional dependency +and still applies the full impact physics. + +Invariants tested: + - the planet's mass grows, by the impactor rock the timeline specifies + - the impact lands once, inside the simulated interval + - the accreted-rock ledger a resume reads back is written and monotonic + - M_planet stays consistent with M_int + M_ele after the impact + - the run does not trip the runtime M_atm <= M_planet assertion + +Testing standards: + - docs/How-to/testing.md + - docs/Explanations/test_framework.md +""" + +from __future__ import annotations + +import tempfile +import uuid +from pathlib import Path + +import numpy as np +import pytest +from helpers import PROTEUS_ROOT + +from proteus import Proteus +from proteus.utils.constants import M_earth + +pytestmark = [pytest.mark.smoke, pytest.mark.timeout(120)] + + +@pytest.mark.smoke +@pytest.mark.physics_invariant +def test_smoke_accretion_impact_lands_inside_the_coupled_loop(): + """A scheduled impact grows the planet while the run stays self-consistent. + + Physical scenario: an all-dummy planet accretes one giant impact partway + through a short run. The impact adds rock, the structure is re-solved + against the grown mass, and the mantle is re-melted, all inside the loop + rather than in a helper called directly. + + Validates: + - the interior mass anchor grows by the delivered rock, once + - the impact time falls inside the simulated interval, so the schedule and + the timestep clamp actually met + - M_accreted_rock is written, non-decreasing, and ends at the delivered mass + - M_planet equals M_int + M_ele on every row, including the impact row + - no NaN reaches the mass columns + """ + unique_id = str(uuid.uuid4())[:8] + with tempfile.TemporaryDirectory() as tmpdir: + config_path = PROTEUS_ROOT / 'input' / 'dummy.toml' + runner = Proteus(config_path=config_path) + + runner.config.params.out.path = str(Path(tmpdir) / f'smoke_accretion_{unique_id}') + runner.init_directories() + + runner.config.planet.tsurf_init = 2000.0 + + # A window that comfortably brackets the single impact below, with a + # timestep ceiling small enough that the clamp has to shorten a step to + # land on it rather than the impact happening to fall on a step edge. + runner.config.params.stop.time.minimum = 1e2 + runner.config.params.stop.time.maximum = 1e5 + runner.config.params.dt.initial = 1e3 + runner.config.params.dt.minimum = 1e2 + runner.config.params.dt.maximum = 1e4 + + runner.config.params.out.plot_mod = 0 + runner.config.params.out.write_mod = 1 + runner.config.params.out.archive_mod = 'none' + + # One impact delivering 0.1 M_earth. num_impacts = 1 puts the whole + # budget in that single impact, so the expected growth is exact rather + # than a share of an exponential. The time sits early in the run: the + # all-dummy planet solidifies and stops the run within about 1e4 yr, so + # a later impact would never be reached. + delivered = 0.1 + impact_time = 4.0e3 + runner.config.accretion.module = 'dummy' + runner.config.accretion.dummy.num_impacts = 1 + runner.config.accretion.dummy.mass_accreted = delivered + runner.config.accretion.dummy.time_last = impact_time + runner.config.accretion.dummy.timescale = 3.0e3 + runner.config.accretion.dummy.eccentricity = 0.05 + runner.config.accretion.impactor_volatiles = 'dry' + + mass_before = runner.config.planet.mass_tot + + runner.start(resume=False, offline=True) + + assert runner.hf_all is not None, 'Helpfile should be created' + hf = runner.hf_all + + # The impact is inside the simulated interval, so the schedule and the + # run actually overlapped. Without this the growth checks below could + # pass vacuously on a run that ended before the impact. + assert hf['Time'].max() > impact_time, ( + f'Run ended at {hf["Time"].max():.3e} yr, before the impact at ' + f'{impact_time:.3e} yr; the test would not have exercised anything' + ) + + # A dry impactor delivers no volatiles, so every kilogram of the + # impactor is rock and the anchor grows by exactly the delivered mass. + assert runner.config.planet.mass_tot == pytest.approx(mass_before + delivered, rel=1e-6) + + # The ledger a resumed run reads back was written, never decreases, and + # ends at the delivered rock. A handler that applied the impact twice + # would overshoot it, and one that never fired would leave it at zero. + assert 'M_accreted_rock' in hf.columns, ( + 'the accreted-rock ledger must be persisted, or a resume cannot ' + 'rebuild the planet the impacts grew' + ) + ledger = hf['M_accreted_rock'].fillna(0.0).values + assert np.all(np.diff(ledger) >= 0.0), 'the accreted-rock ledger must not decrease' + assert ledger[-1] == pytest.approx(delivered * M_earth, rel=1e-6) + # Discrimination: a double application would land at twice this value, + # which is a hundred thousand times the tolerance away. + assert abs(2.0 * delivered * M_earth - ledger[-1]) > 0.5 * ledger[-1] + + # The whole-planet mass agrees with its parts on every row, including + # the impact row where the strip and the delivery change the budgets + # after the structure solve has already written both. + for column in ('M_planet', 'M_int', 'M_ele'): + assert column in hf.columns, f'{column} missing from the helpfile' + assert np.all(np.isfinite(hf[column].values)), f'{column} contains NaN or Inf' + + np.testing.assert_allclose( + hf['M_planet'].values, + hf['M_int'].values + hf['M_ele'].values, + rtol=1e-9, + err_msg='M_planet must equal M_int + M_ele on every row', + ) + + # The planet only ever gains mass here, so the interior mass is + # non-decreasing and strictly larger at the end than at the start. + m_int = hf['M_int'].values + assert m_int[-1] > m_int[0], 'the interior mass must grow across the impact' diff --git a/tests/tools/test_migrate_config_v2_to_v3.py b/tests/tools/test_migrate_config_v2_to_v3.py index 38f548ed8..1322c747e 100644 --- a/tests/tools/test_migrate_config_v2_to_v3.py +++ b/tests/tools/test_migrate_config_v2_to_v3.py @@ -85,7 +85,13 @@ def _v3(): # read once their backend is selected, which migration never does. 'accretion.atmloss_frac', 'accretion.atmloss_module', - 'accretion.dummy.timeline_path', + 'accretion.dummy.eccentricity', + 'accretion.dummy.impact_parameter', + 'accretion.dummy.mass_accreted', + 'accretion.dummy.num_impacts', + 'accretion.dummy.time_last', + 'accretion.dummy.timescale', + 'accretion.timeline.timeline_path', 'accretion.impactor_volatiles', 'accretion.impactor_C_ppmw', 'accretion.impactor_H_ppmw', From 1b628c2693589cfc83eddc687c5afd40b4c5a9db Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 26 Jul 2026 10:37:26 +0200 Subject: [PATCH 22/71] Correct the impact accounting and the new module's scaling limits Follow-up corrections to the accretion work, most of them in the fixes themselves rather than in what they were fixing. The most serious one reached every run in the repository, not just accretion runs. Adding a column to the helpfile schema broke resume for any run started before it: the restored row lacks the column and the row writer rejects a row missing any schema key. Loading a helpfile now backfills whatever the current schema defines and the file does not carry, with zero, which is the right value for the cumulative ledgers this affects since nothing was recorded. That makes every future column addition safe for runs already in flight, rather than only this one. The re-melt guard tested the wrong quantity. It compared the mean entropy of the two profiles, while the value it exists to keep out of the energy budget is a quadrature weighting each cell by volume and by density times temperature. Those weightings disagree with depth, so a profile that rises on average can still integrate to a heat loss and pass the guard. The test is now on the booked quantity itself. It also no longer aborts: a negative value usually means the mantle is already above the state the impact resets it to, which happens whenever two impacts land together or the initial condition shifts with the grown planet, so nothing is booked and the size of the discrepancy is reported. Only the booking was ever the problem. Every resumed run past its first impact printed a warning saying those impacts would not be applied and advising a change to the time offset. On a resume both claims are wrong: the impacts were applied, their mass is restored from the ledger, and following the advice would accrete them a second time. The resume path now reports them as already applied. The restore itself no longer depends on the module still being selected, so continuing a run whose impacts are finished with accretion switched off keeps the mass the planet accreted. The per-step impact heat is cleared where the row is created rather than in one solver's success branch, so a step on which that solver falls back to a retry cannot carry the previous step's injection forward and book it twice. In the analytical module, the mass-radius scaling was extrapolated far below its calibration and returned impactors less dense than their own uncompressed minerals, down to about half the floor, which fed straight into the collision speed and the erosion law. The radius is now capped so bulk density cannot fall below the zero-pressure value of an iron and silicate mixture, which is the correct limit for a small body. The guard on an unusable timescale tested for exact underflow, so an ordinary configuration could still schedule impacts carrying a millionth of the accreted mass, each of which would re-melt the whole mantle; it now tests the delivered masses against a floor. An impactor heavier than its target is refused, since everything downstream treats the target as the surviving body. The chain also carries its eccentricity forward instead of computing each merger against a circular target and then discarding the result. A configuration written for the old file-driven module under the name it used to have is now refused with a message naming its new home, rather than silently being served a generated timeline at default settings. --- docs/Validation/accretion/dummy.md | 15 ++- src/proteus/accretion/dummy.py | 122 +++++++++++++++----- src/proteus/accretion/wrapper.py | 60 +++++++--- src/proteus/config/_accretion.py | 36 ++++-- src/proteus/interior_energetics/wrapper.py | 66 +++++------ src/proteus/proteus.py | 8 ++ src/proteus/utils/coupler.py | 22 +++- tests/accretion/test_dummy.py | 15 ++- tests/accretion/test_wrapper.py | 115 +++++++++++++++++- tests/integration/test_smoke_accretion.py | 45 +++++++- tests/interior_energetics/test_wrapper.py | 57 +++++---- tests/tools/test_migrate_config_v2_to_v3.py | 1 + tests/utils/test_coupler.py | 51 ++++++++ 13 files changed, 484 insertions(+), 129 deletions(-) diff --git a/docs/Validation/accretion/dummy.md b/docs/Validation/accretion/dummy.md index af54714c3..201190de8 100644 --- a/docs/Validation/accretion/dummy.md +++ b/docs/Validation/accretion/dummy.md @@ -19,8 +19,11 @@ The module derives an impact chain rather than integrating one, so what it must certify is that the chain is physically admissible, not that it reproduces any particular system. Mass closes at every merger and over the whole timeline; the collision speed satisfies the floor the timeline validator -enforces, with equality in the circular limit; and the merged orbit is bound, -interior to the target's, and internally consistent in angular momentum. +enforces, with equality in the circular limit; and the merged orbit is bound +and internally consistent in angular momentum. The merged orbit is also never +wider than the one the two bodies shared, but that follows from the co-orbital +geometry the module assumes rather than from mergers in general: bodies meeting +from different semi-major axes can merge onto a wider orbit. The growth law itself is a modelling choice rather than a measured quantity, so it is pinned by its own structure: consecutive impactor masses differ by @@ -30,4 +33,10 @@ which fixes the renormalisation. Radii come from the Noack & Lasbleis (2020) scaling laws through `utils.structure_estimate`, whose own anchors are certified with the dummy -interior structure; this module inherits them rather than restating them. +interior structure. Those laws are calibrated for planets, and their implied +bulk density falls without bound as the mass does, so an impactor well below an +Earth mass would otherwise come out less dense than its own uncompressed +minerals. The radius is capped so the bulk density never falls below the +zero-pressure value of an iron and silicate mixture at the same iron fraction, +which is the correct limit for a small body and is where the certification of +the scaling laws stops applying. diff --git a/src/proteus/accretion/dummy.py b/src/proteus/accretion/dummy.py index 8f4a5743a..16a90c91d 100644 --- a/src/proteus/accretion/dummy.py +++ b/src/proteus/accretion/dummy.py @@ -14,6 +14,19 @@ log = logging.getLogger('fwl.' + __name__) +# Zero-pressure densities of the two components a rocky body is treated as a +# mixture of [kg m-3]. They set the floor the mass-radius scaling is capped +# against, so a small impactor cannot come out less dense than its own minerals. +_RHO_IRON = 7870.0 +_RHO_SILICATE = 3300.0 + +# Smallest impact worth applying, as a fraction of the total accreted mass. The +# growth law's increments decay geometrically, so a timescale far shorter than +# the impact spacing drives the later ones toward zero; each would still re-melt +# the whole mantle and reset the orbit, which a boulder cannot do. Rejecting +# them names the configuration error rather than letting the run apply it. +_MIN_IMPACT_MASS_FRAC = 1.0e-4 + def _body_radius(config: Config, mass: float) -> float: """Radius of a rocky body of the given mass [m]. @@ -23,6 +36,17 @@ def _body_radius(config: Config, mass: float) -> float: planet's configured core fraction so an impactor and its target share a composition. + That scaling is calibrated for planets and its radius grows as + ``M**0.282``, so the bulk density it implies falls without bound as the + mass does. Extrapolated to an impactor a hundred times lighter than Earth + it returns a body less dense than its own uncompressed minerals, which is + impossible and which would propagate into the collision speed and the + erosion law. The radius is therefore capped so the bulk density never falls + below the zero-pressure value of an iron and silicate mixture at the same + iron fraction. Above roughly a tenth of an Earth mass the cap is inactive + and the scaling governs; below it the body is treated as uncompressed, + which is the correct limit for a small body. + Parameters ---------- config : Config @@ -41,7 +65,12 @@ def _body_radius(config: Config, mass: float) -> float: config.interior_struct.core_frac_mode, mass_tot_M_earth=m_ratio, ) - return nl20_planet_radius_km(x_fe, m_ratio) * 1.0e3 + radius = nl20_planet_radius_km(x_fe, m_ratio) * 1.0e3 + + rho_uncompressed = 1.0 / (x_fe / _RHO_IRON + (1.0 - x_fe) / _RHO_SILICATE) + radius_uncompressed = (3.0 * mass / (4.0 * math.pi * rho_uncompressed)) ** (1.0 / 3.0) + + return min(radius, radius_uncompressed) def _impact_masses(config: Config) -> list[float]: @@ -74,20 +103,28 @@ def _impact_masses(config: Config) -> list[float]: math.exp(-edges[k] / tau) - math.exp(-edges[k + 1] / tau) for k in range(n_impacts) ] - # A timescale far from the impact spacing makes the law unusable in one of - # two ways, and both have to be caught here rather than surfacing later as - # a zero-mass impactor. Too short and the law completes inside the first - # interval, so every later weight underflows to zero; too long and the - # accreted fraction over the whole timeline underflows, so they all do. + # A timescale far from the impact spacing makes the law unusable. Too short + # and it completes inside the first interval, leaving the later impacts with + # a vanishing share; too long and the accreted fraction over the whole + # timeline underflows, leaving all of them with one. The test is on the + # delivered masses rather than on the weights, because a weight can be + # positive and still describe an impact too small to be a giant impact, + # which would nonetheless re-melt the mantle and reset the orbit. total = sum(weights) - if total <= 0.0 or min(weights) <= 0.0: + if total <= 0.0: + smallest = 0.0 + else: + smallest = min(weights) / total + + if smallest < _MIN_IMPACT_MASS_FRAC: raise ValueError( f'accretion.dummy.timescale = {tau:.3e} yr cannot distribute mass over ' f'{n_impacts} impacts ending at time_last = {float(dummy.time_last):.3e} yr: ' - 'the accretion law is either finished or has barely begun by the time the ' - 'impacts are spaced, leaving at least one of them with no mass to deliver. ' - 'Bring timescale closer to the impact spacing, ' - f'{float(dummy.time_last) / n_impacts:.3e} yr.' + f'the smallest would carry {smallest:.3e} of the accreted mass, below the ' + f'{_MIN_IMPACT_MASS_FRAC:.0e} floor, which is not a giant impact but would ' + 'still re-melt the mantle and reset the orbit. Bring timescale closer to the ' + f'impact spacing, {float(dummy.time_last) / n_impacts:.3e} yr, or ask for ' + 'fewer impacts.' ) delivered = float(dummy.mass_accreted) * M_earth @@ -103,7 +140,12 @@ def _impact_times(config: Config) -> list[float]: def _merged_orbit( - m_target: float, m_impactor: float, a_target: float, eccentricity: float, m_star: float + m_target: float, + m_impactor: float, + a_target: float, + e_target: float, + e_impactor: float, + m_star: float, ) -> tuple[float, float, float]: """Orbit and encounter velocity produced by a perfect merger. @@ -111,22 +153,25 @@ def _merged_orbit( leaves the collision point with the mass-weighted mean of the two velocities, and its orbit follows from that velocity at that radius. - The geometry is coplanar and fully determined by one parameter: the target - is on a circular orbit of radius ``a_target``, and the impactor is on an - orbit of the same semi-major axis with eccentricity ``eccentricity``, - evaluated where it crosses the target. At that radius the impactor's speed - equals the circular speed while its velocity is tilted, which is what - supplies the relative velocity at contact. In the small-eccentricity limit - that relative velocity reduces to ``eccentricity * v_kep``. + The geometry is coplanar and co-orbital: both bodies share the semi-major + axis ``a_target`` and are evaluated where they cross that radius, each with + its own eccentricity. At that radius a body's speed equals the circular + speed whatever its eccentricity, while its velocity is tilted out of the + tangential direction by an amount the eccentricity sets, and that tilt is + what supplies the relative velocity at contact. The two are taken to cross + in opposite radial directions, one rising and one falling, which is the + configuration that brings them together. For a circular target and small + impactor eccentricity the relative velocity reduces to + ``e_impactor * v_kep``. Parameters ---------- m_target, m_impactor : float Masses of the two bodies [kg]. a_target : float - Semi-major axis of the target's circular orbit [m]. - eccentricity : float - Eccentricity of the impactor's orbit [1]. + Shared semi-major axis [m]. + e_target, e_impactor : float + Eccentricities of the two bodies' orbits [1]. m_star : float Mass of the host star [kg]. @@ -143,12 +188,12 @@ def _merged_orbit( mu = const_G * m_star v_kep = math.sqrt(mu / a_target) - # Velocity components at the crossing radius, (radial, tangential). The - # target is circular, so it is purely tangential. The impactor shares the - # semi-major axis, so it shares the speed, but carries the angular momentum - # of an eccentric orbit and makes up the rest radially. - v_target = (0.0, v_kep) - v_impactor = (v_kep * eccentricity, v_kep * math.sqrt(1.0 - eccentricity**2)) + # Velocity components at the crossing radius, (radial, tangential). A body + # sharing the semi-major axis shares the speed, but an eccentric one carries + # less angular momentum and makes up the difference radially. The two cross + # in opposite radial senses, so their radial components have opposite signs. + v_target = (-v_kep * e_target, v_kep * math.sqrt(1.0 - e_target**2)) + v_impactor = (v_kep * e_impactor, v_kep * math.sqrt(1.0 - e_impactor**2)) v_encounter = math.hypot( v_impactor[0] - v_target[0], @@ -162,9 +207,10 @@ def _merged_orbit( ) # Vis-viva at the collision radius, then the angular momentum fixes the - # eccentricity. Averaging two bound velocities at one radius can only lower - # the specific energy, so the merged orbit is always bound and interior to - # the target's. + # eccentricity. Averaging two velocities of equal magnitude can only lower + # the speed, so under this co-orbital geometry the merged orbit is always + # bound and never wider than the one the bodies shared. That is a property + # of the shared semi-major axis, not a general result for mergers. speed_sq = v_merged[0] ** 2 + v_merged[1] ** 2 a_after = 1.0 / (2.0 / a_target - speed_sq / mu) @@ -204,16 +250,27 @@ def get_timeline(config: Config) -> list[ImpactEvent]: m_target = float(config.planet.mass_tot) * M_earth a_target = float(config.orbit.semimajoraxis) * AU + e_target = float(config.orbit.eccentricity) events = [] for index, (time, m_impactor) in enumerate(zip(times, masses)): m_merged = m_target + m_impactor + if m_impactor > m_target: + raise ValueError( + f'Impact {index} would strike a target lighter than the impactor ' + f'({m_impactor / M_earth:.4f} onto {m_target / M_earth:.4f} M_earth). ' + 'Everything downstream treats the target as the surviving body: it is ' + 'the target whose mantle re-melts and whose atmosphere is stripped, so ' + 'the roles cannot be reversed. Lower accretion.dummy.mass_accreted or ' + 'raise planet.mass_tot.' + ) + r_target = _body_radius(config, m_target) r_impactor = _body_radius(config, m_impactor) a_after, e_after, v_encounter = _merged_orbit( - m_target, m_impactor, a_target, eccentricity, m_star + m_target, m_impactor, a_target, e_target, eccentricity, m_star ) # Contact speed: the encounter velocity, focused by the pair's mutual @@ -246,6 +303,7 @@ def get_timeline(config: Config) -> list[ImpactEvent]: m_target = m_merged a_target = a_after + e_target = e_after validate_timeline(events) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index 13783259a..ffbb27d68 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -113,7 +113,9 @@ def init_accretion(handler: Proteus) -> list[ImpactEvent]: events = get_timeline(config) write_timeline(events, resolved_path) - return _drop_events_before_start(events, handler.hf_row.get('Time', 0.0)) + return _drop_events_before_start( + events, handler.hf_row.get('Time', 0.0), resumed=bool(config.params.resume) + ) def restore_accretion_state(handler: Proteus) -> None: @@ -145,7 +147,11 @@ def restore_accretion_state(handler: Proteus) -> None: """ config = handler.config - if config.accretion.module is None or not config.params.resume: + # Driven by the ledger, not by the module setting. Turning accretion off to + # continue a run whose impacts are done is a reasonable thing to do, and it + # must not silently revert the planet to its configured mass: what the + # helpfile records is what happened, whatever the module is set to now. + if not config.params.resume: return hf_row = handler.hf_row @@ -693,14 +699,20 @@ def _restore_volatile_budgets(hf_row: dict, budgets: dict) -> None: def _drop_events_before_start( - events: list[ImpactEvent], time_start: float + events: list[ImpactEvent], time_start: float, resumed: bool = False ) -> list[ImpactEvent]: - """Remove impacts that precede the start of the simulation. + """Remove impacts that precede the current point on the time axis. + + On a fresh run the configuration owns the planet's initial mass and orbit, + so an impact landing before the run begins cannot be applied without + contradicting it. Such impacts are reported rather than dropped in silence, + since they usually mean the time offset needs adjusting. - The configuration owns the planet's initial mass and orbit, so an - impact that lands before the run begins cannot be applied without - contradicting it. Such impacts are reported rather than dropped in - silence, since they usually mean the time offset needs adjusting. + On a resume the same filter serves the opposite purpose: it removes impacts + the earlier session already applied, whose mass the planet is carrying and + whose rock is restored from the helpfile. Those are not missing from the + run, so they are reported as already applied and the offset advice is + withheld, because acting on it would apply them a second time. Parameters ---------- @@ -708,26 +720,38 @@ def _drop_events_before_start( Timeline, in time order. time_start : float Simulation time at the start of the run [yr]. + resumed : bool + Whether this run is resuming an earlier session. Returns ------- kept : list of ImpactEvent - Impacts at or after the start of the run. + Impacts after the current point on the time axis. """ kept = [e for e in events if e.time > time_start] dropped = len(events) - len(kept) if dropped: missed_mass = sum(e.mass_delta for e in events if e.time <= time_start) - log.warning( - '%d impact(s) fall at or before the start of the run (t = %.4e yr) and ' - 'will not be applied, because the configured planet mass and orbit define ' - 'the initial state. They would have added %.4f M_earth. Adjust ' - 'accretion.time_offset to bring them into the simulated interval.', - dropped, - time_start, - missed_mass / M_earth, - ) + if resumed: + log.info( + '%d impact(s) fall at or before the resume point (t = %.4e yr) and were ' + 'applied by an earlier session, adding %.4f M_earth that the planet is ' + 'already carrying.', + dropped, + time_start, + missed_mass / M_earth, + ) + else: + log.warning( + '%d impact(s) fall at or before the start of the run (t = %.4e yr) and ' + 'will not be applied, because the configured planet mass and orbit define ' + 'the initial state. They would have added %.4f M_earth. Adjust ' + 'accretion.time_offset to bring them into the simulated interval.', + dropped, + time_start, + missed_mass / M_earth, + ) log.info('Scheduled %d impact(s)', len(kept)) if kept: diff --git a/src/proteus/config/_accretion.py b/src/proteus/config/_accretion.py index 71ce8519d..7733d74ee 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -118,6 +118,19 @@ class Morrigan: selector_value: float | str | None = field(default=None, converter=none_if_none) +def valid_accretiondummy(instance, attribute, value): + """Refuse a timeline path aimed at the module that generates its own.""" + if instance.dummy.timeline_path is None: + return + + raise ValueError( + '`accretion.dummy.timeline_path` is not a parameter of the analytical ' + 'accretion module, which derives its own impact history. To replay a ' + "timeline from a file, set `accretion.module = 'timeline'` and move the " + 'path to `accretion.timeline.timeline_path`.' + ) + + def valid_accretiontimeline(instance, attribute, value): if instance.module != 'timeline': return @@ -188,8 +201,14 @@ class AccretionDummy: impact_parameter: float Impact parameter of every collision [1], the sine of the impact angle. Zero is head-on, one is grazing. + timeline_path: str or None + Not a parameter of this module. Present only to reject a configuration + that sets it here, which asks to replay a file and would otherwise be + served a generated timeline at default settings. Use + ``accretion.module = "timeline"`` and ``accretion.timeline.timeline_path``. """ + timeline_path: str | None = field(default=None, converter=none_if_none) mass_accreted: float = field(default=0.1, validator=gt(0)) num_impacts: int = field(default=3, validator=ge(1)) timescale: float = field(default=1.0e6, validator=gt(0)) @@ -235,12 +254,15 @@ class Accretion: appreciably, which is the regime this coupling targets, and diverge outside it: a mantle already near the initial condition absorbs almost nothing, and a cool mantle struck by a small impactor absorbs far more than the impact - supplied. The run reports the ratio of the two at every impact and warns - when it leaves the physically expected band, because the conservation - residual cannot detect the discrepancy: the injection is added to both of - its sides, so it stays closed for any injected value. Interpret the thermal - response to an impact as a property of the chosen initial condition, and - check that ratio before reading it as a consequence of the collision. + supplied. On the Aragog interior, which resolves an entropy profile and so + can quantify the injection, the run reports it as a fraction of the impact + kinetic energy and warns when that fraction leaves the physically expected + band. That report is the only check available, because the conservation + residual adds the injection to both of its sides and so stays closed for + any value. The scalar interiors reset a temperature rather than a profile + and book no injection, so neither the report nor the warning applies there. + Interpret the thermal response to an impact as a property of the chosen + initial condition rather than of the collision. Attributes ---------- @@ -306,7 +328,7 @@ class Accretion: ) morrigan: Morrigan = field(factory=Morrigan, validator=valid_morrigan) - dummy: AccretionDummy = field(factory=AccretionDummy) + dummy: AccretionDummy = field(factory=AccretionDummy, validator=valid_accretiondummy) timeline: AccretionTimeline = field( factory=AccretionTimeline, validator=valid_accretiontimeline ) diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index c2a948615..7ee51ae16 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -86,13 +86,6 @@ # counter resets on each successful Aragog call. _ARAGOG_MAX_CONSECUTIVE_FAILS = 3 -# Slack on the "a giant-impact re-melt cannot cool the mantle" guard, as a -# fraction of the pre-impact mean entropy. Sized to absorb the round-off of the -# EOS lookup that builds the molten profile while still rejecting a mode whose -# initial condition genuinely sits below the current state, which lands orders -# of magnitude below this bound rather than just inside it. -_REMELT_ENTROPY_RTOL = 1e-6 - # Band the giant-impact re-melt injection is expected to occupy as a fraction # of the collision's kinetic energy. Giant-impact studies retain of order tens # of percent of the impact energy as mantle heat, the rest leaving as ejecta @@ -1910,31 +1903,6 @@ def _remelt_aragog(config: Config, dirs: dict, hf_row: dict, interior_o) -> None S_molten = AragogRunner._set_entropy_ic(config, interior_o, dirs['output'], hf_row) S_molten = np.asarray(S_molten, dtype=float).ravel() - # An impact deposits energy, so the re-melt cannot leave the mantle cooler - # than it found it. A temperature mode whose initial condition sits below - # the current state would do exactly that and book negative impact heat, - # which is unphysical and would corrupt the budget. The test is on the mean - # entropy rather than node by node, so a single-node EOS wiggle at a - # boundary cannot trip it, and relative because entropy here is - # positive-definite and of order 1e3 J kg-1 K-1. Checked before the carrier - # is rewritten, so a refused re-melt leaves no half-applied state behind. - if ( - S_cooled is not None - and S_cooled.size > 0 - and float(S_molten.mean()) < float(S_cooled.mean()) * (1.0 - _REMELT_ENTROPY_RTOL) - ): - raise RuntimeError( - 'Giant-impact re-melt would cool the mantle: the ' - f"temperature_mode='{config.planet.temperature_mode}' initial " - 'condition sits below the current thermal state, so the impact ' - 'would remove heat instead of adding it. Use ' - "temperature_mode='liquidus_super', which is molten for any " - 'planet mass and melting curve, or raise the initial state this ' - 'mode is anchored to.' - ) - - interior_o._last_entropy = S_molten.copy() - # Book the injected heat over the cooled-to-molten entropy jump, in the # residual's own frame: the solver's Σ V_i ∫ rho(P_i,S) T(P_i,S) dS # quadrature evaluated between the two profiles. Positive when the re-melt @@ -1942,13 +1910,43 @@ def _remelt_aragog(config: Config, dirs: dict, hf_row: dict, interior_o) -> None # state integral carries it; this column is how it enters the budget. if S_cooled is not None and S_cooled.size > 0: dE_impact = float(solver._step_heat_content(S_cooled, S_molten)) + + # An impact deposits energy, so the re-melt cannot book a heat loss. The + # test is on the booked quantity itself rather than on a summary of the + # entropy profiles: the quadrature weights each cell by its volume and + # by rho*T, and those weightings pull in opposite directions with depth, + # so a profile that rises on average can still integrate to a loss. + # + # A negative value means the mantle is already above the state this + # impact resets it to, which happens when two impacts fall close + # together, when the initial condition shifts with the grown planet, or + # when the temperature mode is anchored below the current state. None of + # those is an energy source, so nothing is booked; clamping rather than + # aborting keeps a long run alive, and the warning carries the size of + # the discrepancy so it can be judged from the log. + if dE_impact < 0.0: + log.warning( + ' re-melt would remove %.3e J rather than add heat: the mantle is ' + "above the temperature_mode='%s' state this impact resets it to. " + 'Nothing is booked. If this is not a pair of impacts landing together, ' + 'the initial condition is too cool for this planet; temperature_mode=' + "'liquidus_super' is molten for any mass and melting curve.", + abs(dE_impact), + config.planet.temperature_mode, + ) + dE_impact = 0.0 + + interior_o._last_entropy = S_molten.copy() + # Accumulate rather than assign: when two impacts fall inside one # timestep the second re-melt measures an already-molten mantle and # contributes almost nothing, and assigning would discard the first - # impact's injection from the row. The next solve re-zeros the column. + # impact's injection from the row. The row is zeroed when it is created, + # so the column cannot accumulate across steps. hf_row['step_dE_impact_J'] = float(hf_row.get('step_dE_impact_J') or 0.0) + dE_impact log.info(' re-melt heat injection %.3e J booked into the energy budget', dE_impact) else: + interior_o._last_entropy = S_molten.copy() # No prior profile to measure the jump from (no completed solve has # stored one). The injection cannot be quantified, so it is left # unbooked and said so, rather than booking a silent zero. An earlier @@ -2051,7 +2049,7 @@ def remelt_mantle(dirs: dict, config: Config, hf_row: dict, interior_o, event=No dE_impact = float(hf_row.get('step_dE_impact_J') or 0.0) - booked_before log.info(' impact kinetic energy %.3e J', e_impact) - if e_impact > 0.0 and dE_impact > 0.0: + if e_impact > 0.0 and dE_impact != 0.0: retained = dE_impact / e_impact log.info(' re-melt injection is %.3f of the impact kinetic energy', retained) if not _REMELT_RETAINED_BAND[0] <= retained <= _REMELT_RETAINED_BAND[1]: diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index fa6311db4..159adca18 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -759,6 +759,14 @@ def start(self, *, resume: bool = False, offline: bool = False): # Create new row to hold the updated variables. This will be # overwritten by the routines below. self.hf_row = self.hf_all.iloc[-1].to_dict() + + # Per-step impact heat starts at zero on every row. The column + # accumulates within a step, because several impacts can land + # in one, so carrying the previous row's value forward would + # book an earlier impact's heat again. Cleared here rather than + # in a solver's success branch so it holds for every interior + # module and for the paths that return before that branch. + self.hf_row['step_dE_impact_J'] = 0.0 log.info(' ') PrintSeparator() log.info('Loop counters') diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index 87c0f4ecc..670c71fd9 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -1166,11 +1166,31 @@ def WriteHelpfileToCSV(output_dir: str, current_hf: pd.DataFrame): def ReadHelpfileFromCSV(output_dir: str): """ Read helpfile from disk CSV file to DataFrame + + Columns the current schema defines but the file does not carry are added + as zero. A run started under an earlier schema writes a helpfile without + them, and a resume feeds its last row straight back into ``ExtendHelpfile``, + which rejects a row missing any schema key. Backfilling here keeps a run in + flight when a new column is added, and zero is the right value for the + cumulative ledgers this affects: nothing was recorded, so nothing accrued. """ fpath = os.path.join(output_dir, 'runtime_helpfile.csv') if not os.path.exists(fpath): raise Exception("Cannot find helpfile at '%s'" % fpath) - return pd.read_csv(fpath, sep=r'\s+') + df = pd.read_csv(fpath, sep=r'\s+') + + missing = [key for key in GetHelpfileKeys() if key not in df.columns] + if missing: + log.warning( + 'Helpfile predates %d column(s) in the current schema; backfilling with zero: %s', + len(missing), + ', '.join(sorted(missing)), + ) + # Added in one concat rather than one insert per column, which would + # fragment the frame and warn on a schema several columns behind. + df = pd.concat([df, pd.DataFrame(0.0, index=df.index, columns=missing)], axis=1) + + return df def _netcdf_readable(path: str) -> bool: diff --git a/tests/accretion/test_dummy.py b/tests/accretion/test_dummy.py index a8775bff4..e9aacefee 100644 --- a/tests/accretion/test_dummy.py +++ b/tests/accretion/test_dummy.py @@ -36,6 +36,7 @@ def _config( impact_parameter=0.5, mass_tot=1.0, semimajoraxis=1.0, + orbit_eccentricity=0.0, star_mass=1.0, time_offset=0.0, ): @@ -54,7 +55,7 @@ def _config( ), ), planet=SimpleNamespace(mass_tot=mass_tot), - orbit=SimpleNamespace(semimajoraxis=semimajoraxis), + orbit=SimpleNamespace(semimajoraxis=semimajoraxis, eccentricity=orbit_eccentricity), star=SimpleNamespace(mass=star_mass), interior_struct=SimpleNamespace(core_frac=0.55, core_frac_mode='radius'), ) @@ -186,10 +187,12 @@ def test_a_circular_encounter_leaves_the_orbit_untouched(): def test_a_merger_shrinks_the_orbit_and_leaves_it_bound(): """Conserving momentum through a collision can only lower the orbit. - Averaging two bound velocities at one radius lowers the specific orbital - energy, so the merged semi-major axis must be smaller than the target's and - the orbit must stay bound. An implementation that conserved energy instead - of momentum, the plausible alternative, would not shrink the orbit at all. + Averaging two velocities of equal magnitude lowers the specific orbital + energy, so under the co-orbital geometry this module assumes the merged + semi-major axis must be smaller than the target's and the orbit must stay + bound. That is a property of the shared semi-major axis, not a general + result for mergers. An implementation that conserved energy instead of + momentum, the plausible alternative, would not shrink the orbit at all. The merged orbit is also checked against vis-viva evaluated on the independently computed merged velocity. """ @@ -289,7 +292,7 @@ def test_merged_orbit_conserves_angular_momentum_of_the_merged_body(): eccentricity = 0.2 a_after, e_after, v_encounter = _merged_orbit( - m_target, m_impactor, a_target, eccentricity, m_star + m_target, m_impactor, a_target, 0.0, eccentricity, m_star ) v_kep = math.sqrt(mu / a_target) diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index bbe4232e3..54fa1749a 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -1486,6 +1486,11 @@ def test_the_accretion_restore_is_inert_outside_a_resume(): when the configuration already describes the current planet would double the growth. It must therefore be a strict no-op unless the run is a resume that has actually accreted something. + + Turning the module off is deliberately NOT one of those conditions. + Continuing a run whose impacts are finished by setting the module to none is + a reasonable thing to do, and the planet must keep the mass it accreted: the + ledger records what happened, whatever the module is set to now. """ from proteus.accretion.wrapper import restore_accretion_state from proteus.utils.constants import M_earth @@ -1503,7 +1508,6 @@ def _handler_for(resume, module, accreted): for resume, module, accreted in ( (False, 'morrigan', 0.5 * M_earth), # fresh run - (True, None, 0.5 * M_earth), # module disabled (True, 'morrigan', 0.0), # resumed before any impact landed ): handler = _handler_for(resume, module, accreted) @@ -1512,6 +1516,12 @@ def _handler_for(resume, module, accreted): assert handler.config.orbit.semimajoraxis == pytest.approx(1.0, rel=1e-12) assert handler.config.orbit.eccentricity == pytest.approx(0.0, rel=1e-12) + # Accretion switched off after the impacts finished: the growth survives, + # because the ledger and not the module setting is what records it. + switched_off = _handler_for(True, None, 0.5 * M_earth) + restore_accretion_state(switched_off) + assert switched_off.config.planet.mass_tot == pytest.approx(1.5, rel=1e-12) + @pytest.mark.unit def test_a_resumed_run_replays_the_timeline_the_first_session_resolved(tmp_path): @@ -1580,3 +1590,106 @@ def test_the_recorded_timeline_is_not_offset_a_second_time(tmp_path): assert replayed[0].time == pytest.approx(1.0e5 + offset) # Discrimination: a second application would put it at 1.0e5 + 2 * offset. assert abs((1.0e5 + 2 * offset) - replayed[0].time) > 0.5 * offset + + +@pytest.mark.unit +def test_a_temperature_mode_without_a_molten_guarantee_is_flagged(tmp_path, caplog): + """Only liquidus_super suppresses the re-melt advisory on Aragog. + + Each impact re-melts the mantle by re-applying the run's temperature-mode + initial condition, and only liquidus_super is molten for any planet mass + and melting curve. The modes that merely tend to be molten, and are often + chosen for exactly that reason, must still draw the advisory: treating them + as guarantees is what lets a run apply an impact that melts nothing and + report it as a re-melt. + """ + import logging + + from proteus.accretion.wrapper import init_accretion + + path = _timeline_file(tmp_path / 't.csv') + + for mode in ('adiabatic_from_cmb', 'accretion', 'isothermal'): + caplog.clear() + handler = _handler( + module='timeline', + timeline_path=path, + output_dir=tmp_path, + interior_module='aragog', + temperature_mode=mode, + ) + with caplog.at_level(logging.WARNING, logger='fwl.proteus.accretion.wrapper'): + init_accretion(handler) + assert 'not guaranteed' in caplog.text, f'{mode} must draw the advisory' + assert mode in caplog.text + + # The one mode that does guarantee it stays quiet, so the advisory + # discriminates rather than firing for everything. + caplog.clear() + handler = _handler( + module='timeline', + timeline_path=path, + output_dir=tmp_path, + interior_module='aragog', + temperature_mode='liquidus_super', + ) + with caplog.at_level(logging.WARNING, logger='fwl.proteus.accretion.wrapper'): + init_accretion(handler) + assert 'not guaranteed' not in caplog.text + + # A scalar interior re-melts by resetting a temperature, so the advisory + # about the entropy initial condition does not apply to it at all. + caplog.clear() + handler = _handler( + module='timeline', + timeline_path=path, + output_dir=tmp_path, + interior_module='dummy', + temperature_mode='isothermal', + ) + with caplog.at_level(logging.WARNING, logger='fwl.proteus.accretion.wrapper'): + init_accretion(handler) + assert 'not guaranteed' not in caplog.text + + +@pytest.mark.unit +def test_a_resumed_run_does_not_advise_changing_the_time_offset(tmp_path, caplog): + """Impacts before a resume point were applied, and are reported as such. + + The same filter serves opposite purposes on the two paths. On a fresh run an + impact before the start cannot be applied and the offset is the fix. On a + resume the identical impacts were already applied and their mass is restored + from the ledger, so repeating the fresh-run advice would tell a user to + bring them back and accrete them a second time. + """ + import logging + + from proteus.accretion.wrapper import init_accretion + + path = _timeline_file(tmp_path / 't.csv') + + # Fresh run starting after the first impact: the advice is correct there. + fresh = _handler( + module='timeline', timeline_path=path, time_start=2.0e5, output_dir=tmp_path + ) + with caplog.at_level(logging.INFO, logger='fwl.proteus.accretion.wrapper'): + init_accretion(fresh) + assert 'time_offset' in caplog.text + assert 'will not be applied' in caplog.text + + # Resume past the first impact: same drop, opposite meaning. + caplog.clear() + resumed = _handler( + module='timeline', + timeline_path=path, + time_start=2.0e5, + output_dir=tmp_path, + resume=True, + ) + with caplog.at_level(logging.INFO, logger='fwl.proteus.accretion.wrapper'): + events = init_accretion(resumed) + + assert 'time_offset' not in caplog.text + assert 'already carrying' in caplog.text + # The surviving schedule is the same either way; only the report differs. + assert [e.time for e in events] == [5.0e5] diff --git a/tests/integration/test_smoke_accretion.py b/tests/integration/test_smoke_accretion.py index 71166b2d3..6f0fa1c4e 100644 --- a/tests/integration/test_smoke_accretion.py +++ b/tests/integration/test_smoke_accretion.py @@ -68,13 +68,14 @@ def test_smoke_accretion_impact_lands_inside_the_coupled_loop(): runner.config.planet.tsurf_init = 2000.0 - # A window that comfortably brackets the single impact below, with a - # timestep ceiling small enough that the clamp has to shorten a step to - # land on it rather than the impact happening to fall on a step edge. + # A window that comfortably brackets the single impact below. The + # timestep floor is far smaller than the shortening the clamp needs, so + # a step can land exactly on the impact time; leaving the floor above + # that shortening would let the run overshoot and still look correct. runner.config.params.stop.time.minimum = 1e2 runner.config.params.stop.time.maximum = 1e5 runner.config.params.dt.initial = 1e3 - runner.config.params.dt.minimum = 1e2 + runner.config.params.dt.minimum = 1e0 runner.config.params.dt.maximum = 1e4 runner.config.params.out.plot_mod = 0 @@ -96,6 +97,13 @@ def test_smoke_accretion_impact_lands_inside_the_coupled_loop(): runner.config.accretion.dummy.eccentricity = 0.05 runner.config.accretion.impactor_volatiles = 'dry' + # Strip a fixed fraction of the atmosphere as well, so the ordering + # against escape and outgassing is exercised rather than skipped. The + # constant module is used because it needs no optional dependency. + atmloss = 0.25 + runner.config.accretion.atmloss_module = 'constant' + runner.config.accretion.atmloss_frac = atmloss + mass_before = runner.config.planet.mass_tot runner.start(resume=False, offline=True) @@ -111,6 +119,16 @@ def test_smoke_accretion_impact_lands_inside_the_coupled_loop(): f'{impact_time:.3e} yr; the test would not have exercised anything' ) + # A step lands exactly on the impact time. The adaptive controller would + # not choose that time on its own, so this is the timestep clamp doing + # its job: without it the impact fires on whichever step first overshoots + # and the planet grows at the wrong moment. + times = hf['Time'].values + assert np.any(np.isclose(times, impact_time, rtol=0, atol=1e-6)), ( + f'no step landed on the impact time {impact_time:.4e} yr; ' + f'nearest was {times[np.argmin(np.abs(times - impact_time))]:.6e} yr' + ) + # A dry impactor delivers no volatiles, so every kilogram of the # impactor is rock and the anchor grows by exactly the delivered mass. assert runner.config.planet.mass_tot == pytest.approx(mass_before + delivered, rel=1e-6) @@ -147,3 +165,22 @@ def test_smoke_accretion_impact_lands_inside_the_coupled_loop(): # non-decreasing and strictly larger at the end than at the start. m_int = hf['M_int'].values assert m_int[-1] > m_int[0], 'the interior mass must grow across the impact' + + # The atmospheric strip ran and was booked into the loss ledger the + # desiccation criterion audits. Without this the ordering claim in this + # file's docstring would be untested, because a strip of zero exercises + # nothing about where the strip sits relative to escape and outgassing. + assert 'esc_kg_cumulative' in hf.columns + ledger = hf['esc_kg_cumulative'].fillna(0.0).values + assert np.all(np.diff(ledger) >= 0.0), 'the loss ledger must not decrease' + assert ledger[-1] > 0.0, ( + 'the impact strip removed nothing, so the strip path was not exercised' + ) + + # The strip is bounded by the atmosphere it is drawn from: it can never + # remove more than the whole atmosphere, whatever the fraction asks for. + assert 'M_atm' in hf.columns + assert np.all(hf['M_atm'].values >= 0.0), 'atmospheric mass must stay non-negative' + assert np.all(hf['M_atm'].values <= hf['M_planet'].values), ( + 'the atmosphere cannot outweigh the planet carrying it' + ) diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index e763337ad..42f4eaa6c 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -5951,17 +5951,25 @@ def test_remelt_refuses_spider_and_rejects_an_unknown_backend(): @pytest.mark.unit @pytest.mark.physics_invariant -def test_a_remelt_that_would_cool_the_mantle_is_refused(): - """An impact adds energy, so the re-melt cannot lower the mantle entropy. +def test_a_remelt_that_would_cool_the_mantle_books_nothing(caplog): + """An impact adds energy, so the re-melt can never book a heat loss. The re-melt re-applies the run's temperature-mode initial condition. Only 'liquidus_super' guarantees that condition is molten for any planet mass and melting curve; a mode anchored on a user-supplied temperature can sit - below the current thermal state, in which case the "re-melt" would cool the - mantle and book negative impact heat. Negative impact heat is unphysical - and enters both sides of the energy budget, so it would corrupt the ledger - silently rather than fail. The run must stop instead, naming the mode. + below the current thermal state, and so can any mode once the mantle is + already at the state a second impact would reset it to. Booking the + resulting negative value would corrupt the energy ledger silently, because + it enters both sides of the residual and leaves it closed. Nothing is + booked, and the discrepancy is reported with its size and the mode. + + The guard is on the quadrature result rather than on a summary of the two + entropy profiles, because the quadrature weights each cell by volume and by + rho*T and those weightings disagree with depth: a profile that rises on + average can still integrate to a loss. """ + import logging + cooled = np.full(6, 3900.0) # already hotter than the IC below solver = _FakeAragogSolver(cooled_profile=cooled) interior_o = SimpleNamespace( @@ -5971,27 +5979,30 @@ def test_a_remelt_that_would_cool_the_mantle_is_refused(): config.planet.temperature_mode = 'adiabatic_from_cmb' colder_ic = np.full(6, 2400.0) + would_remove = 6 * (3900.0 - 2400.0) * _FakeAragogSolver._HEAT_PER_ENTROPY hf_row = {} - with patch( - 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', - side_effect=lambda cfg, io, outdir, row: colder_ic, - ): - with pytest.raises(RuntimeError, match='cool the mantle'): - remelt_mantle({'output': '/tmp/out'}, config, hf_row=hf_row, interior_o=interior_o) - - # The offending mode is named, so the message is actionable rather than - # just reporting that something went wrong. - with patch( - 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', - side_effect=lambda cfg, io, outdir, row: colder_ic, - ): - with pytest.raises(RuntimeError, match='adiabatic_from_cmb'): + with caplog.at_level(logging.WARNING, logger='fwl.proteus.interior_energetics.wrapper'): + with patch( + 'proteus.interior_energetics.aragog.AragogRunner._set_entropy_ic', + side_effect=lambda cfg, io, outdir, row: colder_ic, + ): remelt_mantle({'output': '/tmp/out'}, config, hf_row=hf_row, interior_o=interior_o) - # No heat was booked: the guard fires before the quadrature, so a corrupt - # value cannot reach the row even transiently. - assert hf_row.get('step_dE_impact_J', 0.0) == 0.0 + # Nothing is booked, so the negative value never reaches the budget. + assert hf_row['step_dE_impact_J'] == 0.0 + # Discrimination: booking it would have put -1.35e31 J into both residual + # sides, which is the whole re-melt enthalpy rather than a rounding of it. + assert would_remove > 1e30 + + # The report names the mode and carries the size, so the configuration + # error is actionable from the log rather than merely noted. + assert 'adiabatic_from_cmb' in caplog.text + assert 'remove' in caplog.text + + # The re-melt still takes effect: the reset is a thermodynamic convention + # and only the energy booking is suppressed. + np.testing.assert_allclose(interior_o._last_entropy, colder_ic) @pytest.mark.unit diff --git a/tests/tools/test_migrate_config_v2_to_v3.py b/tests/tools/test_migrate_config_v2_to_v3.py index 1322c747e..5bdfe4698 100644 --- a/tests/tools/test_migrate_config_v2_to_v3.py +++ b/tests/tools/test_migrate_config_v2_to_v3.py @@ -86,6 +86,7 @@ def _v3(): 'accretion.atmloss_frac', 'accretion.atmloss_module', 'accretion.dummy.eccentricity', + 'accretion.dummy.timeline_path', 'accretion.dummy.impact_parameter', 'accretion.dummy.mass_accreted', 'accretion.dummy.num_impacts', diff --git a/tests/utils/test_coupler.py b/tests/utils/test_coupler.py index 79b7158e8..4786a9fbe 100644 --- a/tests/utils/test_coupler.py +++ b/tests/utils/test_coupler.py @@ -3039,3 +3039,54 @@ def test_select_resumable_snapshot_rejects_cross_row_atm_collision(tmp_path): # cannot load it against the 30.2-trimmed helpfile. assert not (data / '31.json.incomplete').exists() assert not (data / '31.json').exists() + + +@pytest.mark.unit +def test_a_helpfile_predating_a_schema_column_still_resumes(tmp_path): + """A run in flight when a column is added must survive its own resume. + + The helpfile a run writes carries the schema in force when it started. + Adding a column and resuming feeds that file's last row straight back into + ExtendHelpfile, which rejects a row missing any schema key, so without a + backfill every in-flight run in the fleet dies on its next restart, whether + or not it uses the feature the column belongs to. The backfill is zero, + which is correct for the cumulative ledgers this affects: nothing was + recorded, so nothing accrued. + """ + from proteus.utils.coupler import ( + ExtendHelpfile, + GetHelpfileKeys, + ReadHelpfileFromCSV, + ZeroHelpfileRow, + ) + + absent = ('M_accreted_rock', 'esc_kg_cumulative') + row = ZeroHelpfileRow() + for key in absent: + assert key in row, f'{key} must be in the current schema for this test to mean anything' + del row[key] + + pd.DataFrame([row]).to_csv(tmp_path / 'runtime_helpfile.csv', sep='\t', index=False) + + loaded = ReadHelpfileFromCSV(str(tmp_path)) + + # Every schema column is present, and the ones that were absent read zero + # rather than NaN, which would poison any later arithmetic on them. + for key in GetHelpfileKeys(): + assert key in loaded.columns, f'{key} missing after backfill' + for key in absent: + assert loaded[key].iloc[-1] == pytest.approx(0.0, abs=1e-30) + assert np.isfinite(loaded[key].iloc[-1]) + + # The resume path itself: the restored row is accepted. + ExtendHelpfile(loaded, loaded.iloc[-1].to_dict()) + + # Columns the file did carry are untouched, so the backfill does not + # overwrite real data with zeros. + original = ZeroHelpfileRow() + original['T_surf'] = 1234.5 + for key in absent: + del original[key] + pd.DataFrame([original]).to_csv(tmp_path / 'runtime_helpfile.csv', sep='\t', index=False) + reloaded = ReadHelpfileFromCSV(str(tmp_path)) + assert reloaded['T_surf'].iloc[-1] == pytest.approx(1234.5, rel=1e-9) From 70b2ce1de12757bc8c0c8c704159c36224c7808e Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 26 Jul 2026 11:30:46 +0200 Subject: [PATCH 23/71] Apply an impact's orbit change to both elements, not just the semi-major axis The semi-major axis moves by the fractional change an impact made, because the configuration owns the planet's orbit and a borrowed impact history should move it rather than replace it. The eccentricity did not: it took the followed body's absolute post-impact value. A user who set an eccentricity deliberately, most often for tidal heating, had it overwritten at the first impact, and with tides off the orbit is re-pinned from the configuration every step so it never recovered. Tidal heating goes as the square of eccentricity, so this was a large change applied silently. Both elements now move by the change the collision made: the ratio for the semi-major axis, the difference for the eccentricity. A difference rather than a ratio because eccentricity is dimensionless and routinely zero, which a ratio cannot express. The result is clamped to a bound orbit, so an impact cannot drive a planet past unity on paper. This needs the impact record to report the eccentricity on both sides of the collision, so the timeline gains an e_before column and the dynamical model reports it. The analytical module carries its own eccentricity through the chain rather than computing each merger against a circular target and discarding the result, which is the same inconsistency in a different place. An accretion run needs a giant-impact model new enough to report the column; the version floor is bumped once that release is out. --- src/proteus/accretion/common.py | 26 ++++++++-- src/proteus/accretion/dummy.py | 1 + src/proteus/accretion/wrapper.py | 20 +++++++- tests/accretion/test_common.py | 4 ++ tests/accretion/test_morrigan.py | 3 ++ tests/accretion/test_timeline.py | 2 + tests/accretion/test_wrapper.py | 59 ++++++++++++++++------- tests/interior_energetics/test_wrapper.py | 2 + 8 files changed, 94 insertions(+), 23 deletions(-) diff --git a/src/proteus/accretion/common.py b/src/proteus/accretion/common.py index 9a85e3a43..565423b9e 100644 --- a/src/proteus/accretion/common.py +++ b/src/proteus/accretion/common.py @@ -31,6 +31,7 @@ 'rho_impactor', 'a_before', 'a_after', + 'e_before', 'e_after', 'id_target', 'id_impactor', @@ -91,6 +92,8 @@ class ImpactEvent: Semi-major axis of the target before the impact [m]. a_after: float Semi-major axis of the merged body [m]. + e_before: float + Eccentricity of the target immediately before the impact [1]. e_after: float Eccentricity of the merged body [1]. id_target: int @@ -112,6 +115,7 @@ class ImpactEvent: rho_impactor: float = field() a_before: float = field() a_after: float = field() + e_before: float = field() e_after: float = field() id_target: int = field(default=-1) id_impactor: int = field(default=-1) @@ -132,6 +136,19 @@ def semimajoraxis_ratio(self) -> float: """ return self.a_after / self.a_before + @property + def eccentricity_change(self) -> float: + """Change in eccentricity this impact makes [1]. + + Applied as a change for the same reason the semi-major axis is applied + as a ratio: the configuration owns the planet's orbit, and the followed + body's absolute eccentricity belongs to its orbit rather than to the + planet being simulated. A change is used instead of a ratio because the + eccentricity is dimensionless and routinely zero, which a ratio cannot + express. + """ + return self.e_after - self.e_before + def _check_event_physics(event: ImpactEvent, index: int) -> None: """Raise if an impact record is not physically self-consistent. @@ -192,10 +209,10 @@ def _check_event_physics(event: ImpactEvent, index: int) -> None: f'{where}: impact parameter must be in [0, 1], got {event.impact_parameter!r}' ) - if not 0.0 <= event.e_after < 1.0: - raise ValueError( - f'{where}: post-impact eccentricity must be in [0, 1), got {event.e_after!r}' - ) + for name in ('e_before', 'e_after'): + value = getattr(event, name) + if not 0.0 <= value < 1.0: + raise ValueError(f'{where}: eccentricity {name} must be in [0, 1), got {value!r}') def validate_timeline( @@ -334,6 +351,7 @@ def read_timeline(path: str, time_offset: float = 0.0) -> list[ImpactEvent]: rho_impactor=float(row['rho_impactor']), a_before=float(row['a_before']), a_after=float(row['a_after']), + e_before=float(row['e_before']), e_after=float(row['e_after']), id_target=int(row['id_target']), id_impactor=int(row['id_impactor']), diff --git a/src/proteus/accretion/dummy.py b/src/proteus/accretion/dummy.py index 16a90c91d..bd8641187 100644 --- a/src/proteus/accretion/dummy.py +++ b/src/proteus/accretion/dummy.py @@ -295,6 +295,7 @@ def get_timeline(config: Config) -> list[ImpactEvent]: rho_impactor=m_impactor / (4.0 / 3.0 * math.pi * r_impactor**3), a_before=a_target, a_after=a_after, + e_before=e_target, e_after=e_after, id_target=0, id_impactor=index + 1, diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index ffbb27d68..cdd633949 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -34,6 +34,11 @@ # the module for a timeline again. _RESOLVED_TIMELINE_FILE = 'impact_timeline.csv' +# Ceiling on the planet's eccentricity after an impact applies its change. An +# impact excites a bound orbit; it cannot unbind one, and the rest of the model +# assumes a closed orbit throughout. +_ECC_MAX = 0.99 + def init_accretion(handler: Proteus) -> list[ImpactEvent]: """Prepare the impact timeline for a run. @@ -278,11 +283,22 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: # Move the orbit by the impact's proportional change in semi-major axis and # its post-impact eccentricity, writing both the configuration and the row. + # Both elements are applied as the change this impact made, not as the + # followed body's absolute values, because the configuration owns the + # planet's orbit: a borrowed impact history moves it, it does not replace + # it. The semi-major axis takes the ratio and the eccentricity the + # difference, since eccentricity is dimensionless and routinely zero, which + # a ratio cannot express. The result is clamped to a bound orbit, so an + # impact that excites a planet already near unity cannot unbind it on paper. ratio = event.semimajoraxis_ratio + eccentricity = min( + max(config.orbit.eccentricity + event.eccentricity_change, 0.0), _ECC_MAX + ) + config.orbit.semimajoraxis *= ratio - config.orbit.eccentricity = event.e_after + config.orbit.eccentricity = eccentricity hf_row['semimajorax'] *= ratio - hf_row['eccentricity'] = event.e_after + hf_row['eccentricity'] = eccentricity log.info( ' planet is now %.4f M_earth at %.5f AU, e = %.4f', diff --git a/tests/accretion/test_common.py b/tests/accretion/test_common.py index 8965f1b55..1c75ea455 100644 --- a/tests/accretion/test_common.py +++ b/tests/accretion/test_common.py @@ -55,6 +55,7 @@ def _event(**overrides) -> ImpactEvent: rho_impactor=3930.0, a_before=1.496e11, a_after=1.400e11, + e_before=0.02, e_after=0.05, id_target=1, id_impactor=4, @@ -318,6 +319,7 @@ def test_read_timeline_parses_both_delimiters_and_applies_the_offset(tmp_path): 3930.0, 1.4e11, 1.35e11, + 0.03, 0.02, 1, 7, @@ -341,6 +343,7 @@ def test_read_timeline_parses_both_delimiters_and_applies_the_offset(tmp_path): 3930.0, 1.496e11, 1.4e11, + 0.02, 0.05, 1, 4, @@ -398,6 +401,7 @@ def test_read_timeline_rejects_unusable_files(tmp_path): 3930.0, 1.496e11, 1.4e11, + 0.02, 0.05, 1, 4, diff --git a/tests/accretion/test_morrigan.py b/tests/accretion/test_morrigan.py index 7c2ef0b05..a8c55bf54 100644 --- a/tests/accretion/test_morrigan.py +++ b/tests/accretion/test_morrigan.py @@ -270,6 +270,7 @@ def test_generated_timeline_is_selected_ordered_and_validated(monkeypatch): 'rho_impactor': 3930.0, 'a_before': 1.4e11, 'a_after': 1.35e11, + 'e_before': 0.03, 'e_after': 0.02, 'id_target': 1, 'id_impactor': 7, @@ -288,6 +289,7 @@ def test_generated_timeline_is_selected_ordered_and_validated(monkeypatch): 'rho_impactor': 3930.0, 'a_before': 1.496e11, 'a_after': 1.4e11, + 'e_before': 0.02, 'e_after': 0.05, 'id_target': 1, 'id_impactor': 4, @@ -342,6 +344,7 @@ def _one_impact_record(): 'rho_impactor': 3930.0, 'a_before': 1.496e11, 'a_after': 1.4e11, + 'e_before': 0.02, 'e_after': 0.05, 'id_target': 1, 'id_impactor': 4, diff --git a/tests/accretion/test_timeline.py b/tests/accretion/test_timeline.py index 14b118446..5aaeb08db 100644 --- a/tests/accretion/test_timeline.py +++ b/tests/accretion/test_timeline.py @@ -36,6 +36,7 @@ 3930.0, 1.496e11, 1.4e11, + 0.02, 0.05, 1, 4, @@ -54,6 +55,7 @@ 3930.0, 1.4e11, 1.35e11, + 0.03, 0.02, 1, 7, diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 54fa1749a..5dc8a7e87 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -38,6 +38,7 @@ 3930.0, 1.496e11, 1.4e11, + 0.02, 0.05, 1, 4, @@ -56,6 +57,7 @@ 3930.0, 1.4e11, 1.35e11, + 0.03, 0.02, 1, 7, @@ -211,6 +213,7 @@ def _impact_event(**overrides): rho_impactor=3930.0, a_before=1.496e11, a_after=1.4e11, + e_before=0.02, e_after=0.05, id_target=1, id_impactor=4, @@ -356,11 +359,15 @@ def test_impact_on_a_crystallised_planet_reopens_outgassing(monkeypatch): def test_impact_moves_the_orbit_in_both_the_config_and_the_row(monkeypatch): """The orbit change is applied as a jump to both the config and the row. - The semi-major axis moves by the impact's proportional change and the - eccentricity takes its post-impact value. Both the configuration, which - pins the orbit when tides are off, and the running row, which the tidal - evolution carries forward when tides are on, must be written, or the - jump would be lost under one of the two orbit modes. + Both elements move by the change the impact made rather than taking the + followed body's absolute values, because the configuration owns the + planet's orbit: a borrowed impact history moves it, it does not replace it. + The semi-major axis takes the ratio and the eccentricity the difference, + since eccentricity is dimensionless and routinely zero, which a ratio + cannot express. Both the configuration, which pins the orbit when tides are + off, and the running row, which the tidal evolution carries forward when + tides are on, must be written, or the jump would be lost under one of the + two orbit modes. """ from proteus.accretion.wrapper import apply_impact from proteus.utils.constants import AU @@ -371,7 +378,8 @@ def test_impact_moves_the_orbit_in_both_the_config_and_the_row(monkeypatch): handler = _impact_handler(semimajoraxis=0.5, eccentricity=0.1) # a_after / a_before = 1.4e11 / 1.4e11 scaled: choose a clean 1.2 ratio. - event = _impact_event(a_before=1.0e11, a_after=1.2e11, e_after=0.03) + # The followed body goes 0.02 -> 0.03, so the impact excites it by +0.01. + event = _impact_event(a_before=1.0e11, a_after=1.2e11, e_before=0.02, e_after=0.03) ratio = 1.2 apply_impact(handler, event) @@ -382,19 +390,25 @@ def test_impact_moves_the_orbit_in_both_the_config_and_the_row(monkeypatch): assert handler.hf_row['semimajorax'] / AU == pytest.approx( handler.config.orbit.semimajoraxis, rel=1e-12 ) - # Eccentricity takes the post-impact value in both places. - assert handler.config.orbit.eccentricity == pytest.approx(0.03, rel=1e-12) - assert handler.hf_row['eccentricity'] == pytest.approx(0.03, rel=1e-12) + # The planet's own 0.1 is excited by the impact's +0.01, not replaced by + # the followed body's 0.03. + assert handler.config.orbit.eccentricity == pytest.approx(0.11, rel=1e-12) + assert handler.hf_row['eccentricity'] == pytest.approx(0.11, rel=1e-12) + # Discrimination: transplanting the absolute value would give 0.03, which + # is nearly four times away from the correct 0.11. + assert abs(0.03 - 0.11) > 0.5 * 0.11 @pytest.mark.unit @pytest.mark.physics_invariant def test_a_grazing_head_on_impact_leaves_the_orbit_circular(monkeypatch): - """A zero post-impact eccentricity is a valid boundary and is applied. + """A circularising impact damps the planet's own eccentricity, and stops at zero. - The eccentricity is written directly, so the circular limit must come - through as exactly zero rather than being clamped away, and the - semi-major axis still moves by its ratio independently of it. + An impact that circularises the followed body applies a negative change, + which must reduce the planet's eccentricity rather than replace it. The + result is clamped at zero, since a negative eccentricity has no meaning and + would propagate into the separation and Hill-radius formulae as a sign + error. The semi-major axis still moves by its ratio independently of it. """ from proteus.accretion.wrapper import apply_impact @@ -402,16 +416,27 @@ def test_a_grazing_head_on_impact_leaves_the_orbit_circular(monkeypatch): 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None ) + # The followed body is circularised from 0.05 to 0, a change of -0.05, + # which damps a planet at 0.2 to 0.15 rather than resetting it. handler = _impact_handler(semimajoraxis=1.0, eccentricity=0.2) - event = _impact_event(a_before=1.0e11, a_after=1.0e11, e_after=0.0) + event = _impact_event(a_before=1.0e11, a_after=1.0e11, e_before=0.05, e_after=0.0) apply_impact(handler, event) - assert handler.config.orbit.eccentricity == 0.0 - assert handler.hf_row['eccentricity'] == 0.0 + assert handler.config.orbit.eccentricity == pytest.approx(0.15, rel=1e-12) + assert handler.hf_row['eccentricity'] == pytest.approx(0.15, rel=1e-12) # Equal before/after semi-major axis is a unit ratio, so the orbit size - # is unchanged while the eccentricity is reset. + # is unchanged while the eccentricity is damped. assert handler.config.orbit.semimajoraxis == pytest.approx(1.0, rel=1e-12) + # A change larger than the planet's own eccentricity clamps at zero rather + # than going negative, which is the boundary the clamp exists for. + floored = _impact_handler(semimajoraxis=1.0, eccentricity=0.01) + apply_impact( + floored, _impact_event(a_before=1.0e11, a_after=1.0e11, e_before=0.05, e_after=0.0) + ) + assert floored.config.orbit.eccentricity == 0.0 + assert floored.hf_row['eccentricity'] == 0.0 + @pytest.mark.unit @pytest.mark.physics_invariant diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index 42f4eaa6c..2961e1511 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -6089,6 +6089,7 @@ def test_the_remelt_injection_is_weighed_against_the_impact_energy(caplog): rho_impactor=3930.0, a_before=1.496e11, a_after=1.4e11, + e_before=0.02, e_after=0.05, ) reduced = tiny.M_target_before * tiny.M_impactor / (tiny.M_target_before + tiny.M_impactor) @@ -6134,6 +6135,7 @@ def test_the_remelt_injection_is_weighed_against_the_impact_energy(caplog): rho_impactor=5510.0, a_before=1.496e11, a_after=1.4e11, + e_before=0.02, e_after=0.05, ) hf_row2 = {} From 399da7855724acd7f6770d2ff0b0b4e8d8ac0b7d Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 26 Jul 2026 13:25:07 +0200 Subject: [PATCH 24/71] Scope the helpfile backfill and cover the fixes that had no tests The backfill added for resuming across a schema change filled in every missing column with zero, justified for the cumulative ledgers it was written for but applied to everything. Zero is a true statement about a ledger a file never recorded, and a specific wrong one about instantaneous state: a zero-filled surface temperature or planet mass reads as real to everything downstream and quietly poisons a resumed run, where the loud failure it replaced at least stopped. The fill is now scoped to a declared set of cumulative columns and anything outside it fails as before, naming what cannot be reconstructed. A run already in flight when the accretion ledger appeared gets that column filled with zero, so the restore finds nothing and continues from the configured mass, which is the behaviour the ledger exists to prevent. It cannot be distinguished from a run that has genuinely not accreted yet, so both the reader and the restore now say what happened rather than leaving it to be inferred from a planet that quietly shrank. The eccentricity clamp reports when it saturates. An impact asking for an orbit the model cannot represent is worth a line, and absorbing it silently is how a compounding drift in the applied change would hide for a whole run. The analytical module refused an impact carrying too small a share of the accreted budget, which is a statement about how the budget was divided rather than about the collision. A large budget spread over many impacts onto a heavy planet still schedules collisions far too small to melt a mantle, so an impactor is now also required to be a meaningful fraction of the body it strikes. Eight fixes across these commits had no test that failed against the code before them: the per-step impact-heat reset, the impactor-heavier-than-target guard, the density floor on small impactors, the eccentricity carried along the chain, the impact-mass floor at its discriminating boundary, the eccentricity clamp, the pre-impact eccentricity bound in the timeline validator, and the refusal of a timeline path aimed at the analytical module. Each now has one, and each was checked by removing the fix and confirming the test fails. The validation page cited three tests as reference-pinned that carried no such marker. They pin against analytical limits and have earned it, so they now carry it. --- src/proteus/accretion/dummy.py | 21 +++++ src/proteus/accretion/wrapper.py | 30 ++++++- src/proteus/utils/coupler.py | 63 ++++++++++---- tests/accretion/test_common.py | 9 ++ tests/accretion/test_dummy.py | 140 +++++++++++++++++++++++++++++++ tests/accretion/test_wrapper.py | 47 +++++++++++ tests/config/test_accretion.py | 25 ++++++ tests/test_proteus.py | 42 ++++++++++ tests/utils/test_coupler.py | 46 ++++++++++ 9 files changed, 406 insertions(+), 17 deletions(-) diff --git a/src/proteus/accretion/dummy.py b/src/proteus/accretion/dummy.py index bd8641187..6ea83e557 100644 --- a/src/proteus/accretion/dummy.py +++ b/src/proteus/accretion/dummy.py @@ -27,6 +27,12 @@ # them names the configuration error rather than letting the run apply it. _MIN_IMPACT_MASS_FRAC = 1.0e-4 +# Smallest impactor worth applying, as a fraction of the target it strikes. Every +# impact re-melts the whole mantle, strips atmosphere and moves the orbit, so a +# body far below this cannot be one: the Moon-forming impactor is of order a +# tenth of Earth, and a thousandth is already three orders below that. +_MIN_IMPACTOR_TARGET_RATIO = 1.0e-3 + def _body_radius(config: Config, mass: float) -> float: """Radius of a rocky body of the given mass [m]. @@ -266,6 +272,21 @@ def get_timeline(config: Config) -> list[ImpactEvent]: 'raise planet.mass_tot.' ) + # Whether an impact is a giant impact is a statement about the two bodies, + # not about how the delivered mass happens to be divided up. The share of + # the budget is bounded elsewhere, but a large budget spread over many + # impacts onto a heavy planet can still schedule collisions far too small + # to melt a mantle or reset an orbit, which is what each one goes on to do. + if m_impactor < _MIN_IMPACTOR_TARGET_RATIO * m_target: + raise ValueError( + f'Impact {index} carries {m_impactor / m_target:.3e} of its target ' + f'mass ({m_impactor / M_earth:.4e} onto {m_target / M_earth:.4f} ' + f'M_earth), below the {_MIN_IMPACTOR_TARGET_RATIO:.0e} floor. An ' + 'impact that small is not a giant impact, yet it would still re-melt ' + 'the whole mantle, strip the atmosphere and move the orbit. Raise ' + 'accretion.dummy.mass_accreted or ask for fewer impacts.' + ) + r_target = _body_radius(config, m_target) r_impactor = _body_radius(config, m_impactor) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index cdd633949..fea255596 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -163,6 +163,19 @@ def restore_accretion_state(handler: Proteus) -> None: accreted = float(hf_row.get('M_accreted_rock') or 0.0) if accreted <= 0.0: + # Say so rather than returning in silence. A ledger of zero means either + # that no impact has landed yet, which is ordinary, or that the helpfile + # predates the ledger and the reader filled it in, in which case the + # growth of every impact before this restart is not recoverable and the + # run continues from the configured mass. The reader warns when it fills + # the column; this line is what connects that warning to its consequence. + if config.accretion.module is not None: + log.info( + 'No accreted rock recorded before this resume: continuing from the ' + 'configured mass of %.4f M_earth. If this run had already applied an ' + 'impact, its helpfile predates the ledger and that growth is lost.', + config.planet.mass_tot, + ) return config.planet.mass_tot += accreted / M_earth @@ -291,9 +304,20 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: # a ratio cannot express. The result is clamped to a bound orbit, so an # impact that excites a planet already near unity cannot unbind it on paper. ratio = event.semimajoraxis_ratio - eccentricity = min( - max(config.orbit.eccentricity + event.eccentricity_change, 0.0), _ECC_MAX - ) + requested = config.orbit.eccentricity + event.eccentricity_change + eccentricity = min(max(requested, 0.0), _ECC_MAX) + + # A saturated clamp means the impact asked for an orbit the rest of the model + # cannot represent, so report it rather than absorbing it. Clamping in silence + # is how a compounding drift in the applied change hides for a whole run. + if abs(requested - eccentricity) > 1e-12: + log.warning( + ' impact asked for eccentricity %.4f, clamped to %.4f: the change it ' + 'applies (%+.4f) takes the orbit outside the representable range', + requested, + eccentricity, + event.eccentricity_change, + ) config.orbit.semimajoraxis *= ratio config.orbit.eccentricity = eccentricity diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index 670c71fd9..32718f939 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -596,6 +596,20 @@ def CreateLockFile(output_dir: str): return keepalive_file +# Schema columns a resumed run may read as zero when its helpfile predates them. +# Each accumulates over a run, so zero is a true statement about a file that +# never recorded it: nothing was written, therefore nothing accrued. Every other +# column holds instantaneous state, where zero is a specific wrong value rather +# than a missing one, so a helpfile short of those cannot be resumed at all. +# Add a column here only when zero is the correct reading of its absence. +RESUMABLE_ZERO_FILL_KEYS = frozenset( + { + 'esc_kg_cumulative', + 'M_accreted_rock', + } +) + + def GetHelpfileKeys(): """ Variables to be held in the helpfile. @@ -1167,12 +1181,18 @@ def ReadHelpfileFromCSV(output_dir: str): """ Read helpfile from disk CSV file to DataFrame - Columns the current schema defines but the file does not carry are added - as zero. A run started under an earlier schema writes a helpfile without - them, and a resume feeds its last row straight back into ``ExtendHelpfile``, - which rejects a row missing any schema key. Backfilling here keeps a run in - flight when a new column is added, and zero is the right value for the - cumulative ledgers this affects: nothing was recorded, so nothing accrued. + A run started under an earlier schema writes a helpfile without the columns + added since, and a resume feeds its last row straight back into + ``ExtendHelpfile``, which rejects a row missing any schema key. Such a run + would die on its first restart, whether or not it uses the feature the new + column belongs to. + + Only the columns in ``RESUMABLE_ZERO_FILL_KEYS`` are filled in, because zero + is a true statement about them: they accumulate over a run, so a file that + never recorded one accrued nothing. Every other column carries instantaneous + physical state, where zero is not "unknown" but a specific and wrong value: a + zero-filled temperature or radius would be read as real and quietly poison a + resumed run. Those still fail, loudly, the way they did before. """ fpath = os.path.join(output_dir, 'runtime_helpfile.csv') if not os.path.exists(fpath): @@ -1180,15 +1200,30 @@ def ReadHelpfileFromCSV(output_dir: str): df = pd.read_csv(fpath, sep=r'\s+') missing = [key for key in GetHelpfileKeys() if key not in df.columns] - if missing: - log.warning( - 'Helpfile predates %d column(s) in the current schema; backfilling with zero: %s', - len(missing), - ', '.join(sorted(missing)), + if not missing: + return df + + fillable = [key for key in missing if key in RESUMABLE_ZERO_FILL_KEYS] + unfillable = sorted(set(missing) - set(fillable)) + + if unfillable: + raise Exception( + f'Helpfile at {fpath} is missing {len(unfillable)} column(s) that carry ' + f'physical state, which cannot be reconstructed: {unfillable}. This run ' + 'was started under a schema that predates them and cannot be resumed ' + 'under the current one; start it again from the beginning.' ) - # Added in one concat rather than one insert per column, which would - # fragment the frame and warn on a schema several columns behind. - df = pd.concat([df, pd.DataFrame(0.0, index=df.index, columns=missing)], axis=1) + + log.warning( + 'Helpfile predates %d cumulative column(s) in the current schema, and they ' + 'are read as zero for the rest of this run: %s. Any amount they recorded ' + 'before this restart is not in the file and is therefore lost.', + len(fillable), + ', '.join(sorted(fillable)), + ) + # Added in one concat rather than one insert per column, which would + # fragment the frame and warn on a schema several columns behind. + df = pd.concat([df, pd.DataFrame(0.0, index=df.index, columns=fillable)], axis=1) return df diff --git a/tests/accretion/test_common.py b/tests/accretion/test_common.py index 1c75ea455..c8a24c4a2 100644 --- a/tests/accretion/test_common.py +++ b/tests/accretion/test_common.py @@ -166,6 +166,15 @@ def test_impact_geometry_and_eccentricity_stay_in_range(): with pytest.raises(ValueError, match='eccentricity'): validate_timeline([_event(e_after=bad_e)]) + # The pre-impact eccentricity carries the same bound and is checked by name, + # since the applied orbit change is the difference of the two and an unbound + # value on either side makes that difference meaningless. + validate_timeline([_event(e_before=0.0)]) + validate_timeline([_event(e_before=0.999)]) + for bad_e in (1.0, 1.5, -0.01): + with pytest.raises(ValueError, match='e_before'): + validate_timeline([_event(e_before=bad_e)]) + @pytest.mark.unit @pytest.mark.physics_invariant diff --git a/tests/accretion/test_dummy.py b/tests/accretion/test_dummy.py index e9aacefee..dc2c6acf3 100644 --- a/tests/accretion/test_dummy.py +++ b/tests/accretion/test_dummy.py @@ -126,6 +126,7 @@ def test_impactor_masses_decay_and_the_first_impact_is_the_largest(): @pytest.mark.unit @pytest.mark.physics_invariant +@pytest.mark.reference_pinned def test_collision_velocity_never_falls_below_the_mutual_escape_velocity(): """v_impact = sqrt(v_encounter^2 + v_esc^2) holds, including at e = 0. @@ -165,6 +166,7 @@ def test_collision_velocity_never_falls_below_the_mutual_escape_velocity(): @pytest.mark.unit @pytest.mark.physics_invariant +@pytest.mark.reference_pinned def test_a_circular_encounter_leaves_the_orbit_untouched(): """With no encounter eccentricity the merger cannot move the orbit. @@ -275,6 +277,7 @@ def test_an_unusable_timescale_is_refused_with_an_actionable_message(): @pytest.mark.unit @pytest.mark.physics_invariant +@pytest.mark.reference_pinned def test_merged_orbit_conserves_angular_momentum_of_the_merged_body(): """The returned orbit reproduces the angular momentum it was built from. @@ -309,3 +312,140 @@ def test_merged_orbit_conserves_angular_momentum_of_the_merged_body(): # a percent of it, and it must not be zero. assert v_encounter == pytest.approx(eccentricity * v_kep, rel=2e-2) assert v_encounter > 0.0 + + +@pytest.mark.unit +def test_an_impactor_heavier_than_its_target_is_refused(): + """The target must survive the collision, so it cannot be the lighter body. + + Everything downstream treats the target as the survivor: its mantle re-melts, + its atmosphere is stripped, its orbit moves. A timeline whose impactor + outweighs the target describes the opposite collision, and the whole chain + would silently model the wrong body. Asking for more mass than the planet has + is the ordinary way to reach that, so it fails at generation. + """ + with pytest.raises(ValueError, match='lighter than the impactor'): + get_timeline(_config(mass_accreted=5.0, mass_tot=1.0, num_impacts=1)) + + # Just under the planet's own mass is still a legal, if violent, merger, so + # the guard discriminates rather than refusing every large impact. + events = get_timeline(_config(mass_accreted=0.9, mass_tot=1.0, num_impacts=1)) + assert events[0].M_impactor < events[0].M_target_before + + +@pytest.mark.unit +def test_an_impactor_far_too_small_for_a_giant_impact_is_refused(): + """An impact must be large relative to the body it strikes, not just to the budget. + + Each impact re-melts the whole mantle, strips atmosphere and resets the orbit. + A collision carrying a millionth of the target's mass cannot do any of that, + and scheduling one silently applies a giant impact's consequences to a pebble + strike. The share of the accreted budget is bounded separately: this case has + a perfectly reasonable share of a tiny budget, so only a ratio against the + target catches it. + """ + with pytest.raises(ValueError, match='not a giant impact'): + get_timeline(_config(mass_accreted=1.0e-6, mass_tot=1.0, num_impacts=2)) + + # A budget large enough that each impact is a real collision passes, so the + # floor does not simply reject small timelines. + events = get_timeline(_config(mass_accreted=0.1, mass_tot=1.0, num_impacts=2)) + assert all(e.M_impactor / e.M_target_before > 1.0e-3 for e in events) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_a_small_impactor_is_not_less_dense_than_its_own_minerals(): + """The mass-radius scaling is capped at the uncompressed density. + + The scaling is fitted for planets and its radius grows as M**0.282, so the + density it implies falls without bound as the mass does; extrapolated to a + small impactor it returns a body less dense than the rock and iron it is made + of. That radius feeds the mutual escape velocity and the erosion law, so the + error does not stay local. Below roughly a tenth of an Earth mass the body is + treated as uncompressed instead, which is the correct limit for a small body. + """ + from proteus.accretion.dummy import _RHO_IRON, _RHO_SILICATE, _body_radius + from proteus.utils.structure_estimate import iron_fractions + + config = _config() + _, x_fe, _ = iron_fractions(0.55, 'radius', mass_tot_M_earth=1.0e-3) + floor = 1.0 / (x_fe / _RHO_IRON + (1.0 - x_fe) / _RHO_SILICATE) + + tiny = 1.0e-3 * M_earth + radius = _body_radius(config, tiny) + density = tiny / (4.0 / 3.0 * math.pi * radius**3) + + # At this mass the cap governs, so the density sits at the floor exactly. + assert density == pytest.approx(floor, rel=1e-9) + assert density > 4000.0 + + # Discrimination: the uncapped scaling would give a body under 1000 kg m-3, + # less dense than water and impossible for rock and iron. + from proteus.utils.structure_estimate import nl20_planet_radius_km + + uncapped_r = nl20_planet_radius_km(x_fe, 1.0e-3) * 1.0e3 + uncapped_rho = tiny / (4.0 / 3.0 * math.pi * uncapped_r**3) + assert uncapped_rho < 0.5 * floor + + # An Earth-mass body is inside the scaling's range, so the cap is inactive + # there and the scaling still governs. The iron fraction is mass-dependent + # under the radius-mode core fraction, so it is evaluated at this body's own + # mass rather than reused from the small one above. + _, x_fe_earth, _ = iron_fractions(0.55, 'radius', mass_tot_M_earth=1.0) + earth_r = _body_radius(config, M_earth) + assert earth_r == pytest.approx(nl20_planet_radius_km(x_fe_earth, 1.0) * 1.0e3, rel=1e-9) + + # And the cap is genuinely inactive there: the uncompressed radius is the + # larger of the two, so the scaling is what min() selects. + floor_earth = 1.0 / (x_fe_earth / _RHO_IRON + (1.0 - x_fe_earth) / _RHO_SILICATE) + r_uncompressed_earth = (3.0 * M_earth / (4.0 * math.pi * floor_earth)) ** (1.0 / 3.0) + assert r_uncompressed_earth > earth_r + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_the_chain_carries_its_eccentricity_from_one_impact_to_the_next(): + """Each merger sees the orbit the previous one produced, not the original. + + The module computes a merged eccentricity and must hand it to the next + collision as the target's state. Dropping that assignment leaves every + impact computed against the configured orbit, so the chain describes a + planet that is re-circularised between collisions, and the eccentricity it + reports is the one it just discarded. + """ + events = get_timeline( + _config(num_impacts=3, eccentricity=0.3, orbit_eccentricity=0.2, mass_accreted=0.6) + ) + + # Each impact's stated pre-impact eccentricity is the previous impact's + # result, which is exactly what carrying the value forward means. + assert events[0].e_before == pytest.approx(0.2, rel=1e-12) + for previous, current in zip(events, events[1:]): + assert current.e_before == pytest.approx(previous.e_after, rel=1e-12) + + # Discrimination: without the carry every impact would start from the + # configured 0.2, and the first merger moves it well away from that. + assert abs(events[0].e_after - 0.2) > 1e-3 + + +@pytest.mark.unit +def test_a_timescale_that_starves_a_late_impact_is_refused(): + """A weight that is small but non-zero is still an unusable impact. + + The growth law's increments decay geometrically, so a timescale far shorter + than the impact spacing drives the later ones toward zero without ever + reaching it. Testing for exact underflow therefore misses the whole regime + the guard exists for: an impact carrying a millionth of the budget is not a + giant impact, but every arithmetic in the module is perfectly happy with it. + """ + with pytest.raises(ValueError, match='below the'): + get_timeline(_config(num_impacts=4, timescale=1.0e5, time_last=5.0e6)) + + # The smallest weight here is far above zero, so an exact-underflow test + # would let this configuration through. + weights = [ + math.exp(-k * 1.25e6 / 1.0e5) - math.exp(-(k + 1) * 1.25e6 / 1.0e5) for k in range(4) + ] + assert min(weights) > 0.0 + assert min(weights) / sum(weights) < 1.0e-4 diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 5dc8a7e87..491b1e77b 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -1718,3 +1718,50 @@ def test_a_resumed_run_does_not_advise_changing_the_time_offset(tmp_path, caplog assert 'already carrying' in caplog.text # The surviving schedule is the same either way; only the report differs. assert [e.time for e in events] == [5.0e5] + + +@pytest.mark.unit +def test_the_impact_eccentricity_is_clamped_to_a_bound_orbit(monkeypatch, caplog): + """An impact cannot drive the planet onto an open orbit, and says when it tries. + + The applied change is a difference, so a large positive one on an already + eccentric planet can ask for an eccentricity at or above unity, which the + rest of the model cannot represent: the separation, periapsis and Hill radius + all assume a closed orbit. The result is clamped, and the clamp reports + itself, because absorbing it in silence is how a compounding drift in the + applied change would hide for a whole run. + """ + import logging + + from proteus.accretion.wrapper import _ECC_MAX, apply_impact + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + handler = _impact_handler(semimajoraxis=1.0, eccentricity=0.9) + # The followed body is excited from 0.01 to 0.8, a change of +0.79, which + # would take a planet at 0.9 to 1.69. + event = _impact_event(a_before=1.0e11, a_after=1.0e11, e_before=0.01, e_after=0.8) + + with caplog.at_level(logging.WARNING, logger='fwl.proteus.accretion.wrapper'): + apply_impact(handler, event) + + assert handler.config.orbit.eccentricity == pytest.approx(_ECC_MAX, rel=1e-12) + assert handler.hf_row['eccentricity'] == pytest.approx(_ECC_MAX, rel=1e-12) + assert 0.0 <= handler.config.orbit.eccentricity < 1.0 + assert 'clamped' in caplog.text + + # Discrimination: unclamped the orbit would be reported at 1.69, which is not + # an orbit at all, and every quantity derived from it would be nonsense. + assert 0.9 + 0.79 > 1.0 + + # A change that stays inside the range passes through untouched and silent. + caplog.clear() + quiet = _impact_handler(semimajoraxis=1.0, eccentricity=0.1) + with caplog.at_level(logging.WARNING, logger='fwl.proteus.accretion.wrapper'): + apply_impact( + quiet, _impact_event(a_before=1.0e11, a_after=1.0e11, e_before=0.01, e_after=0.05) + ) + assert quiet.config.orbit.eccentricity == pytest.approx(0.14, rel=1e-12) + assert 'clamped' not in caplog.text diff --git a/tests/config/test_accretion.py b/tests/config/test_accretion.py index 6699a8b2d..fb7774e23 100644 --- a/tests/config/test_accretion.py +++ b/tests/config/test_accretion.py @@ -397,3 +397,28 @@ def pole(mass_earth, stellar_mass_sun): assert pole(10.0, 0.1) < 50.0 # The scaling itself: an eighth of the stellar mass halves the pole. assert pole(10.0, 0.125) == pytest.approx(0.5 * pole(10.0, 1.0), rel=1e-9) + + +@pytest.mark.unit +def test_a_timeline_path_aimed_at_the_analytical_module_is_refused(): + """A path under the analytical module names a file it will never read. + + The file-driven module was renamed, so a configuration written before that + names the analytical module and hands it a timeline path. Loading it would + silently run a generated timeline at default settings instead of replaying + the user's file: the run would succeed and model a different history than + the one asked for, which is worse than failing. It fails instead, naming + where the path belongs now. + """ + from proteus.config._accretion import Accretion, AccretionDummy + + with pytest.raises(ValueError, match='accretion.timeline.timeline_path'): + Accretion(module='dummy', dummy=AccretionDummy(timeline_path='impacts.csv')) + + # It is refused whatever the module is set to, since the path is meaningless + # under this block in every case. + with pytest.raises(ValueError, match='timeline_path'): + Accretion(module='none', dummy=AccretionDummy(timeline_path='impacts.csv')) + + # The analytical module without a path is the ordinary case and loads. + assert Accretion(module='dummy').dummy.timeline_path is None diff --git a/tests/test_proteus.py b/tests/test_proteus.py index a26ed228f..9ba7df7ab 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -884,3 +884,45 @@ def test_structure_baseline_skipped_for_superliquidus_adiabat(tmp_path): mock_update.assert_not_called() # forced re-solve skipped, IC adiabat stands assert p._baseline_structure_done is True # latched so it is not re-checked + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_the_per_step_impact_heat_starts_each_row_at_zero(): + """The impact-heat column is cleared when a row is created, on every path. + + The column accumulates within a timestep, because several impacts can land + in one, and the coupler adds it to both sides of the cumulative energy + budget. A row that inherited the previous row's value would therefore book + an earlier impact's heat again on every subsequent step, inflating both + cumulatives without ever disturbing the residual, which is the one quantity + that would otherwise reveal it. + + Clearing it where the row is created, rather than in an interior solver's + success branch, is what makes this hold for every interior module and for + the retry paths that return before that branch is reached. + """ + import inspect + + from proteus.proteus import Proteus + + source = inspect.getsource(Proteus.start) + + # The row is created by copying the previous one; the clear must follow that + # copy, or it would be overwritten by the very value it exists to drop. + copy_at = source.index('self.hf_row = self.hf_all.iloc[-1].to_dict()') + clear_at = source.index("self.hf_row['step_dE_impact_J'] = 0.0") + assert clear_at > copy_at + + # Behavioural check on the same two operations, which is what a row carrying + # a booked value through to the next step would break. + previous = {'step_dE_impact_J': 6.1e30, 'T_surf': 1500.0} + row = dict(previous) + row['step_dE_impact_J'] = 0.0 + + assert row['step_dE_impact_J'] == 0.0 + # Everything else survives the copy: the clear is scoped to the one column. + assert row['T_surf'] == pytest.approx(previous['T_surf'], rel=1e-12) + # Discrimination: without the clear the row would carry 6.1e30 J into the + # next step's budget, the whole of a mantle re-melt. + assert previous['step_dE_impact_J'] > 1e30 diff --git a/tests/utils/test_coupler.py b/tests/utils/test_coupler.py index 4786a9fbe..8b2454baa 100644 --- a/tests/utils/test_coupler.py +++ b/tests/utils/test_coupler.py @@ -3090,3 +3090,49 @@ def test_a_helpfile_predating_a_schema_column_still_resumes(tmp_path): pd.DataFrame([original]).to_csv(tmp_path / 'runtime_helpfile.csv', sep='\t', index=False) reloaded = ReadHelpfileFromCSV(str(tmp_path)) assert reloaded['T_surf'].iloc[-1] == pytest.approx(1234.5, rel=1e-9) + + +@pytest.mark.unit +def test_a_helpfile_missing_physical_state_is_refused_not_zero_filled(): + """Only cumulative columns may be read as zero; state columns must fail. + + Zero is a true statement about a ledger a file never recorded: nothing was + written, so nothing accrued. It is a specific and wrong statement about + instantaneous state. A zero-filled surface temperature or planet mass would + be read as real by everything downstream and would quietly poison a resumed + run, which is worse than the loud failure this function gave before the + backfill existed. So the backfill is scoped to a declared set, and anything + outside it still stops the run. + """ + from proteus.utils.coupler import ( + RESUMABLE_ZERO_FILL_KEYS, + GetHelpfileKeys, + ReadHelpfileFromCSV, + ) + + # Every fillable key is in the schema, so the set cannot drift into naming + # columns that no longer exist. + assert RESUMABLE_ZERO_FILL_KEYS <= set(GetHelpfileKeys()) + + with tempfile.TemporaryDirectory() as tmpdir: + row = ZeroHelpfileRow() + row['T_surf'] = 1500.0 + del row['T_surf'] # a state column, not a ledger + pd.DataFrame([row]).to_csv( + os.path.join(tmpdir, 'runtime_helpfile.csv'), sep='\t', index=False + ) + + with pytest.raises(Exception, match='physical state'): + ReadHelpfileFromCSV(tmpdir) + + # The same function still fills a ledger column, so the guard discriminates + # between the two rather than refusing every schema change. + with tempfile.TemporaryDirectory() as tmpdir: + row = ZeroHelpfileRow() + del row['M_accreted_rock'] + pd.DataFrame([row]).to_csv( + os.path.join(tmpdir, 'runtime_helpfile.csv'), sep='\t', index=False + ) + + loaded = ReadHelpfileFromCSV(tmpdir) + assert loaded['M_accreted_rock'].iloc[-1] == pytest.approx(0.0, abs=1e-30) From 56525711e19d46371b8ae2eac3d5766a72accb9b Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 26 Jul 2026 16:26:40 +0200 Subject: [PATCH 25/71] Require the giant-impact release that reports the pre-impact eccentricity The coupling applies an impact's orbit change rather than the followed body's absolute orbit, which needs the eccentricity on both sides of the collision. That field arrived in 26.07.26, so the floor moves to it and the generated version table follows. The installer derives its checkout tag from the same floor, so an editable checkout lands on the matching release without a second edit. --- docs/Reference/module_versions.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Reference/module_versions.md b/docs/Reference/module_versions.md index b976c63a3..bc16a5929 100644 --- a/docs/Reference/module_versions.md +++ b/docs/Reference/module_versions.md @@ -48,7 +48,7 @@ pinned commit. | LovePy | Multi-phase tidal heating (Julia) | [![LovePy](https://img.shields.io/badge/LovePy-main-lightgrey)](https://github.com/nichollsh/LovePy){target="_blank" rel="noopener"} | [GitHub](https://github.com/nichollsh/LovePy) | | atmodeller | Alternative outgassing backend (GPL-3.0) | [![atmodeller](https://img.shields.io/badge/atmodeller-%3E%3D1.0.2-blue)](https://pypi.org/project/atmodeller/1.0.2/){target="_blank" rel="noopener"} | [GitHub](https://github.com/djbower/atmodeller) | | VULCAN | Atmospheric chemistry (GPL-3.0) | [![VULCAN](https://img.shields.io/badge/VULCAN-%3E%3D26.04.22-blue)](https://pypi.org/project/fwl-vulcan/26.04.22/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/VULCAN) | -| Morrigan | Protoplanet accretion via giant impacts | [![Morrigan](https://img.shields.io/badge/Morrigan-%3E%3D26.07.25-blue)](https://pypi.org/project/fwl-morrigan/26.07.25/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/Morrigan) | +| Morrigan | Protoplanet accretion via giant impacts | [![Morrigan](https://img.shields.io/badge/Morrigan-%3E%3D26.07.26-blue)](https://pypi.org/project/fwl-morrigan/26.07.26/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/Morrigan) | | Obliqua | Orbital evolution and tides (Julia) | n/a | [GitHub](https://github.com/FormingWorlds/Obliqua) | diff --git a/pyproject.toml b/pyproject.toml index acbe6a582..560bb9995 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,7 @@ vulcan = ["fwl-vulcan>=26.04.22"] # accretion.module = "morrigan". Supplies the giant-impact timeline the # accretion coupling replays. Also installable editable via # tools/get_morrigan.sh. -morrigan = ["fwl-morrigan>=26.07.25"] +morrigan = ["fwl-morrigan>=26.07.26"] develop = [ # coverage[toml] enables standalone coverage tool with TOML config support (used by ratcheting script) From 7ceec248251a44c25224c9bc8b493f1d10761ba7 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 26 Jul 2026 17:30:16 +0200 Subject: [PATCH 26/71] Show accretion in the overview figures and carry the domain palette The planet schematic and the architecture flowchart both name every module group the framework runs, and neither showed accretion. Both now carry it, with the module chip linking to the Morrigan documentation the way the other chips link to theirs. The colour is the ecosystem's accretion domain token rather than a new one. One hue per physical domain is held stable across the website, these figures and the module tables, so the mapping a reader learns in one place carries to the others; inventing a hue here would have broken that for the sake of a figure. The stylesheet gains the full domain palette alongside the brand colours it already had, plus the diverging phase ramp and the status colours, all matching the ecosystem tokens exactly so a future edit has one place to check against. Subscripts on the flux labels are upright. The quantity is the variable and stays italic; the subscript names which flux it is, so it is a label rather than a variable and should not be set in italics. Both the picture and the draw.io source inside each file are updated. They are two representations of the same figure and a change to one alone leaves the next person editing in draw.io silently dropping it. --- docs/assets/proteus_architecture.svg | 2 +- docs/assets/proteus_modules_schematic.svg | 2 +- .../proteus_modules_schematic_darkmode.svg | 2 +- docs/stylesheets/extra.css | 39 +++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/docs/assets/proteus_architecture.svg b/docs/assets/proteus_architecture.svg index db472ac4e..35c84fed0 100644 --- a/docs/assets/proteus_architecture.svg +++ b/docs/assets/proteus_architecture.svg @@ -1,4 +1,4 @@ -
Energetics
orbit (wrapper)
LovePy
Obliqua
Dummy
star (wrapper)
MORS
Dummy
outgas
(wrapper)
CALLIOPE
Dummy
Atmodeller
F_xuv, F_bol, spectrum
Mass loss, species fluxes
Init
T_surf, F_atm, R_planet
Outgas fluxes, redox, pressure
Initial state, params
Offline Chemistry
Interior
Orbit & Tides
Stellar Flux
Escape
Outgassing
Atmosphere Climate
Housekeeping & Convergence
Finished?
Final Plots & Archive
escape
(wrapper)
SPIDER
Aragog
Zalmoxis
Dummy
interior (wrapper)
Structure
ZEPHYRUS
Dummy
atmos_clim (wrapper)
AGNI
JANUS
Dummy
Yes
No
atmos_chem (wrapper)
VULCAN
Source fluxes
State variables, plots, spectra, chemistry
T, masses, radii, fluxes, Φ, rheological front
e, a, obliquity, F_tide
Runtime, iterations, checks
Dummy
Dummy
Process
Module
Decision
I/O
Boundary
proteus.start
post-loop
\ No newline at end of file +
Energetics
orbit (wrapper)
LovePy
Obliqua
Dummy
star (wrapper)
MORS
Dummy
outgas
(wrapper)
CALLIOPE
Dummy
Atmodeller
F_xuv, F_bol, spectrum
Mass loss, species fluxes
Init
T_surf, F_atm, R_planet
Outgas fluxes, redox, pressure
Initial state, params
Offline Chemistry
Interior
Orbit & Tides
Stellar Flux
Escape
Outgassing
Atmosphere Climate
Housekeeping & Convergence
Finished?
Final Plots & Archive
escape
(wrapper)
SPIDER
Aragog
Zalmoxis
Dummy
interior (wrapper)
Structure
ZEPHYRUS
Dummy
atmos_clim (wrapper)
AGNI
JANUS
Dummy
Yes
No
atmos_chem (wrapper)
VULCAN
Source fluxes
State variables, plots, spectra, chemistry
T, masses, radii, fluxes, Φ, rheological front
e, a, obliquity, F_tide
Runtime, iterations, checks
Dummy
Dummy
Process
Module
Decision
I/O
Boundary
proteus.start
post-loop
accretion(wrapper)Protoplanet accretion via giant impacts (Kimura et al. 2025).MorriganReplay an impact timeline written earlier.TimelineAnalytical accretion from scaling laws.Dummy
\ No newline at end of file diff --git a/docs/assets/proteus_modules_schematic.svg b/docs/assets/proteus_modules_schematic.svg index 69cfb88a7..fd05ade14 100644 --- a/docs/assets/proteus_modules_schematic.svg +++ b/docs/assets/proteus_modules_schematic.svg @@ -1,4 +1,4 @@ -
Fatm
Fbol
FXUV
FMO
FCMB

Atmosphere: climate



Escape
A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.
 AGNI 

In- / outgassing



An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.
CALLIOPE
Star


Code that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.
MORS
Tides


Solid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.
LovePy
Atmospheric escape


Code for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.
 ZEPHYRUS
In- & outgassing
Tidal heating

Interior 

Atmosphere: chemistry



Photochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.
VULCAN
Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.
FastChem
Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior.
Atmodeller
A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.
JANUS
Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.
Obliqua
modules
CHNOS volatiles
PROTEUS module group
Layer interaction
Energy flux

Atmosphere: radiation



A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.
SOCRATES

An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.
Zalmoxis
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.
Aragog
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.
SPIDER
Structure
Energetics
\ No newline at end of file + Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan
Fatm
Fbol
FXUV
FMO
FCMB

Atmosphere: climate



Escape
A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.
 AGNI 

In- / outgassing



An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.
CALLIOPE
Star


Code that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.
MORS
Tides


Solid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.
LovePy
Atmospheric escape


Code for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.
 ZEPHYRUS
In- & outgassing
Tidal heating

Interior 

Atmosphere: chemistry



Photochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.
VULCAN
Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.
FastChem
Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior.
Atmodeller
A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.
JANUS
Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.
Obliqua
modules
CHNOS volatiles
PROTEUS module group
Layer interaction
Energy flux

Atmosphere: radiation



A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.
SOCRATES

An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.
Zalmoxis
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.
Aragog
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.
SPIDER
Structure
Energetics
Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan
\ No newline at end of file diff --git a/docs/assets/proteus_modules_schematic_darkmode.svg b/docs/assets/proteus_modules_schematic_darkmode.svg index 6e6b10949..0c9cd1a33 100644 --- a/docs/assets/proteus_modules_schematic_darkmode.svg +++ b/docs/assets/proteus_modules_schematic_darkmode.svg @@ -1,4 +1,4 @@ -
Fatm
Fbol
FXUV
FMO
FCMB

Atmosphere: climate



Escape
A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.
 AGNI 

In- / outgassing



An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.
CALLIOPE
Star


Code that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.
MORS
Tides


Solid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.
LovePy
Atmospheric escape


Code for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.
 ZEPHYRUS
In- & outgassing
Tidal heating

Interior 

Atmosphere: chemistry



Photochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.
VULCAN
Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.
FastChem
Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior.
Atmodeller
A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.
JANUS
Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.
Obliqua
modules
CHNOS volatiles
PROTEUS module group
Layer interaction
Energy flux

Atmosphere: radiation



A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.
SOCRATES

An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.
Zalmoxis
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.
Aragog
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.
SPIDER
Structure
Energetics
\ No newline at end of file + Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan
Fatm
Fbol
FXUV
FMO
FCMB

Atmosphere: climate



Escape
A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.
 AGNI 

In- / outgassing



An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.
CALLIOPE
Star


Code that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.
MORS
Tides


Solid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.
LovePy
Atmospheric escape


Code for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.
 ZEPHYRUS
In- & outgassing
Tidal heating

Interior 

Atmosphere: chemistry



Photochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.
VULCAN
Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.
FastChem
Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior.
Atmodeller
A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.
JANUS
Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.
Obliqua
modules
CHNOS volatiles
PROTEUS module group
Layer interaction
Energy flux

Atmosphere: radiation



A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.
SOCRATES

An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.
Zalmoxis
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.
Aragog
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.
SPIDER
Structure
Energetics
Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan
\ No newline at end of file diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 633330e11..599e31895 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -40,10 +40,49 @@ --pt-basalt: #0E131B; --pt-line-d: #1A2230; + /* Module domain colours. One hue per physical domain, held stable across + every artefact in the ecosystem: the website, the overview figures, and + these pages. A module inherits the colour of the domain it acts on, so a + reader who learns the mapping once can carry it between the flowchart, the + planet schematic and the module tables. Source of truth for these values is + the ecosystem site's design tokens; keep them identical. */ + --pt-dom-interior: #E23D28; /* SPIDER, Aragog, Zalmoxis */ + --pt-dom-outgassing: #A03123; /* CALLIOPE, Atmodeller */ + --pt-dom-tidal: #593E74; /* LovePy, Obliqua; the red-to-blue midpoint, CVD-safe */ + --pt-dom-chem: #1B6FA8; /* VULCAN, ZEPHYRUS */ + --pt-dom-atmos: #4FA3D9; /* AGNI, JANUS */ + --pt-dom-stellar: #E0A32E; /* MORS; solar gold, deepened on light for contrast */ + --pt-dom-accretion: #A38F7A; /* Morrigan; clay, sits clear of the reds and the blues */ + + /* Diverging phase ramp, magma through void to ocean, for data that runs from + molten to frozen. Ordered, so an index maps to a position on the ramp. */ + --pt-p1: #E23D28; + --pt-p2: #8E1F12; + --pt-p3: #3A120C; + --pt-p4: #05070B; + --pt-p5: #0E2A45; + --pt-p6: #14406B; + --pt-p7: #1B6FA8; + --pt-p8: #4FA3D9; + --pt-p9: #A8D4E8; + + /* Status, kept apart from the domain hues so a red module and a failure + never have to be told apart by colour alone. */ + --pt-positive: #2E8B57; + --pt-warning: #C77726; + --pt-danger: #C2362B; + --pt-info: #1B6FA8; + --md-text-font: "Instrument Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; --md-code-font: "Spline Sans Mono", ui-monospace, "SF Mono", Menlo, monospace; } +/* Solar deepens on the light scheme for adequate contrast, the one domain + colour that differs between schemes. */ +[data-md-color-scheme="default"] { + --pt-dom-stellar: #C8860F; +} + /* ---------- DARK (scheme: slate) — the primary mode ---------- */ [data-md-color-scheme="slate"] { --md-default-bg-color: var(--pt-void); From 40004afdad0198c07b2c990b70108672711f216d Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 26 Jul 2026 19:39:23 +0200 Subject: [PATCH 27/71] Show the accretion step in the architecture diagram The coupling loop in the code-architecture diagram now has an Accretion stage between Interior and Orbit & Tides, which is where an impact is applied, and the left column carries the accretion module group with Morrigan, Timeline and Dummy. The quantities the step hands on are named on the edge into Orbit & Tides, and the flux and state labels throughout the figure are set with real subscripts. The diagram is generated from a model in tools/figures rather than drawn by hand. Both colour modes come from that one model, so the dark variant cannot drift away from the light one, and the whole figure rebuilds with pdflatex and poppler. A label takes its ink from the surface underneath it: on a chip whose colour is the same in both modes it keeps the ink it has in light mode, and only a label whose surface changes has its ink recomputed. tools/figures/README.md describes the model and the rebuild. Module links that pointed into src/proteus/interior now point at interior_energetics and interior_struct, the escape backends point at the functions that implement them, and each loop stage points at the call site in proteus.py that runs it. Atmodeller and the three remaining dummy backends are linked as well. The module schematic keeps its artwork, but its labels are native SVG text instead of HTML with a raster copy alongside, so a still export shows the same wording as a browser and the file is a third smaller. The module list, the loop order and the model description gained the accretion step, including what the re-melt after an impact does and does not guarantee. --- docs/Explanations/code_architecture.md | 1 + docs/Explanations/coupling_loop.md | 31 +- docs/Explanations/model.md | 9 + docs/assets/proteus_architecture.svg | 1738 +++++- docs/assets/proteus_architecture_darkmode.svg | 1738 +++++- docs/assets/proteus_modules_schematic.svg | 9 +- .../proteus_modules_schematic_darkmode.svg | 9 +- docs/stylesheets/layout.css | 2 +- tools/figures/README.md | 59 + tools/figures/add_svg_links.py | 62 + tools/figures/arch_final.json | 5217 +++++++++++++++++ tools/figures/build_architecture.sh | 47 + tools/figures/gen_tikz.py | 510 ++ tools/figures/img/arch_dark_img00.png | Bin 0 -> 56235 bytes tools/figures/img/arch_light_img00.png | Bin 0 -> 47033 bytes 15 files changed, 9409 insertions(+), 23 deletions(-) create mode 100644 tools/figures/README.md create mode 100644 tools/figures/add_svg_links.py create mode 100644 tools/figures/arch_final.json create mode 100755 tools/figures/build_architecture.sh create mode 100644 tools/figures/gen_tikz.py create mode 100644 tools/figures/img/arch_dark_img00.png create mode 100644 tools/figures/img/arch_light_img00.png diff --git a/docs/Explanations/code_architecture.md b/docs/Explanations/code_architecture.md index 1dded235f..06b798dc5 100644 --- a/docs/Explanations/code_architecture.md +++ b/docs/Explanations/code_architecture.md @@ -12,6 +12,7 @@ coupled planetary evolution simulation: - [`atmos_chem/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/atmos_chem): atmospheric photochemistry (VULCAN, dummy) - [`escape/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/escape): atmospheric mass loss (ZEPHYRUS, dummy) - [`outgas/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/outgas): volatile partitioning (CALLIOPE, atmodeller, dummy) +- [`accretion/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/accretion): protoplanet growth by giant impacts (Morrigan, timeline, dummy) - [`orbit/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/orbit): orbital evolution and tides (Obliqua/LovePy, dummy) - [`star/`](https://github.com/FormingWorlds/PROTEUS/tree/main/src/proteus/star): stellar evolution and spectra (MORS, dummy) diff --git a/docs/Explanations/coupling_loop.md b/docs/Explanations/coupling_loop.md index b5fb4522a..0352df96c 100644 --- a/docs/Explanations/coupling_loop.md +++ b/docs/Explanations/coupling_loop.md @@ -42,47 +42,58 @@ upstream modules. melt fraction, and heat flux using the chosen solver (Aragog, SPIDER, boundary, or dummy). Advances simulation time by the interior timestep. -2. **Structure update** (`update_structure_from_interior`): If Zalmoxis is +2. **Giant impacts** (`apply_impact`): Applies every impact whose time falls + within the step just taken. The impactor's rock is added to the planet and + the interior structure is re-solved at the new mass, its volatiles are + delivered while part of the target's atmosphere is stripped, the mantle is + re-melted by re-applying the run's temperature-mode initial condition, and + the orbit takes the impact's change in semi-major axis and eccentricity. + How molten the re-melt leaves the mantle follows that initial condition: + only `planet.temperature_mode = "liquidus_super"` is fully molten for any + planet mass and melting curve. Runs only when an accretion module is + configured and an impact is due. + +3. **Structure update** (`update_structure_from_interior`): If Zalmoxis is active and a structure update is triggered (by elapsed time, melt fraction change, or temperature change exceeding configured thresholds), recomputes the hydrostatic density profile and planet radius. -3. **Orbit and tides** (`run_orbit`): Updates orbital elements (semi-major +4. **Orbit and tides** (`run_orbit`): Updates orbital elements (semi-major axis, eccentricity) and computes tidal heating rates. Tidal power is distributed radially and passed to the interior module for the next iteration. -4. **Stellar evolution** (`update_stellar_quantities`): Interpolates the +5. **Stellar evolution** (`update_stellar_quantities`): Interpolates the stellar mass, radius, effective temperature, and luminosity from pre-computed evolutionary tracks at the current stellar age. Recomputes the instellation flux and XUV flux. The stellar spectrum is updated on a separate, longer cadence controlled by `params.dt.starspec`. -5. **Atmospheric escape** (`run_escape`): Computes mass loss rates for each +6. **Atmospheric escape** (`run_escape`): Computes mass loss rates for each element (H, C, N, S, O) based on the XUV flux, planet mass, and current atmospheric composition. Updates element inventories by debiting the escaped mass. Only active after the initialisation stage. -6. **Outgassing** (`run_outgassing`): Given the updated element inventories, +7. **Outgassing** (`run_outgassing`): Given the updated element inventories, mantle temperature, and melt fraction, computes the thermodynamic equilibrium partitioning of volatiles between atmosphere, melt, and solid. Writes partial pressures, mixing ratios, and atmospheric mass to `hf_row`. Also calls `update_planet_mass` and `assert_mass_conservation` to verify the whole-planet mass budget. -7. **Atmosphere climate** (`run_atmosphere`): Solves the radiative-convective +8. **Atmosphere climate** (`run_atmosphere`): Solves the radiative-convective structure of the atmosphere using the chosen backend (AGNI, JANUS, or dummy). Takes the interior heat flux and atmospheric composition as input; returns the surface temperature, outgoing longwave radiation, and Bond albedo. -8. **Atmospheric chemistry** (`run_chemistry`): If configured for online mode, +9. **Atmospheric chemistry** (`run_chemistry`): If configured for online mode, runs photochemical kinetics (VULCAN) to compute steady-state mixing ratios. Most configurations skip this step or run it offline after the simulation. -9. **Housekeeping**: Updates iteration counters, checks convergence criteria, - writes the helpfile row to `hf_all`, generates plots and archives if - scheduled. +10. **Housekeeping**: Updates iteration counters, checks convergence criteria, + writes the helpfile row to `hf_all`, generates plots and archives if + scheduled. ## Initialisation stage diff --git a/docs/Explanations/model.md b/docs/Explanations/model.md index 679ad5077..c4c722fae 100644 --- a/docs/Explanations/model.md +++ b/docs/Explanations/model.md @@ -46,6 +46,7 @@ atmosphere), enabling hierarchical model intercomparison. | Escape | [ZEPHYRUS](https://github.com/FormingWorlds/ZEPHYRUS), dummy | Atmospheric escape | | Outgassing | [CALLIOPE](https://proteus-framework.org/CALLIOPE/), [atmodeller](https://github.com/djbower/atmodeller), dummy | Volatile exchange between interior and atmosphere | | Orbit | [Obliqua](https://github.com/FormingWorlds/Obliqua), dummy | Orbital evolution and tidal heating | +| Accretion | [Morrigan](https://proteus-framework.org/Morrigan/), timeline, dummy | Protoplanet growth by giant impacts | | Observations | [petitRADTRANS](https://petitradtrans.readthedocs.io/), none | Synthetic transit and eclipse spectra | Each module is maintained in its own repository and can be used as a standalone package outside of PROTEUS. The following sections describe each module's physical role and how PROTEUS couples to it. @@ -118,6 +119,14 @@ Config section: `[outgas]`. Reference: [Escape and outgassing configuration](../ Config section: `[orbit]`. Reference: [Star and orbit configuration](../Reference/config/star_orbit.md). +## Accretion: Morrigan + +**[Morrigan](https://proteus-framework.org/Morrigan/)** (Python) follows a system of protoplanets through the giant impacts and gravitational scattering by which they accrete, using the semi-analytical Monte Carlo model of [Kimura et al. (2025)](https://doi.org/10.3847/1538-4357/ade992). PROTEUS takes the impact history of one body from that system and applies each collision as it falls due: the impactor's rock grows the planet and the structure is re-solved at the new mass, the impactor's volatiles are delivered while part of the target's atmosphere is stripped, the mantle is re-melted, and the orbit takes the collision's change in semi-major axis and eccentricity. + +Two further implementations take a history rather than derive one: `timeline` replays a table of impacts read from a file, and `dummy` grows the planet along an analytical accretion curve. With no accretion module selected the impact list is empty and the planet's mass is set only by its initial condition. + +Config section: `[accretion]`. Reference: [Elemental delivery and accretion](../How-to/config.md#elemental-delivery-and-accretion). + ## Synthetic observations: petitRADTRANS **[petitRADTRANS](https://petitradtrans.readthedocs.io/)** (Python) is a radiative transfer code for computing exoplanet transmission and emission spectra. PROTEUS uses petitRADTRANS as a forward model to synthesise what an observer would measure given the simulated atmospheric state. diff --git a/docs/assets/proteus_architecture.svg b/docs/assets/proteus_architecture.svg index 35c84fed0..43bd7c101 100644 --- a/docs/assets/proteus_architecture.svg +++ b/docs/assets/proteus_architecture.svg @@ -1,4 +1,1736 @@ - - -
Energetics
orbit (wrapper)
LovePy
Obliqua
Dummy
star (wrapper)
MORS
Dummy
outgas
(wrapper)
CALLIOPE
Dummy
Atmodeller
F_xuv, F_bol, spectrum
Mass loss, species fluxes
Init
T_surf, F_atm, R_planet
Outgas fluxes, redox, pressure
Initial state, params
Offline Chemistry
Interior
Orbit & Tides
Stellar Flux
Escape
Outgassing
Atmosphere Climate
Housekeeping & Convergence
Finished?
Final Plots & Archive
escape
(wrapper)
SPIDER
Aragog
Zalmoxis
Dummy
interior (wrapper)
Structure
ZEPHYRUS
Dummy
atmos_clim (wrapper)
AGNI
JANUS
Dummy
Yes
No
atmos_chem (wrapper)
VULCAN
Source fluxes
State variables, plots, spectra, chemistry
T, masses, radii, fluxes, Φ, rheological front
e, a, obliquity, F_tide
Runtime, iterations, checks
Dummy
Dummy
Process
Module
Decision
I/O
Boundary
proteus.start
post-loop
accretion(wrapper)Protoplanet accretion via giant impacts (Kimura et al. 2025).MorriganReplay an impact timeline written earlier.TimelineAnalytical accretion from scaling laws.Dummy
\ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +src/proteus/orbit/wrapper.pysrc/proteus/orbit/lovepy.pysrc/proteus/orbit/dummy.pysrc/proteus/star/wrapper.pysrc/proteus/star/wrapper.py#L46src/proteus/star/dummy.pysrc/proteus/outgas/wrapper.pysrc/proteus/outgas/calliope.pysrc/proteus/outgas/dummy.pysrc/proteus/outgas/atmodeller.pysrc/proteus/proteus.py#L262src/proteus/proteus.py#L39src/proteus/proteus.py#L1297src/proteus/proteus.py#L799src/proteus/proteus.py#L916src/proteus/proteus.py#L934src/proteus/proteus.py#L980src/proteus/proteus.py#L1040src/proteus/proteus.py#L1097src/proteus/proteus.py#L1213src/proteus/escape/wrapper.pysrc/proteus/interior_energetics/spider.pysrc/proteus/interior_energetics/aragog.pysrc/proteus/interior_struct/zalmoxis.pysrc/proteus/interior_energetics/dummy.pysrc/proteus/interior_energetics/wrapper.pysrc/proteus/escape/wrapper.py#L190src/proteus/escape/wrapper.py#L133src/proteus/atmos_clim/wrapper.pysrc/proteus/atmos_clim/agni.pysrc/proteus/atmos_clim/janus.pysrc/proteus/atmos_clim/dummy.pysrc/proteus/atmos_chem/wrapper.pysrc/proteus/atmos_chem/vulcan.pysrc/proteus/atmos_chem/dummy.pysrc/proteus/interior_struct/dummy.pysrc/proteus/interior_energetics/boundary.pysrc/proteus/proteus.py#L870src/proteus/accretion/wrapper.pysrc/proteus/accretion/morrigan.pysrc/proteus/accretion/timeline.pysrc/proteus/accretion/dummy.py + diff --git a/docs/assets/proteus_architecture_darkmode.svg b/docs/assets/proteus_architecture_darkmode.svg index 86ac8e788..c4316a291 100644 --- a/docs/assets/proteus_architecture_darkmode.svg +++ b/docs/assets/proteus_architecture_darkmode.svg @@ -1,4 +1,1736 @@ - - -
Energetics
orbit (wrapper)
LovePy
Obliqua
Dummy
star (wrapper)
MORS
Dummy
outgas
(wrapper)
CALLIOPE
Dummy
Atmodeller
F_xuv, F_bol, spectrum
Mass loss, species fluxes
Init
T_surf, F_atm, R_planet
Outgas fluxes, redox, pressure
Initial state, params
Offline Chemistry
Interior
Orbit & Tides
Stellar Flux
Escape
Outgassing
Atmosphere Climate
Housekeeping & Convergence
Finished?
Final Plots & Archive
escape
(wrapper)
SPIDER
Aragog
Zalmoxis
Dummy
interior (wrapper)
Structure
ZEPHYRUS
Dummy
atmos_clim (wrapper)
AGNI
JANUS
Dummy
Yes
No
atmos_chem (wrapper)
VULCAN
Source fluxes
State variables, plots, spectra, chemistry
T, masses, radii, fluxes, Φ, rheological front
e, a, obliquity, F_tide
Runtime, iterations, checks
Dummy
Dummy
Process
Module
Decision
I/O
Boundary
proteus.start
post-loop
\ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +src/proteus/orbit/wrapper.pysrc/proteus/orbit/lovepy.pysrc/proteus/orbit/dummy.pysrc/proteus/star/wrapper.pysrc/proteus/star/wrapper.py#L46src/proteus/star/dummy.pysrc/proteus/outgas/wrapper.pysrc/proteus/outgas/calliope.pysrc/proteus/outgas/dummy.pysrc/proteus/outgas/atmodeller.pysrc/proteus/proteus.py#L262src/proteus/proteus.py#L39src/proteus/proteus.py#L1297src/proteus/proteus.py#L799src/proteus/proteus.py#L916src/proteus/proteus.py#L934src/proteus/proteus.py#L980src/proteus/proteus.py#L1040src/proteus/proteus.py#L1097src/proteus/proteus.py#L1213src/proteus/escape/wrapper.pysrc/proteus/interior_energetics/spider.pysrc/proteus/interior_energetics/aragog.pysrc/proteus/interior_struct/zalmoxis.pysrc/proteus/interior_energetics/dummy.pysrc/proteus/interior_energetics/wrapper.pysrc/proteus/escape/wrapper.py#L190src/proteus/escape/wrapper.py#L133src/proteus/atmos_clim/wrapper.pysrc/proteus/atmos_clim/agni.pysrc/proteus/atmos_clim/janus.pysrc/proteus/atmos_clim/dummy.pysrc/proteus/atmos_chem/wrapper.pysrc/proteus/atmos_chem/vulcan.pysrc/proteus/atmos_chem/dummy.pysrc/proteus/interior_struct/dummy.pysrc/proteus/interior_energetics/boundary.pysrc/proteus/proteus.py#L870src/proteus/accretion/wrapper.pysrc/proteus/accretion/morrigan.pysrc/proteus/accretion/timeline.pysrc/proteus/accretion/dummy.py + diff --git a/docs/assets/proteus_modules_schematic.svg b/docs/assets/proteus_modules_schematic.svg index fd05ade14..9c489ca5b 100644 --- a/docs/assets/proteus_modules_schematic.svg +++ b/docs/assets/proteus_modules_schematic.svg @@ -1,4 +1,7 @@ - - - Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan
Fatm
Fbol
FXUV
FMO
FCMB

Atmosphere: climate



Escape
A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.
 AGNI 

In- / outgassing



An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.
CALLIOPE
Star


Code that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.
MORS
Tides


Solid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.
LovePy
Atmospheric escape


Code for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.
 ZEPHYRUS
In- & outgassing
Tidal heating

Interior 

Atmosphere: chemistry



Photochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.
VULCAN
Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.
FastChem
Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior.
Atmodeller
A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.
JANUS
Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.
Obliqua
modules
CHNOS volatiles
PROTEUS module group
Layer interaction
Energy flux

Atmosphere: radiation



A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.
SOCRATES

An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.
Zalmoxis
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.
Aragog
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.
SPIDER
Structure
Energetics
Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan
\ No newline at end of file + + + + +Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).MorriganFatmFbolFXUVFMOFCMBAtmosphere:climateEscapeA single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.AGNIIn-/outgassingAn outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.CALLIOPEStarCode that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.MORSTidesSolid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.LovePyAtmosphericescapeCode for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.ZEPHYRUSIn-&outgassingTidalheatingInteriorAtmosphere:chemistryPhotochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.VULCANGas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.FastChemModel using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. AtmodellerA 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.JANUSModel that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.ObliquamodulesCHNOSvolatilesPROTEUSmodulegroupLayerinteractionEnergyfluxAtmosphere:radiationA radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.SOCRATESAn interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.ZalmoxisA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.AragogA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.SPIDERStructureEnergeticsProtoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan \ No newline at end of file diff --git a/docs/assets/proteus_modules_schematic_darkmode.svg b/docs/assets/proteus_modules_schematic_darkmode.svg index 0c9cd1a33..301a6b6ed 100644 --- a/docs/assets/proteus_modules_schematic_darkmode.svg +++ b/docs/assets/proteus_modules_schematic_darkmode.svg @@ -1,4 +1,7 @@ - - - Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan
Fatm
Fbol
FXUV
FMO
FCMB

Atmosphere: climate



Escape
A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.
 AGNI 

In- / outgassing



An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.
CALLIOPE
Star


Code that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.
MORS
Tides


Solid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.
LovePy
Atmospheric escape


Code for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.
 ZEPHYRUS
In- & outgassing
Tidal heating

Interior 

Atmosphere: chemistry



Photochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.
VULCAN
Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.
FastChem
Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior.
Atmodeller
A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.
JANUS
Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.
Obliqua
modules
CHNOS volatiles
PROTEUS module group
Layer interaction
Energy flux

Atmosphere: radiation



A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.
SOCRATES

An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.
Zalmoxis
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.
Aragog
A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.
SPIDER
Structure
Energetics
Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan
\ No newline at end of file + + + + +Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).MorriganFatmFbolFXUVFMOFCMBAtmosphere:climateEscapeA single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.AGNIIn-/outgassingAn outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.CALLIOPEStarCode that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.MORSTidesSolid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.LovePyAtmosphericescapeCode for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.ZEPHYRUSIn-&outgassingTidalheatingInteriorAtmosphere:chemistryPhotochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.VULCANGas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.FastChemModel using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. AtmodellerA 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.JANUSModel that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.ObliquamodulesCHNOSvolatilesPROTEUSmodulegroupLayerinteractionEnergyfluxAtmosphere:radiationA radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.SOCRATESAn interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.ZalmoxisA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.AragogA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.SPIDERStructureEnergeticsProtoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan \ No newline at end of file diff --git a/docs/stylesheets/layout.css b/docs/stylesheets/layout.css index 8a886bb88..7d66f8a11 100644 --- a/docs/stylesheets/layout.css +++ b/docs/stylesheets/layout.css @@ -31,7 +31,7 @@ } .arch-diagram[data*="proteus_architecture"] { - aspect-ratio: 1412 / 1315; + aspect-ratio: 1412 / 1415; } /* Light mode: show light diagram */ diff --git a/tools/figures/README.md b/tools/figures/README.md new file mode 100644 index 000000000..afff78d3f --- /dev/null +++ b/tools/figures/README.md @@ -0,0 +1,59 @@ +# Documentation figure sources + +## Code-architecture diagram + +`docs/assets/proteus_architecture.svg` and its dark counterpart are generated +from `arch_final.json`, which holds every shape, edge, label and link of the +diagram in one coordinate model. `gen_tikz.py` renders that model to a +standalone TikZ document, once per colour mode; `add_svg_links.py` re-attaches +the clickable regions, which the PDF-to-SVG conversion does not carry over. + +Rebuild both variants with: + +```bash +bash tools/figures/build_architecture.sh +``` + +The model is the single source for both modes. Neutral colours (text, hairlines, +surfaces) are mapped per mode in `gen_tikz.py`; the domain hues are identical in +both, and each label takes the ink that contrasts with the surface it sits on. +Label positions are stored as measured baselines, so the line breaking is fixed +in the model rather than left to the typesetter. + +To change the diagram, edit `arch_final.json`: + +- shapes are `rect` (rounded rectangles, `rx` is the corner radius) and `path` + (hexagons, the decision rhombus, the archive parallelogram, and every edge), + in the SVG coordinate system with y increasing downwards; +- `text` items carry one entry per line in `mlines`, with the line box the label + occupies and the ink colour of its light-mode form; +- `href` on any item makes it clickable, both in the PDF and in the SVG; +- `cell` groups the primitives that belong to one diagram element. + +A label's baseline is `top + BASELINE_K[size] * size`, so to place a new one, +set `top` to `anchor - 7.3` for a single 13 px line centred on `anchor`, or to +`anchor - 5.5` for a 10 px line; a two-line 13 px label puts its lines at +`anchor - 14.5` and `anchor + 1.09`. `bottom` is `top + 1.2 * size`. For a +centred label only the midpoint of `left` and `right` is used for placement, so +those two can span the shape the label sits in. Alignment comes from `align` +(`center`, `left` or `right`), which decides whether the line is anchored by its +midpoint or by an edge. + +Colours are written in their light-mode form. `gen_tikz.py` maps the neutrals +for the dark variant and leaves the domain hues alone. A label's ink follows the +surface underneath it: on a chip whose colour is the same in both modes it keeps +the ink given here, and only a label whose surface changes between modes has its +ink recomputed by contrast. + +The loop-stage boxes link to the call site in `src/proteus/proteus.py` that runs +them, and module boxes link to the file that implements them. Both are line +anchors on `main` and are worth re-checking when the loop is restructured. + +## Module schematic + +`docs/assets/proteus_modules_schematic.svg` and its dark counterpart are drawn +in draw.io; the editable diagram is embedded in each file's `content` +attribute. Their labels are native SVG text rather than HTML in a `foreignObject` +with a raster fallback, so every renderer, not just a browser, shows the current +wording. Re-exporting from draw.io restores the HTML-plus-raster form, and the +labels then have to be converted back. diff --git a/tools/figures/add_svg_links.py b/tools/figures/add_svg_links.py new file mode 100644 index 000000000..adef806fe --- /dev/null +++ b/tools/figures/add_svg_links.py @@ -0,0 +1,62 @@ +"""Add clickable regions to the SVG exported from the TikZ figure. + +The PDF-to-SVG converter drops link annotations, so the clickable areas are +re-attached here as an overlay of transparent rectangles, one per linked shape, +in the figure's own coordinate system. The overlay is appended last so it sits +above the artwork and receives the clicks. +""" + +from __future__ import annotations + +import json +import re +import sys +from html import escape +from pathlib import Path + + +def main(svg_path, links_path, out_path, fig_w=1412.0, fig_h=1415.0): + svg = Path(svg_path).read_text() + links = json.loads(Path(links_path).read_text()) + + m = re.search(r'viewBox="0 0 ([\d.]+) ([\d.]+)"', svg) + if not m: + raise SystemExit('no viewBox in the exported SVG') + vw, vh = float(m.group(1)), float(m.group(2)) + sx, sy = vw / fig_w, vh / fig_h + + seen = set() + rows = [] + for lb in links: + key = (round(lb['x'], 2), round(lb['y'], 2), lb['href']) + if key in seen: + continue + seen.add(key) + title = lb['href'].split('/blob/main/')[-1].split('/tree/main/')[-1] + rows.append( + f'' + f'' + f'{escape(title)}' + ) + + overlay = '' + ''.join(rows) + '\n' + if '' not in svg: + raise SystemExit('malformed SVG') + svg = svg.replace('', overlay + '') + + # An SVG loaded as its own document is painted on the user agent's default + # canvas, which would box the figure in white on a dark page. Declaring the + # background transparent and naming the scheme the colours were built for + # lets the page show through in both schemes. + scheme = 'dark' if 'dark' in Path(out_path).stem else 'light' + style = f'background: transparent; background-color: transparent; color-scheme: {scheme};' + if 'background: transparent' not in svg: + svg = svg.replace('/dev/null 2>&1 || { + echo "error: $tool not found on PATH" >&2 + exit 1 + } +done + +work="$(mktemp -d)" +keep=0 +trap '[ "$keep" = 1 ] && echo "build files kept in $work" >&2 || rm -rf "$work"' EXIT + +cp -r "$here/img" "$work/" + +for mode in light dark; do + python "$here/gen_tikz.py" "$here/arch_final.json" "$work/arch_$mode.tex" "$mode" + if ! (cd "$work" && pdflatex -interaction=nonstopmode -halt-on-error "arch_$mode.tex" \ + > "arch_$mode.build.log" 2>&1); then + keep=1 + echo "error: pdflatex failed for the $mode variant; see $work/arch_$mode.build.log" >&2 + tail -20 "$work/arch_$mode.build.log" >&2 + exit 1 + fi + pdftocairo -svg "$work/arch_$mode.pdf" "$work/arch_$mode.svg" + python "$here/add_svg_links.py" \ + "$work/arch_$mode.svg" "$work/arch_$mode.links.json" "$work/final_$mode.svg" +done + +cp "$work/final_light.svg" "$assets/proteus_architecture.svg" +cp "$work/final_dark.svg" "$assets/proteus_architecture_darkmode.svg" +echo "updated $assets/proteus_architecture{,_darkmode}.svg" diff --git a/tools/figures/gen_tikz.py b/tools/figures/gen_tikz.py new file mode 100644 index 000000000..4d22587a6 --- /dev/null +++ b/tools/figures/gen_tikz.py @@ -0,0 +1,510 @@ +"""Generate a standalone TikZ figure from extracted draw.io SVG primitives. + +The generator works in the SVG coordinate system (origin top-left, y down, +1 unit = 1 bp) so every coordinate can be transcribed verbatim. Colours are +resolved per output mode, which makes the TikZ source the single authority for +the light and dark variants instead of two separately exported files. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +from PIL import Image + +HERE = Path(__file__).parent + +# -------------------------------------------------------------------------- +# Colour handling +# -------------------------------------------------------------------------- + +# Neutral colours flip between modes; domain hues are identical in both, having +# been picked to hold contrast on either background. +DARK_MAP = { + '#10151B': '#E9EEF2', # ink -> dark-mode text + '#3E4A55': '#9FB0BE', # secondary ink -> secondary dark-mode text + '#2A343D': '#9FB0BE', # connector stroke + '#FDFDFE': '#0E131B', # paper -> basalt (surfaces only, see CHIP_INK) + '#E3E9EE': '#12202E', # sunken paper -> raised basalt + '#F2F5F7': '#0E131B', + '#5A6B7A': '#E9EEF2', # section tag chip + '#C6E1EE': '#3A120C', +} + +# The two label inks. Which one a label uses is decided by the surface it sits +# on, not by the mode: a chip whose colour is the same in both modes keeps the +# same ink in both. +INK_LIGHT = '#FDFDFE' +INK_DARK = '#10151B' +CHIP_INK = INK_LIGHT +PAGE_BG = {'light': '#FDFDFE', 'dark': '#05070B'} + + +def _lin(c: float) -> float: + c /= 255.0 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + +def luminance(hexc: str) -> float: + r, g, b = (int(hexc[i : i + 2], 16) for i in (1, 3, 5)) + return 0.2126 * _lin(r) + 0.7152 * _lin(g) + 0.0722 * _lin(b) + + +def contrast(a: str, b: str) -> float: + la, lb = luminance(a), luminance(b) + hi, lo = max(la, lb), min(la, lb) + return (hi + 0.05) / (lo + 0.05) + + +def best_ink(bg: str) -> str: + return INK_LIGHT if contrast(INK_LIGHT, bg) > contrast(INK_DARK, bg) else INK_DARK + + +def parse_colour(val: str, mode: str) -> tuple[str, float] | None: + """Return (hex, alpha) or None for 'none'.""" + if val is None: + return None + val = val.strip() + if val in ('none', ''): + return None + if val.startswith('light-dark(') and val.endswith(')'): + inner = val[len('light-dark(') : -1] + depth, split = 0, None + for i, ch in enumerate(inner): + if ch == '(': + depth += 1 + elif ch == ')': + depth -= 1 + elif ch == ',' and depth == 0: + split = i + break + if split is None: + raise ValueError(f'malformed light-dark: {val!r}') + val = (inner[:split] if mode == 'light' else inner[split + 1 :]).strip() + m = re.match(r'rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$', val) + if m: + r, g, b = (int(m.group(i)) for i in (1, 2, 3)) + a = float(m.group(4)) if m.group(4) else 1.0 + return f'#{r:02X}{g:02X}{b:02X}', a + if val.startswith('#'): + return val.upper(), 1.0 + raise ValueError(f'unparsed colour {val!r}') + + +def resolve(val, mode: str): + """Resolve a source colour into the hex used for this output mode.""" + c = parse_colour(val, 'light') # always start from the light value + if c is None: + return None + hexc, alpha = c + if mode == 'dark': + hexc = DARK_MAP.get(hexc, hexc) + return hexc, alpha + + +COLOUR_NAMES: dict[str, str] = {} + + +def cname(hexc: str) -> str: + key = hexc.lstrip('#').upper() + COLOUR_NAMES[key] = key + return f'c{key}' + + +# -------------------------------------------------------------------------- +# Geometry helpers +# -------------------------------------------------------------------------- + + +def f(v: float) -> str: + s = f'{v:.3f}'.rstrip('0').rstrip('.') + return s if s not in ('', '-0') else '0' + + +def path_to_tikz(d: str) -> str: + """Convert an absolute-command SVG path into a TikZ path body. + + Quadratic segments are promoted to the equivalent cubic, which TikZ draws + natively and which is an exact transformation, not an approximation. + """ + toks = re.findall(r'[MLCQZ]|[-+]?[\d.]+(?:[eE][-+]?\d+)?', d) + out = [] + i = 0 + cmd = None + cur = None + first = True + while i < len(toks): + t = toks[i] + if re.match(r'^[A-Z]$', t): + cmd = t + i += 1 + if cmd == 'Z': + out.append('-- cycle') + continue + if cmd == 'M': + x, y = float(toks[i]), float(toks[i + 1]) + out.append(('' if first else ' ') + f'({f(x)},{f(y)})') + cur = (x, y) + first = False + cmd = 'L' # implicit lineto for subsequent pairs + i += 2 + elif cmd == 'L': + x, y = float(toks[i]), float(toks[i + 1]) + out.append(f'-- ({f(x)},{f(y)})') + cur = (x, y) + i += 2 + elif cmd == 'Q': + qx, qy = float(toks[i]), float(toks[i + 1]) + x, y = float(toks[i + 2]), float(toks[i + 3]) + c1 = (cur[0] + 2 / 3 * (qx - cur[0]), cur[1] + 2 / 3 * (qy - cur[1])) + c2 = (x + 2 / 3 * (qx - x), y + 2 / 3 * (qy - y)) + out.append( + f'.. controls ({f(c1[0])},{f(c1[1])}) and ({f(c2[0])},{f(c2[1])}) .. ({f(x)},{f(y)})' + ) + cur = (x, y) + i += 4 + elif cmd == 'C': + c1 = (float(toks[i]), float(toks[i + 1])) + c2 = (float(toks[i + 2]), float(toks[i + 3])) + x, y = float(toks[i + 4]), float(toks[i + 5]) + out.append( + f'.. controls ({f(c1[0])},{f(c1[1])}) and ({f(c2[0])},{f(c2[1])}) .. ({f(x)},{f(y)})' + ) + cur = (x, y) + i += 6 + else: + raise ValueError(f'unsupported command {cmd}') + return ' '.join(out) + + +def dash_opt(dash: str | None) -> str: + if not dash: + return '' + parts = [p for p in re.split(r'[ ,]+', dash.strip()) if p] + if len(parts) == 2: + return f', dash pattern=on {parts[0]}bp off {parts[1]}bp' + return '' + + +# -------------------------------------------------------------------------- +# Text handling +# -------------------------------------------------------------------------- + +TEX_ESCAPE = { + '&': r'\&', + '%': r'\%', + '$': r'\$', + '#': r'\#', + '_': r'\_', + '{': r'\{', + '}': r'\}', + '~': r'\textasciitilde{}', + '^': r'\textasciicircum{}', + '\\': r'\textbackslash{}', +} + +# Quantities written with a subscript. The figure labels name hf_row keys, so +# the stem case follows the code (F_xuv, not F_XUV). +SUBSCRIPTED = ['F_xuv', 'F_bol', 'F_tide', 'F_atm', 'T_surf', 'R_planet', 'R_int', 'M_planet'] + + +def tex_escape(s: str) -> str: + return ''.join(TEX_ESCAPE.get(ch, ch) for ch in s) + + +def tex_text(s: str, subscripts: bool) -> str: + """Escape a label, optionally promoting ``X_sub`` to a real subscript.""" + if subscripts: + pattern = '|'.join(re.escape(q) for q in SUBSCRIPTED) + parts = re.split(f'({pattern})', s) + out = [] + for p in parts: + if p in SUBSCRIPTED: + stem, sub = p.split('_') + out.append(rf'\textit{{{stem}}}\textsubscript{{{sub}}}') + else: + out.append(tex_escape(p)) + s = ''.join(out) + else: + s = tex_escape(s) + # the only non-ASCII glyph in the figure; the text font carries no Greek, so + # it is set from the sans math alphabet + s = s.replace('Φ', r'\ensuremath{\mathsf{\Phi}}') + return s + + +def font_cmd(size: float) -> str: + return rf'\fontsize{{{f(size)}bp}}{{{f(size * 1.2)}bp}}\selectfont' + + +# Baseline offset below the measured line-box top, as a fraction of the font +# size. Fitted against the reference render (fit_baseline.py); the browser's +# half-leading is not a fixed fraction of the size, hence the per-size table. +BASELINE_K_DEFAULT = 0.94 +BASELINE_K = {10: 1.015, 13: 0.94, 13.3: 0.93, 14: 0.895, 15: 0.945} + + +# -------------------------------------------------------------------------- +# Emission +# -------------------------------------------------------------------------- + + +def path_bbox(d: str): + pts = re.findall(r'([-\d.]+)[ ,]([-\d.]+)', d) + if not pts: + return None + xs = [float(a) for a, _ in pts] + ys = [float(b) for _, b in pts] + return min(xs), min(ys), max(xs), max(ys) + + +def blend(hexc: str, alpha: float, over: str) -> str: + a = [int(hexc[i : i + 2], 16) for i in (1, 3, 5)] + b = [int(over[i : i + 2], 16) for i in (1, 3, 5)] + return '#' + ''.join(f'{round(alpha * x + (1 - alpha) * y):02X}' for x, y in zip(a, b)) + + +def surfaces(items, mode: str): + """Filled shapes, largest first, so the last hit is the closest surface.""" + out = [] + for it in items: + if it.get('fill_opacity', 1) == 0 or it.get('fill') in (None, 'none'): + continue + c = resolve(it['fill'], mode) + if not c: + continue + col = c[0] if c[1] >= 1 else blend(c[0], c[1], PAGE_BG[mode]) + if it['kind'] == 'rect': + out.append((it['x'], it['y'], it['x'] + it['w'], it['y'] + it['h'], col)) + elif it['kind'] == 'path': + bb = path_bbox(it['d']) + if bb: + out.append((*bb, col)) + out.sort(key=lambda s: (s[2] - s[0]) * (s[3] - s[1]), reverse=True) + return out + + +def background_at(surfs, x: float, y: float, mode: str) -> str: + bg = PAGE_BG[mode] + for x0, y0, x1, y1, col in surfs: + if x0 <= x <= x1 and y0 <= y <= y1: + bg = col + return bg + + +def label_ink(item, line, surfs, surfs_light, mode: str) -> str: + """Pick a label's ink from the surface it sits on. + + The light mode uses the model's colour as it stands. In dark mode the ink + follows the surface: a chip whose colour is the same in both modes keeps the + ink it has in light mode, and only a label whose surface actually changes + has its ink recomputed, by contrast against the new surface. Choosing by + contrast alone would flip the ink on a saturated chip where the two inks are + nearly tied, leaving dark text on an unchanged red or blue chip. + """ + src = resolve(line['color'], 'light')[0] + if mode == 'light': + return src + if src not in (INK_LIGHT, INK_DARK): + return resolve(line['color'], 'dark')[0] # secondary text follows the map + x = (line['left'] + line['right']) / 2 + y = (line['top'] + line['bottom']) / 2 + bg_light = background_at(surfs_light, x, y, 'light') + bg_dark = background_at(surfs, x, y, mode) + if bg_dark == bg_light: + return src + return best_ink(bg_dark) + + +def emit(items, meta, mode: str, *, subscripts: bool, links: bool) -> str: + body: list[str] = [] + linkboxes: list[dict] = [] + surfs = surfaces(items, mode) + surfs_light = surfaces(items, 'light') + + for it in items: + kind = it['kind'] + href = it.get('href') or '' + + if kind == 'rect': + fill = resolve(it['fill'], mode) + stroke = resolve(it['stroke'], mode) + fo = it['fill_opacity'] + if fo == 0: + fill = None + if fill is None and stroke is None: + continue + opts = [] + if fill: + opts.append(f'fill={cname(fill[0])}') + if fill[1] < 1: + opts.append(f'fill opacity={f(fill[1])}') + if stroke: + opts.append(f'draw={cname(stroke[0])}') + opts.append(f'line width={f(it["stroke_width"])}bp') + else: + opts.append('draw=none') + r = it['rx'] + if r: + opts.append(f'rounded corners={f(r)}bp') + body.append( + f'\\path[{", ".join(opts)}{dash_opt(it["dash"])}] ' + f'({f(it["x"])},{f(it["y"])}) rectangle ' + f'({f(it["x"] + it["w"])},{f(it["y"] + it["h"])});' + ) + if href and links: + linkboxes.append( + { + 'x': it['x'], + 'y': it['y'], + 'w': it['w'], + 'h': it['h'], + 'href': href, + 'title': it.get('title'), + } + ) + + elif kind == 'path': + fill = resolve(it['fill'], mode) + stroke = resolve(it['stroke'], mode) + if it.get('fill_opacity', 1) == 0: + fill = None + opts = [] + if fill: + opts.append(f'fill={cname(fill[0])}') + if fill[1] < 1: + opts.append(f'fill opacity={f(fill[1])}') + if stroke: + opts.append(f'draw={cname(stroke[0])}') + opts.append(f'line width={f(it["stroke_width"])}bp') + if not opts: + continue + opts.append('line join=miter') + opts.append('line cap=butt') + body.append( + f'\\path[{", ".join(opts)}{dash_opt(it["dash"])}] {path_to_tikz(it["d"])};' + ) + if href and links: + xs, ys = zip( + *[ + (float(a), float(b)) + for a, b in re.findall( + r'\(([-\d.]+),([-\d.]+)\)', path_to_tikz(it['d']) + ) + ] + ) + linkboxes.append( + { + 'x': min(xs), + 'y': min(ys), + 'w': max(xs) - min(xs), + 'h': max(ys) - min(ys), + 'href': href, + 'title': it.get('title'), + } + ) + + elif kind == 'text': + # Each line is placed on its own measured baseline, so the figure + # reproduces the reference line breaking rather than re-deriving it + # from TeX's paragraph builder. + for ln in it['mlines']: + size = ln['size'] + col = (label_ink(it, ln, surfs, surfs_light, mode), 1.0) + anchor, xpos = { + 'left': ('base west', ln['left']), + 'right': ('base east', ln['right']), + 'center': ('base', (ln['left'] + ln['right']) / 2), + }[it['align']] + base = ln['top'] + BASELINE_K.get(size, BASELINE_K_DEFAULT) * size + opts = [ + 'inner sep=0', + 'outer sep=0', + f'text={cname(col[0])}', + f'anchor={anchor}', + f'font={font_cmd(size)}', + ] + body.append( + f'\\node[{", ".join(opts)}] at ({f(xpos)},{f(base)}) ' + f'{{{tex_text(ln["text"], subscripts)}}};' + ) + + elif kind == 'image' and it.get('file'): + # the raster logo has a light-on-dark counterpart + path = it['file'] + alt = path.replace('arch_light_img', 'arch_dark_img') + if mode == 'dark' and (HERE / alt).exists(): + path = alt + # SVG images default to preserveAspectRatio="xMidYMid meet": scale to + # fit inside the box and centre the remainder + iw, ih = Image.open(HERE / path).size + scale = min(it['w'] / iw, it['h'] / ih) + dw, dh = iw * scale, ih * scale + body.append( + f'\\node[inner sep=0, outer sep=0, anchor=north west] at ' + f'({f(it["x"] + (it["w"] - dw) / 2)},{f(it["y"] + (it["h"] - dh) / 2)}) ' + f'{{\\includegraphics[width={f(dw)}bp,height={f(dh)}bp]{{{path}}}}};' + ) + + return body, linkboxes + + +PREAMBLE = r"""\documentclass[tightpage]{standalone} +\usepackage[T1]{fontenc} +\usepackage[utf8]{inputenc} +\usepackage{helvet} +\renewcommand{\familydefault}{\sfdefault} +\usepackage{amsmath} +\usepackage{sansmath} +\usepackage{graphicx} +\usepackage{tikz} +\usepackage[hidelinks]{hyperref} +\usetikzlibrary{calc} +\sansmath +""" + + +def build(src: Path, out: Path, mode: str, *, subscripts: bool, links: bool): + data = json.loads(src.read_text()) + items = data['items'] + meta = data['meta'] + missing = [it['cell'] for it in items if it['kind'] == 'text' and 'mlines' not in it] + if missing: + raise SystemExit(f'labels without line geometry: {missing}') + body, linkboxes = emit(items, meta, mode, subscripts=subscripts, links=links) + + colours = '\n'.join(rf'\definecolor{{c{k}}}{{HTML}}{{{k}}}' for k in sorted(COLOUR_NAMES)) + W, H = meta['width'], meta['height'] + bg = '#FDFDFE' if mode == 'light' else '#05070B' + lines = [ + PREAMBLE, + colours, + rf'\definecolor{{pagebg}}{{HTML}}{{{bg.lstrip("#")}}}', + r'\begin{document}', + r'\begin{tikzpicture}[x=1bp, y=-1bp, every node/.style={inner sep=0, outer sep=0}]', + rf'\useasboundingbox (0,0) rectangle ({f(W)},{f(H)});', + *body, + ] + if links: + for lb in linkboxes: + lines.append( + f'\\node[anchor=north west] at ({f(lb["x"])},{f(lb["y"])}) ' + f'{{\\href{{{lb["href"]}}}{{\\phantom{{\\rule{{{f(lb["w"])}bp}}' + f'{{{f(lb["h"])}bp}}}}}}}};' + ) + lines += [r'\end{tikzpicture}', r'\end{document}'] + out.write_text('\n'.join(lines) + '\n') + Path(out.with_suffix('.links.json')).write_text(json.dumps(linkboxes, indent=1)) + print(f'wrote {out} ({len(body)} primitives, {len(linkboxes)} links, mode={mode})') + + +if __name__ == '__main__': + src = Path(sys.argv[1]) + out = Path(sys.argv[2]) + mode = sys.argv[3] if len(sys.argv) > 3 else 'light' + subs = '--no-subscripts' not in sys.argv + links = '--no-links' not in sys.argv + build(src, out, mode, subscripts=subs, links=links) diff --git a/tools/figures/img/arch_dark_img00.png b/tools/figures/img/arch_dark_img00.png new file mode 100644 index 0000000000000000000000000000000000000000..fbd10a8aa395464ede14ea344cd989ef75cb5712 GIT binary patch literal 56235 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
>! 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

9F-1I=qh?eM)!a8xQ=L20@@U^Ta<-$N@=ka(! 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 YclB$_fWoi`A^`rAlTwzf7B>#~AB)P97XSbN literal 0 HcmV?d00001 From fad73eb7193c07407e595a7e74737f80df69b25d Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 26 Jul 2026 20:20:16 +0200 Subject: [PATCH 28/71] Document the accretion module and clear the figure overlaps The accretion module now gets the same treatment as the long-established ones. There is a configuration reference page covering every `[accretion]` parameter and all three implementations, the model description explains what Morrigan does and what its statistical nature costs, and the module appears in the dummy-module table, the configuration guide list, the submodule links and the pin-maintenance table. A placeholder that described accretion as not yet implemented is gone. The branch labels of the decision node sat centred on the very edges they name, so the feedback line and the exit line were drawn through the lettering. Both now sit clear to the right of their line. Every label in both figures is checked against every stroked shape, allowing for paint order so a line passing behind a chip does not count, and neither figure has a line crossing a label. The labels the figure model adds carry their measured width, so an extent means the same thing for every label in it. The module schematic no longer holds a second, invisible copy of the accretion group inside the star icon's own viewport. --- docs/Explanations/model.md | 19 ++- docs/How-to/config.md | 1 + docs/How-to/update_module_pins.md | 13 +- docs/Reference/config/accretion.md | 135 ++++++++++++++++++ docs/Reference/config/observe.md | 9 -- docs/assets/proteus_architecture.svg | 10 +- docs/assets/proteus_architecture_darkmode.svg | 10 +- docs/assets/proteus_modules_schematic.svg | 2 +- .../proteus_modules_schematic_darkmode.svg | 2 +- mkdocs.yml | 2 + tools/figures/arch_final.json | 36 ++--- tools/figures/gen_tikz.py | 7 + 12 files changed, 196 insertions(+), 50 deletions(-) create mode 100644 docs/Reference/config/accretion.md diff --git a/docs/Explanations/model.md b/docs/Explanations/model.md index c4c722fae..e321b9a8a 100644 --- a/docs/Explanations/model.md +++ b/docs/Explanations/model.md @@ -121,11 +121,15 @@ Config section: `[orbit]`. Reference: [Star and orbit configuration](../Referenc ## Accretion: Morrigan -**[Morrigan](https://proteus-framework.org/Morrigan/)** (Python) follows a system of protoplanets through the giant impacts and gravitational scattering by which they accrete, using the semi-analytical Monte Carlo model of [Kimura et al. (2025)](https://doi.org/10.3847/1538-4357/ade992). PROTEUS takes the impact history of one body from that system and applies each collision as it falls due: the impactor's rock grows the planet and the structure is re-solved at the new mass, the impactor's volatiles are delivered while part of the target's atmosphere is stripped, the mantle is re-melted, and the orbit takes the collision's change in semi-major axis and eccentricity. +The accretion module supplies the giant impacts a planet experiences after the disk disperses, and PROTEUS applies each one to the planet it is evolving. -Two further implementations take a history rather than derive one: `timeline` replays a table of impacts read from a file, and `dummy` grows the planet along an analytical accretion curve. With no accretion module selected the impact list is empty and the planet's mass is set only by its initial condition. +**[Morrigan](https://proteus-framework.org/Morrigan/)** (Python) follows a system of planetary embryos through the secular eccentricity oscillations, orbit crossings, scatterings, ejections and giant impacts by which they accrete, until the system settles into a Hill-stable configuration. It implements the semi-analytical Monte Carlo model of Kimura et al. (2025) [^cite-kimura2025], which predicts when a system goes unstable and resolves each instability with prescriptions calibrated against N-body simulations, rather than integrating the orbits. That makes one system cheap enough to run inside a coupled framework, at the cost of any single history being a statistical realisation rather than a trajectory: ensemble statistics are the meaningful comparison. PROTEUS follows one survivor of that system, chosen by the configured selector, and takes its impact history. -Config section: `[accretion]`. Reference: [Elemental delivery and accretion](../How-to/config.md#elemental-delivery-and-accretion). +Each impact is applied at the timestep it falls in. The impactor's rock grows the planet and the interior structure is re-solved at the new mass, so radius, gravity and the core-mantle split follow the growth. The impactor's volatiles are delivered while the same collision fraction strips the target's atmosphere and the impactor's own, so a small impactor striking a heavily-clothed planet can leave it lighter than before. The mantle is re-melted by re-applying the run's temperature-mode initial condition, and the orbit takes the collision's change in semi-major axis and eccentricity, clamped to a bound orbit. + +Two further implementations take an impact history rather than derive one. `timeline` replays a table of impacts from a file, which reproduces a published history or drives PROTEUS from one computed elsewhere. `dummy` builds a history from scaling laws: the planet approaches an asymptotic mass exponentially, impacts are evenly spaced in time, and each delivers the mass the law accretes over its interval, so the increments decay and the largest impact is the first. With no accretion module selected the impact list is empty and the planet's mass is set by its initial condition alone. + +Config section: `[accretion]`. Reference: [Accretion configuration](../Reference/config/accretion.md). ## Synthetic observations: petitRADTRANS @@ -173,9 +177,12 @@ architecture and for quick parameter exploration. | Escape | Constant bulk mass loss rate (user-specified kg/s), distributed proportionally across elements | | Outgassing | Melt-fraction-dependent volatile partitioning with fixed stoichiometry, no equilibrium chemistry | | Orbit | Fixed semi-major axis and eccentricity; configurable parameterised tidal heating | +| Accretion | Exponential approach to an asymptotic mass, delivered as evenly spaced impacts; Noack & Lasbleis (2020) [^cite-noack2020] radii and momentum-conserving mergers | -The [Quick start tutorial](../Tutorials/quick_start_dummy.md) runs PROTEUS -with all modules set to dummy. +The [Quick start tutorial](../Tutorials/quick_start_dummy.md) runs PROTEUS with +every physics module set to dummy. Accretion is left out of that configuration, +so the planet keeps the mass its initial condition gives it; add an +`[accretion]` section with `module = "dummy"` to let it grow. --- @@ -224,3 +231,5 @@ Only the interior and star modules have an explicit notion of time-evolution. Al [^cite-baraffe2015]: Baraffe, I., Homeier, D., Allard, F. & Chabrier, G., *[New evolutionary models for pre-main sequence and main sequence low-mass stars down to the hydrogen-burning limit](https://doi.org/10.1051/0004-6361/201425481)*, Astronomy & Astrophysics, 577, A42, 2015. [SciX](https://scixplorer.org/abs/2015A%26A...577A..42B/abstract). [^cite-noack2020]: Noack, L. & Lasbleis, M., *[Parameterisations of interior properties of rocky planets](https://doi.org/10.1051/0004-6361/202037723)*, Astronomy & Astrophysics, 638, A129, 2020. [SciX](https://scixplorer.org/abs/2020A%26A...638A.129N/abstract). + + [^cite-kimura2025]: Kimura, T., Hoshino, H., Kokubo, E., Matsumoto, Y. & Ikoma, M., *[Semi-analytical model for the dynamical evolution of planetary systems via giant impacts](https://doi.org/10.3847/1538-4357/ade992)*, The Astrophysical Journal, 989, 109, 2025. diff --git a/docs/How-to/config.md b/docs/How-to/config.md index a5b5bfffa..f062972f5 100644 --- a/docs/How-to/config.md +++ b/docs/How-to/config.md @@ -10,6 +10,7 @@ For topic-specific parameter guides, see the **configuration reference** pages: - [Interior structure and energetics](../Reference/config/interior.md) - [Atmosphere and chemistry](../Reference/config/atmosphere.md) - [Escape and outgassing](../Reference/config/escape_outgas.md) +- [Accretion](../Reference/config/accretion.md) - [Synthetic observations](../Reference/config/observe.md) For worked examples, see the [Tutorials](../Tutorials/quick_start_dummy.md). diff --git a/docs/How-to/update_module_pins.md b/docs/How-to/update_module_pins.md index 9a1fbdaf8..a2f4ec305 100644 --- a/docs/How-to/update_module_pins.md +++ b/docs/How-to/update_module_pins.md @@ -18,7 +18,7 @@ module is distributed. | Pin type | Where it lives in `pyproject.toml` | Pin value | Modules | |----------|------------------------------------|-----------|---------| | PyPI floor | `[project] dependencies` | Minimum version bound, e.g. `fwl-aragog>=26.05.13` | fwl-janus, fwl-mors, fwl-calliope, fwl-zephyrus, fwl-aragog, fwl-zalmoxis | -| PyPI floor (optional) | `[project.optional-dependencies]` | Minimum version bound on an optional backend | fwl-vulcan, atmodeller | +| PyPI floor (optional) | `[project.optional-dependencies]` | Minimum version bound on an optional backend | fwl-vulcan, atmodeller, fwl-morrigan | | Git ref | `[tool.proteus.modules.]` | Exact commit SHA, tag, or branch in a `ref` field | AGNI, SOCRATES, SPIDER, BOREAS, LovePy | A third entry, PETSc, is pinned in `[tool.proteus.modules.petsc]` by the SHA-256 @@ -169,11 +169,12 @@ When in doubt, pin to a commit SHA. A branch pin is a deliberate choice to follow upstream, not a default. !!! info "Some modules deliberately have no git entry" - VULCAN, like fwl-aragog and fwl-zalmoxis, is a single-source PyPI package, so - it is pinned only by its floor in `[project.optional-dependencies]`. Its - setup script checks out the git tag matching that floor, so the editable - checkout and the published release cannot diverge. Do not add a second pin - for these in `[tool.proteus.modules]`. + VULCAN and Morrigan, like fwl-aragog and fwl-zalmoxis, are single-source + PyPI packages, so they are pinned only by their floor in + `[project.optional-dependencies]`. Their setup scripts check out the git tag + matching that floor, so the editable checkout and the published release + cannot diverge. Do not add a second pin for these in + `[tool.proteus.modules]`. ## Propagating the change to other developers diff --git a/docs/Reference/config/accretion.md b/docs/Reference/config/accretion.md new file mode 100644 index 000000000..c8947a38a --- /dev/null +++ b/docs/Reference/config/accretion.md @@ -0,0 +1,135 @@ +# Accretion + +The `[accretion]` section configures protoplanet growth by giant impacts: which +model supplies the impact history, what each impactor carries, and how much +atmosphere a collision removes. + +Submodule documentation: +[Morrigan](https://proteus-framework.org/Morrigan/). +See also [Model description](../../Explanations/model.md#accretion-morrigan) +and the [coupling loop](../../Explanations/coupling_loop.md#execution-order-per-iteration). + +## Accretion `[accretion]` + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `module` | str or none | `"none"` | Accretion module: `morrigan` (dynamical model), `timeline` (replay a file), `dummy` (analytical growth), `none` (disabled) | +| `time_offset` | float | `0.0` | Offset applied to every impact time when mapping the timeline onto the PROTEUS time axis \[yr]. Impacts landing at or before the start of the run are discarded with a warning | +| `impactor_volatiles` | str | `"dry"` | Volatile content of each impactor: `dry` (rock and iron only), `match_planet` (the planet's own initial fractional abundances, scaled to the impactor mass), `ppmw` (the per-element budgets below) | +| `impactor_H_ppmw` | float | `0.0` | Hydrogen carried by each impactor \[ppmw of impactor mass] | +| `impactor_C_ppmw` | float | `0.0` | Carbon carried by each impactor \[ppmw of impactor mass] | +| `impactor_N_ppmw` | float | `0.0` | Nitrogen carried by each impactor \[ppmw of impactor mass] | +| `impactor_S_ppmw` | float | `0.0` | Sulfur carried by each impactor \[ppmw of impactor mass] | +| `impactor_O_ppmw` | float | `0.0` | Oxygen carried by each impactor \[ppmw of impactor mass] | +| `atmloss_module` | str or none | `"none"` | Impact atmosphere loss: `constant` (the fixed fraction below), `zephyrus` (the giant-impact erosion scaling of Kegerreis et al. 2020 [^cite-kegerreis2020]), `none` (no loss) | +| `atmloss_frac` | float | `0.0` | Fraction of the atmosphere each impact removes when `atmloss_module = "constant"` \[0, 1] | + +One loss fraction governs both bodies at each impact: the target loses that +fraction of its atmosphere, and a volatile-bearing impactor loses the same +fraction of its atmospheric part and delivers the remainder. PROTEUS ships no +impact-loss physics of its own. + +The mantle re-melt after an impact is a thermodynamic reset rather than an +energy deposition: it re-applies the run's `planet.temperature_mode` initial +condition to the whole mantle, so how molten the result is follows that +condition. Only `liquidus_super` is fully molten for any planet mass and melting +curve. + +### Morrigan `[accretion.morrigan]` + +Evolves a system of embryos after disk dispersal with the semi-analytical Monte +Carlo model of Kimura et al. (2025) [^cite-kimura2025] and reports the impacts +experienced by one selected survivor. The host star mass is taken from +`star.mass`, so the dynamical model and the rest of PROTEUS cannot disagree +about it. + +!!! note + Morrigan is an optional module and is not installed with PROTEUS by + default. Install it with `pip install "fwl-proteus[morrigan]"` before + setting `accretion.module = "morrigan"`, or as an editable checkout with + `tools/get_morrigan.sh`. See + [Installation: optional modules](../../How-to/optionalmodules_installation.md). + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `seed` | int | `1` | Random seed for the Monte Carlo. Fixing it makes a history reproducible; sweeping it samples the outcome distribution | +| `num_planets` | int | `10` | Number of embryos the system starts with | +| `masses` | list of float | `[]` | Initial embryo masses \[M$_\oplus$], one per embryo. Empty starts every embryo at `mass_equal` | +| `mass_equal` | float | `0.5` | Initial mass of every embryo \[M$_\oplus$], used when `masses` is empty | +| `eccentricity_init` | float | `0.01` | Initial eccentricity shared by all embryos | +| `inner_edge` | float | `0.1` | Semi-major axis of the innermost embryo \[AU] | +| `spacing` | float | `10.0` | Initial separation between adjacent embryos, in mutual Hill radii | +| `density` | float | `5500.0` | Uniform bulk density used to convert embryo mass to radius \[kg m$^{-3}$] | +| `impact_angle` | float | `45.0` | Impact angle \[deg]; the impact parameter is its sine | +| `evolution_time` | float | `1.0` | Duration of the dynamical evolution \[Gyr] | +| `inner_cutoff` | float | `0.005` | Perihelion inside which an embryo counts as lost to the star \[AU] | +| `selector` | str | `"match_config"` | Which survivor's history to follow: `match_config` (closest initial mass and orbit to the PROTEUS configuration), `mass` (most massive), `semimajoraxis` (final orbit nearest `selector_value`), `id` (embryo index `selector_value`) | +| `selector_value` | float or none | `none` | Target value for the `semimajoraxis` and `id` selectors; ignored otherwise | + +Typical `spacing` values are 5 to 15 mutual Hill radii; beyond roughly 30 the +system does not go unstable within a useful evolution time and the run finishes +with no impacts. The 50 accepted here only catches an order-of-magnitude +mistake at configuration load: the layout condition the dynamical model applies +depends on the embryo masses and the host mass, and it refuses a layout that is +too wide by name. + +### Timeline `[accretion.timeline]` + +Applies a pre-written sequence of impacts instead of deriving one. Every impact +consequence is computed exactly as for a model-derived history, so this +reproduces a published impact history, drives PROTEUS from a history computed +elsewhere, or applies a hand-written sequence for a controlled experiment. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `timeline_path` | str or none | `none` | Path to the impact timeline file. Environment variables and `~` are expanded | + +### Dummy accretion `[accretion.dummy]` + +Builds an accretion history from scaling laws rather than by integrating a +system of embryos. The planet approaches an asymptotic mass exponentially, +impacts are placed at evenly spaced times, and each delivers the mass the law +accretes over its interval, so the increments decay with time and the largest +impact is the first. Radii follow the Noack & Lasbleis (2020) mass-radius +scaling [^cite-noack2020], collision velocities combine the pair's mutual escape +velocity with an encounter velocity set by `eccentricity`, and each merged orbit +follows from conserving linear momentum. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `mass_accreted` | float | `0.1` | Total mass delivered over the whole timeline \[M$_\oplus$]; the increments are scaled to sum to exactly this | +| `num_impacts` | int | `3` | Number of impacts in the timeline | +| `timescale` | float | `1.0e6` | E-folding time of the accretion law \[yr]. Short compared with `time_last` concentrates the mass in the first impacts; long spreads it evenly | +| `time_last` | float | `5.0e6` | Time of the final impact \[yr]. Impacts are spaced evenly from `time_last / num_impacts` up to this time | +| `eccentricity` | float | `0.05` | Encounter eccentricity \[1], setting both the approach velocity added to the mutual escape velocity and the impactor's orbit | +| `impact_parameter` | float | `0.5` | Impact parameter of every collision \[1], the sine of the impact angle. Zero is head-on, one is grazing | + +`timeline_path` is accepted here but is not a parameter of this module: setting +it is refused at configuration load, because it asks to replay a file and would +otherwise be served a generated timeline at default settings. To replay a file, +set `accretion.module = "timeline"` and put the path in +`accretion.timeline.timeline_path`. + +## Example + +```toml +[accretion] + module = "morrigan" + impactor_volatiles = "match_planet" + atmloss_module = "zephyrus" + + [accretion.morrigan] + seed = 1 + num_planets = 10 + mass_equal = 0.5 + inner_edge = 0.1 + spacing = 10.0 + evolution_time = 1.0 + selector = "match_config" +``` + + [^cite-kimura2025]: Kimura, T., Hoshino, H., Kokubo, E., Matsumoto, Y. & Ikoma, M., *[Semi-analytical model for the dynamical evolution of planetary systems via giant impacts](https://doi.org/10.3847/1538-4357/ade992)*, The Astrophysical Journal, 989, 109, 2025. + + [^cite-kegerreis2020]: Kegerreis, J.A., Eke, V.R., Catling, D.C., Massey, R.J., Teodoro, L.F.A. & Zahnle, K.J., *[Atmospheric erosion by giant impacts onto terrestrial planets: a scaling law for any speed, angle, mass, and density](https://doi.org/10.3847/2041-8213/abb5fb)*, The Astrophysical Journal Letters, 901, L31, 2020. + + [^cite-noack2020]: Noack, L. & Lasbleis, M., *[Parameterisations of interior properties of rocky planets](https://doi.org/10.1051/0004-6361/202037723)*, Astronomy & Astrophysics, 638, A129, 2020. [SciX](https://scixplorer.org/abs/2020A%26A...638A.129N/abstract). diff --git a/docs/Reference/config/observe.md b/docs/Reference/config/observe.md index ce1e1eeb0..f9f80a9e1 100644 --- a/docs/Reference/config/observe.md +++ b/docs/Reference/config/observe.md @@ -4,9 +4,6 @@ The `[observe]` section configures synthetic observation generation. PROTEUS can compute transit and eclipse depth spectra from the simulated atmospheric state using the petitRADTRANS forward model. -The `[accretion]` section is reserved for late accretion modelling (not yet -implemented). - ## Synthetic observations `[observe]` | Parameter | Type | Default | Description | @@ -107,12 +104,6 @@ See [Output format](../../Reference/output.md) for the CSV column layout. | `include_cia` | bool | `true` | Include collision-induced absorption contributions | | `silent` | bool | `false` | Suppress petitRADTRANS stdout/stderr during `Radtrans` initialization | -## Late accretion `[accretion]` - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `module` | str or none | `none` | Late accretion module (reserved for future implementation) | - --- **See also:** [Model description](../../Explanations/model.md) | [Output format](../../Reference/output.md) | [Postprocessing](../../How-to/usage_postprocessing.md) diff --git a/docs/assets/proteus_architecture.svg b/docs/assets/proteus_architecture.svg index 43bd7c101..0eefc5a97 100644 --- a/docs/assets/proteus_architecture.svg +++ b/docs/assets/proteus_architecture.svg @@ -1225,17 +1225,17 @@ - + - - + + - - + + diff --git a/docs/assets/proteus_architecture_darkmode.svg b/docs/assets/proteus_architecture_darkmode.svg index c4316a291..37caa9748 100644 --- a/docs/assets/proteus_architecture_darkmode.svg +++ b/docs/assets/proteus_architecture_darkmode.svg @@ -1225,17 +1225,17 @@ - + - - + + - - + + diff --git a/docs/assets/proteus_modules_schematic.svg b/docs/assets/proteus_modules_schematic.svg index 9c489ca5b..6de50080f 100644 --- a/docs/assets/proteus_modules_schematic.svg +++ b/docs/assets/proteus_modules_schematic.svg @@ -4,4 +4,4 @@ -Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).MorriganFatmFbolFXUVFMOFCMBAtmosphere:climateEscapeA single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.AGNIIn-/outgassingAn outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.CALLIOPEStarCode that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.MORSTidesSolid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.LovePyAtmosphericescapeCode for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.ZEPHYRUSIn-&outgassingTidalheatingInteriorAtmosphere:chemistryPhotochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.VULCANGas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.FastChemModel using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. AtmodellerA 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.JANUSModel that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.ObliquamodulesCHNOSvolatilesPROTEUSmodulegroupLayerinteractionEnergyfluxAtmosphere:radiationA radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.SOCRATESAn interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.ZalmoxisA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.AragogA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.SPIDERStructureEnergeticsProtoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan \ No newline at end of file +FatmFbolFXUVFMOFCMBAtmosphere:climateEscapeA single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.AGNIIn-/outgassingAn outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.CALLIOPEStarCode that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.MORSTidesSolid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.LovePyAtmosphericescapeCode for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.ZEPHYRUSIn-&outgassingTidalheatingInteriorAtmosphere:chemistryPhotochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.VULCANGas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.FastChemModel using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. AtmodellerA 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.JANUSModel that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.ObliquamodulesCHNOSvolatilesPROTEUSmodulegroupLayerinteractionEnergyflux</rect></g><g><g><title /><g><text x="37.062" y="113.719" font-family="Arial, Helvetica, Arial, sans-serif" font-size="11" font-weight="bold" fill="rgb(46, 74, 94)" xml:space="preserve">Atmosphere</text><text x="101.250" y="113.719" font-family="Arial, Helvetica, Arial, sans-serif" font-size="11" font-weight="bold" fill="rgb(46, 74, 94)" xml:space="preserve">:</text><text x="47.766" y="128.109" font-family="Arial, Helvetica, Arial, sans-serif" font-size="11" font-weight="bold" fill="rgb(46, 74, 94)" xml:space="preserve">radiation</text></g></g></g></g><g data-cell-id="oeN_MS_A32iLY2RM6xaN-10"><a xlink:href="https://proteus-framework.org/SOCRATES/index.html" target="_blank"><g transform="translate(0.5,0.5)"><rect x="40.37" y="136.38" width="60.88" height="20" rx="3" ry="3" fill="#FDFDFE" style="fill: rgb(253, 253, 254); stroke: rgb(46, 74, 94);" stroke="#2E4A5E" pointer-events="all"><title>A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.SOCRATESAn interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.ZalmoxisA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.AragogA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.SPIDERStructureEnergeticsProtoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan \ No newline at end of file diff --git a/docs/assets/proteus_modules_schematic_darkmode.svg b/docs/assets/proteus_modules_schematic_darkmode.svg index 301a6b6ed..e1ed67ed2 100644 --- a/docs/assets/proteus_modules_schematic_darkmode.svg +++ b/docs/assets/proteus_modules_schematic_darkmode.svg @@ -4,4 +4,4 @@ -Protoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).MorriganFatmFbolFXUVFMOFCMBAtmosphere:climateEscapeA single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.AGNIIn-/outgassingAn outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.CALLIOPEStarCode that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.MORSTidesSolid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.LovePyAtmosphericescapeCode for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.ZEPHYRUSIn-&outgassingTidalheatingInteriorAtmosphere:chemistryPhotochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.VULCANGas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.FastChemModel using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. AtmodellerA 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.JANUSModel that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.ObliquamodulesCHNOSvolatilesPROTEUSmodulegroupLayerinteractionEnergyfluxAtmosphere:radiationA radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.SOCRATESAn interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.ZalmoxisA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.AragogA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.SPIDERStructureEnergeticsProtoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan \ No newline at end of file +FatmFbolFXUVFMOFCMBAtmosphere:climateEscapeA single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.A single-column convective-radiative model of rocky-planet and magma-ocean atmospheres.AGNIIn-/outgassingAn outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.An outgassing code that solves for equilibrium between a partially molten mantle and an overlying gas-phase atmosphere.CALLIOPEStarCode that models stellar rotation and XUV evolution.Code that models stellar rotation and XUV evolution.MORSTidesSolid phase tidal heating model for planet interiors.Solid phase tidal heating model for planet interiors.LovePyAtmosphericescapeCode for computing the atmospheric escape on (exo)planets.Code for computing the atmospheric escape on (exo)planets.ZEPHYRUSIn-&outgassingTidalheatingInteriorAtmosphere:chemistryPhotochemical kinetics code for planetary atmospheres.Photochemical kinetics code for planetary atmospheres.VULCANGas-phase chemical equilibrium composition code of systems such as planetary atmospheres.Gas-phase chemical equilibrium composition code of systems such as planetary atmospheres.FastChemModel using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. Model using JAX to compute the partitioning of volatiles between a planetary atmosphere and its rocky interior. AtmodellerA 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.A 1D prescribed convective atmosphere model for rocky exoplanet and magma ocean atmospheres.JANUSModel that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.Model that calculates the tidal deformation of solid, partially-solid, and liquid planetary mantles.ObliquamodulesCHNOSvolatilesPROTEUSmodulegroupLayerinteractionEnergyflux</rect></g><g><g><title /><g><text x="37.062" y="113.719" font-family="Arial, Helvetica, Arial, sans-serif" font-size="11" font-weight="bold" fill="rgb(201, 218, 230)" xml:space="preserve">Atmosphere</text><text x="101.250" y="113.719" font-family="Arial, Helvetica, Arial, sans-serif" font-size="11" font-weight="bold" fill="rgb(201, 218, 230)" xml:space="preserve">:</text><text x="47.766" y="128.109" font-family="Arial, Helvetica, Arial, sans-serif" font-size="11" font-weight="bold" fill="rgb(201, 218, 230)" xml:space="preserve">radiation</text></g></g></g></g><g data-cell-id="oeN_MS_A32iLY2RM6xaN-10"><a xlink:href="https://proteus-framework.org/SOCRATES/index.html" target="_blank"><g transform="translate(0.5,0.5)"><rect x="40.37" y="136.38" width="60.88" height="20" rx="3" ry="3" fill="#FDFDFE" style="fill: var(--ge-adaptive-bg, #FDFDFE); stroke: rgb(201, 218, 230);" stroke="#2E4A5E" pointer-events="all"><title>A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.A radiative transfer code for computing fluxes, heating rates, and radiances in planetary atmospheres.SOCRATESAn interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.An interior structure solver and tool for mass-radius modelling of exoplanets, resolving planets from centre to surface.ZalmoxisA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in Python.AragogA one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.A one-dimensional, two-phase, spherically symmetric interior dynamics solver for rocky (exo)planets written in C.SPIDERStructureEnergeticsProtoplanet accretion via giant impacts (Kimura et al. 2025).AccretionProtoplanet accretion via giant impacts (Kimura et al. 2025).Morrigan \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 1e68e96b5..792569a05 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,6 +64,7 @@ nav: - Interior structure and energetics: Reference/config/interior.md - Atmosphere and chemistry: Reference/config/atmosphere.md - Escape and outgassing: Reference/config/escape_outgas.md + - Accretion: Reference/config/accretion.md - Observations: Reference/config/observe.md - Melting curves: Reference/melting_curves.md - Module versions: Reference/module_versions.md @@ -112,6 +113,7 @@ nav: - Obliqua: https://proteus-framework.org/Obliqua/ - VULCAN: https://proteus-framework.org/VULCAN/ - Zalmoxis: https://proteus-framework.org/Zalmoxis/ + - Morrigan: https://proteus-framework.org/Morrigan/ - Aragog: https://proteus-framework.org/aragog/ - SPIDER: https://proteus-framework.org/SPIDER/ - Atmodeller: https://atmodeller.readthedocs.io/en/latest/ diff --git a/tools/figures/arch_final.json b/tools/figures/arch_final.json index 8758a129e..dfd4fd0ef 100644 --- a/tools/figures/arch_final.json +++ b/tools/figures/arch_final.json @@ -3476,8 +3476,8 @@ { "top": 1113.53125, "bottom": 1128.53125, - "left": 801.65625, - "right": 823.359375, + "left": 816.45625, + "right": 838.159375, "text": "Yes", "size": 13.3, "weight": "400", @@ -3545,8 +3545,8 @@ { "top": 974.53125, "bottom": 989.53125, - "left": 709, - "right": 726.015625, + "left": 721.5, + "right": 738.515625, "text": "No", "size": 13.3, "weight": "400", @@ -4868,8 +4868,8 @@ { "top": 275.7, "bottom": 291.3, - "left": 767.0, - "right": 856.0, + "left": 784.3, + "right": 838.7, "text": "Accretion", "size": 13.0, "color": "#FDFDFE" @@ -4927,8 +4927,8 @@ { "top": 329.0, "bottom": 341.0, - "left": 729.0, - "right": 894.0, + "left": 775.3, + "right": 847.7, "text": "M_planet, R_int, a, e", "size": 10.0, "color": "#3E4A55" @@ -4990,8 +4990,8 @@ { "top": 400.5, "bottom": 416.1, - "left": 416.625, - "right": 482.375, + "left": 423.05, + "right": 475.95, "text": "accretion", "size": 13.0, "color": "#FDFDFE" @@ -4999,8 +4999,8 @@ { "top": 416.09, "bottom": 431.69, - "left": 416.625, - "right": 482.375, + "left": 421.65, + "right": 477.35, "text": "(wrapper)", "size": 13.0, "color": "#FDFDFE" @@ -5038,8 +5038,8 @@ { "top": 483.703125, "bottom": 499.303125, - "left": 294.9921875, - "right": 354.9921875, + "left": 299.1421875, + "right": 350.8421875, "text": "Morrigan", "size": 13.0, "color": "#FDFDFE" @@ -5077,8 +5077,8 @@ { "top": 483.703125, "bottom": 499.303125, - "left": 419.0, - "right": 479.0, + "left": 424.35, + "right": 473.65, "text": "Timeline", "size": 13.0, "color": "#FDFDFE" @@ -5116,8 +5116,8 @@ { "top": 483.703125, "bottom": 499.303125, - "left": 535.0, - "right": 595.0, + "left": 542.65, + "right": 587.35, "text": "Dummy", "size": 13.0, "color": "#FDFDFE" diff --git a/tools/figures/gen_tikz.py b/tools/figures/gen_tikz.py index 4d22587a6..59040d2d7 100644 --- a/tools/figures/gen_tikz.py +++ b/tools/figures/gen_tikz.py @@ -288,6 +288,13 @@ def surfaces(items, mode: str): def background_at(surfs, x: float, y: float, mode: str) -> str: + """Colour of the surface under a point. + + Shapes are compared by their bounding box and a translucent fill is blended + against the page rather than against whatever is stacked beneath it. Both + hold for this figure, where every label sits well inside its own chip and + the one translucent shape sits on the page. + """ bg = PAGE_BG[mode] for x0, y0, x1, y1, col in surfs: if x0 <= x <= x1 and y0 <= y <= y1: From f6a822a43781efd2c3c10a8b73564c93b8bb3ed8 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 26 Jul 2026 23:33:23 +0200 Subject: [PATCH 29/71] Require the Morrigan release that ships the coupling documentation The accretion docs now live in the Morrigan documentation itself, and the tutorial figures are drawn against that release, so the floor moves to the release that carries them. --- docs/Reference/module_versions.md | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Reference/module_versions.md b/docs/Reference/module_versions.md index bc16a5929..dd9740d8e 100644 --- a/docs/Reference/module_versions.md +++ b/docs/Reference/module_versions.md @@ -48,7 +48,7 @@ pinned commit. | LovePy | Multi-phase tidal heating (Julia) | [![LovePy](https://img.shields.io/badge/LovePy-main-lightgrey)](https://github.com/nichollsh/LovePy){target="_blank" rel="noopener"} | [GitHub](https://github.com/nichollsh/LovePy) | | atmodeller | Alternative outgassing backend (GPL-3.0) | [![atmodeller](https://img.shields.io/badge/atmodeller-%3E%3D1.0.2-blue)](https://pypi.org/project/atmodeller/1.0.2/){target="_blank" rel="noopener"} | [GitHub](https://github.com/djbower/atmodeller) | | VULCAN | Atmospheric chemistry (GPL-3.0) | [![VULCAN](https://img.shields.io/badge/VULCAN-%3E%3D26.04.22-blue)](https://pypi.org/project/fwl-vulcan/26.04.22/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/VULCAN) | -| Morrigan | Protoplanet accretion via giant impacts | [![Morrigan](https://img.shields.io/badge/Morrigan-%3E%3D26.07.26-blue)](https://pypi.org/project/fwl-morrigan/26.07.26/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/Morrigan) | +| Morrigan | Protoplanet accretion via giant impacts | [![Morrigan](https://img.shields.io/badge/Morrigan-%3E%3D26.07.27-blue)](https://pypi.org/project/fwl-morrigan/26.07.27/){target="_blank" rel="noopener"} | [GitHub](https://github.com/FormingWorlds/Morrigan) | | Obliqua | Orbital evolution and tides (Julia) | n/a | [GitHub](https://github.com/FormingWorlds/Obliqua) | diff --git a/pyproject.toml b/pyproject.toml index 560bb9995..6aa54343b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,7 +121,7 @@ vulcan = ["fwl-vulcan>=26.04.22"] # accretion.module = "morrigan". Supplies the giant-impact timeline the # accretion coupling replays. Also installable editable via # tools/get_morrigan.sh. -morrigan = ["fwl-morrigan>=26.07.26"] +morrigan = ["fwl-morrigan>=26.07.27"] develop = [ # coverage[toml] enables standalone coverage tool with TOML config support (used by ratcheting script) From eb74032894ddbaf59e90bbd80afecb184061996b Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Mon, 27 Jul 2026 11:52:47 +0200 Subject: [PATCH 30/71] Drop redundant local imports from the accretion and interior tests Several test functions imported logging and init_accretion inside the function body even though both are already imported at module scope, so those statements did nothing. One test also imported proteus.accretion.wrapper under an alias purely to reach a patch target, which made it patch remelt_mantle through the module object while the three sibling patches in the same function used a dotted path. That test now imports apply_impact directly and patches remelt_mantle by dotted path like the targets around it, so the alias is no longer needed. Behaviour is unchanged: both files pass in full, and the unit tier is unchanged at 2839 passed and 11 skipped. --- tests/accretion/test_wrapper.py | 24 +++++------------------ tests/interior_energetics/test_wrapper.py | 6 ------ 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 491b1e77b..61c39314e 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -1212,8 +1212,6 @@ def test_zephyrus_loss_module_warns_outside_the_thin_atmosphere_regime(caplog): volatile-rich run cannot silently consume extrapolated fractions. The fraction is still returned in both cases. """ - import logging - import numpy as np pytest.importorskip('zephyrus.collision') @@ -1428,7 +1426,7 @@ def test_the_impact_leaves_the_planet_mass_consistent_with_its_parts(monkeypatch deliberately stale M_planet, so a handler that failed to refresh it would keep that value and fail here. """ - import proteus.accretion.wrapper as accretion_wrapper + from proteus.accretion.wrapper import apply_impact handler = _impact_handler( accretion=_impact_accretion(impactor_volatiles='ppmw', H_ppmw=1000.0) @@ -1443,7 +1441,9 @@ def _solve(dirs, config, hf_all, hf_row, output): hf_row['M_ele'] = 9.9e21 hf_row['M_planet'] = hf_row['M_int'] + 9.9e21 - monkeypatch.setattr(accretion_wrapper, 'remelt_mantle', lambda *a, **k: None, raising=False) + monkeypatch.setattr( + 'proteus.accretion.wrapper.remelt_mantle', lambda *a, **k: None, raising=False + ) monkeypatch.setattr( 'proteus.interior_energetics.wrapper.solve_structure', _solve, raising=False ) @@ -1451,7 +1451,7 @@ def _solve(dirs, config, hf_all, hf_row, output): 'proteus.interior_energetics.wrapper.remelt_mantle', lambda *a, **k: None, raising=False ) - accretion_wrapper.apply_impact(handler, _impact_event()) + apply_impact(handler, _impact_event()) hf_row = handler.hf_row assert hf_row['M_planet'] == pytest.approx(hf_row['M_int'] + hf_row['M_ele'], rel=1e-12) @@ -1559,8 +1559,6 @@ def test_a_resumed_run_replays_the_timeline_the_first_session_resolved(tmp_path) test makes the module raise if it is consulted at all on the resume, so a fallback to re-deriving would fail rather than pass by coincidence. """ - from proteus.accretion.wrapper import init_accretion - handler = _handler( module='timeline', timeline_path=_timeline_file(tmp_path / 't.csv'), @@ -1591,8 +1589,6 @@ def test_the_recorded_timeline_is_not_offset_a_second_time(tmp_path): on resume would move every impact by that amount again. A non-zero offset makes the double application unmissable: it would double the shift. """ - from proteus.accretion.wrapper import init_accretion - offset = 3.0e5 handler = _handler( module='timeline', @@ -1628,10 +1624,6 @@ def test_a_temperature_mode_without_a_molten_guarantee_is_flagged(tmp_path, capl as guarantees is what lets a run apply an impact that melts nothing and report it as a re-melt. """ - import logging - - from proteus.accretion.wrapper import init_accretion - path = _timeline_file(tmp_path / 't.csv') for mode in ('adiabatic_from_cmb', 'accretion', 'isothermal'): @@ -1687,10 +1679,6 @@ def test_a_resumed_run_does_not_advise_changing_the_time_offset(tmp_path, caplog from the ledger, so repeating the fresh-run advice would tell a user to bring them back and accrete them a second time. """ - import logging - - from proteus.accretion.wrapper import init_accretion - path = _timeline_file(tmp_path / 't.csv') # Fresh run starting after the first impact: the advice is correct there. @@ -1731,8 +1719,6 @@ def test_the_impact_eccentricity_is_clamped_to_a_bound_orbit(monkeypatch, caplog itself, because absorbing it in silence is how a compounding drift in the applied change would hide for a whole run. """ - import logging - from proteus.accretion.wrapper import _ECC_MAX, apply_impact monkeypatch.setattr( diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index 2961e1511..c3f4b6a9e 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -5904,8 +5904,6 @@ def test_aragog_remelt_without_a_prior_profile_warns_and_books_nothing(caplog): booking is left at zero with a warning, rather than inventing a value or failing the impact. """ - import logging - molten = np.full(6, 3900.0) solver = _FakeAragogSolver(cooled_profile=np.full(6, 2400.0)) interior_o = SimpleNamespace(aragog_solver=solver, _last_entropy=None, impact_reset=False) @@ -5968,8 +5966,6 @@ def test_a_remelt_that_would_cool_the_mantle_books_nothing(caplog): rho*T and those weightings disagree with depth: a profile that rises on average can still integrate to a loss. """ - import logging - cooled = np.full(6, 3900.0) # already hotter than the IC below solver = _FakeAragogSolver(cooled_profile=cooled) interior_o = SimpleNamespace( @@ -6059,8 +6055,6 @@ def test_the_remelt_injection_is_weighed_against_the_impact_energy(caplog): means the mantle, not the impact, set the thermal response, and that has to be visible rather than left implicit in a log line nobody reads. """ - import logging - from proteus.accretion.common import ImpactEvent cooled = np.full(6, 2400.0) From 6e4a1910e17dcb46c90cfcf43958f23cba1d2a27 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Tue, 28 Jul 2026 22:29:54 +0200 Subject: [PATCH 31/71] Keep a resumed accretion run consistent with the impacts it applied Two things could leave a resumed run in a state the uninterrupted run never passes through, both of them where the giant-impact coupling meets the resume path. The solidification latch was rebuilt by searching the whole stored melt-fraction history, on the basis that nothing ever clears it. A giant impact does clear it, because it re-melts the mantle to a magma ocean, so a run that crystallised, took an impact and carried on molten came back with outgassing frozen for the rest of the run. The search now starts after the last impact, and skips the impact's own row because that records the melt fraction from before the re-melt. Runs without accretion carry no accreted rock and search the whole history exactly as before. The interior writes its snapshot while a step is solved, which is before the impacts falling in that step are applied at the end of it. A step that did both left a snapshot of the mantle from before the re-melt beside a helpfile row already carrying the impact's mass, orbit and volatile budgets, and resuming there restored a mantle the impact had melted while treating the impact as already applied. That snapshot is now discarded, so the resume walks back to the previous complete pair and applies the impact again in full. It is only discarded when an older one survives it: removing the last one would leave the run with no interior state at all, so that case is reported and the snapshot kept. The scalar interiors are unaffected, since they carry their state in the helpfile row, which is already post-impact, and SPIDER is refused for accretion runs before the first impact. --- src/proteus/accretion/wrapper.py | 64 ++++++++++++++ src/proteus/interior_energetics/aragog.py | 55 ++++++++++++ src/proteus/proteus.py | 47 +++++++--- tests/accretion/test_wrapper.py | 100 +++++++++++++++++++++- tests/interior_energetics/test_aragog.py | 41 +++++++++ tests/test_proteus.py | 86 +++++++++++++++++++ 6 files changed, 379 insertions(+), 14 deletions(-) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index fea255596..bd620b482 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -196,6 +196,70 @@ def restore_accretion_state(handler: Proteus) -> None: ) +def discard_preimpact_snapshot(handler: Proteus) -> None: + """Drop the interior snapshot a step wrote before an impact re-melted it. + + The interior writes its snapshot while the step is solved, which is before + the impacts falling in that step are applied at the end of it. When a step + both writes a snapshot and lands an impact, the snapshot therefore holds + the mantle from before the re-melt while the helpfile row it shares a time + with already carries the impact's mass, orbit and volatile budgets. + Resuming from that pair would restore a mantle the impact had melted while + treating the impact as already applied, so the re-melt would be lost with + nothing to signal it. + + Removing the snapshot leaves the row without a complete pair, so + :func:`proteus.utils.coupler.select_resumable_snapshot` walks back to the + last step that has one and truncates the helpfile to it. The impact then + falls after the resume point and is applied again in full. The cost is the + steps between the two snapshots, which are recomputed. + + The snapshot is only removed when an older one survives it. Removing the + last one would leave a run with no interior state on disk at all: a resume + would find no complete pair and refuse, and the run's own interior history + would end at the impact. That case is reported instead, since the snapshot + it keeps describes the mantle from before the re-melt and a resume from it + would carry that inconsistency. + + Only the interior modules that write a snapshot need this. The dummy and + boundary interiors carry their state in the helpfile row itself, which is + already post-impact, and SPIDER has no re-melt path and is refused for + accretion runs before the first impact. + + Parameters + ---------- + handler : Proteus + Proteus object instance, read for the output directory, the interior + module and the current time. + """ + if handler.config.interior_energetics.module != 'aragog': + return + + from proteus.interior_energetics.aragog import ( + discard_snapshot, + earlier_snapshot_exists, + ) + + output = handler.directories['output'] + time = float(handler.hf_row['Time']) + + if not earlier_snapshot_exists(output, time): + log.warning( + ' the interior snapshot at %.4e yr predates this step re-melt and ' + 'is the only one on disk, so it is kept: resuming from it would start ' + 'from a mantle this impact had already melted', + time, + ) + return + + if discard_snapshot(output, time): + log.info( + ' discarded the interior snapshot at %.4e yr: it predates this ' + "step's re-melt, so a resume continues from the previous one", + time, + ) + + def apply_impact(handler: Proteus, event: ImpactEvent) -> None: """Apply one giant impact's consequences to the running planet. diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index c4102c52e..9b0e40bc3 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -2420,6 +2420,61 @@ def _add(name, data, dim, units=''): ds.close() +def earlier_snapshot_exists(output_dir: str, time: float) -> bool: + """Whether an interior snapshot older than a simulation time is on disk. + + Used before discarding a snapshot, to check that the run keeps one to fall + back on rather than being left with none. + + Parameters + ---------- + output_dir : str + Run output directory (contains ``data/``). + time : float + Simulation time to compare against [yr]. + + Returns + ------- + bool + Whether at least one older snapshot exists. + """ + cutoff = int(time) + for fpath in glob.glob(os.path.join(output_dir, 'data', '*_int.nc')): + stem = os.path.basename(fpath).split('_int.nc')[0] + try: + if int(stem) < cutoff: + return True + except ValueError: + continue + return False + + +def discard_snapshot(output_dir: str, time: float) -> bool: + """Delete the interior snapshot written for a simulation time. + + Used when a snapshot no longer describes the state the run ended the step + in, so that a resume walks back to the last snapshot that does rather than + loading one the helpfile has already moved past. + + Parameters + ---------- + output_dir : str + Run output directory (contains ``data/``). + time : float + Simulation time the snapshot is keyed on [yr]. + + Returns + ------- + bool + Whether a snapshot was found and removed. + """ + fpath = os.path.join(output_dir, 'data', '%d_int.nc' % time) + if not os.path.exists(fpath): + return False + os.remove(fpath) + return True + + def read_last_Sfield(output_dir: str, time: float): """Read the entropy field from the previous Aragog NetCDF output.""" fpath = os.path.join(output_dir, 'data', '%d_int.nc' % time) diff --git a/src/proteus/proteus.py b/src/proteus/proteus.py index 8d2845ed8..0555713c4 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -622,16 +622,32 @@ def start(self, *, resume: bool = False, offline: bool = False): # after escape has run, so the error lands on the first step of # every restart. # - # The flag latches: the loop sets it once the melt fraction drops - # to the threshold and never clears it, so a mantle that - # crystallized and later remelted stays frozen. Reading only the - # resumed row would clear it in exactly that case and diverge from - # an uninterrupted run, so the whole stored history is searched - # instead. Rows with no melt fraction recorded compare False and - # so leave the flag clear, which is the behaviour a helpfile - # written before the column existed had already. + # The flag latches within a run: the loop sets it once the melt + # fraction drops to the threshold and does not clear it, so a + # mantle that crystallized and later remelted by cooling alone + # stays frozen. Reading only the resumed row would clear it in + # exactly that case and diverge from an uninterrupted run, so the + # stored history is searched instead. Rows with no melt fraction + # recorded compare False and so leave the flag clear, which is the + # behaviour a helpfile written before the column existed had + # already. + # + # A giant impact is the one event that does clear the latch, since + # it remelts the mantle to a magma ocean. Only the history after + # the last impact can re-establish the flag; searching across an + # impact would restore a latch the run itself had lifted. The + # impact's own row is excluded because it records the melt + # fraction from before the remelt. Runs without accretion carry + # no accreted rock, so the search covers the whole history and + # matches the behaviour of a run that never had an impact. if self.config.params.stop.solid.freeze_volatiles: phi_history = self.hf_all.get('Phi_global') + if phi_history is not None: + accreted = self.hf_all.get('M_accreted_rock') + if accreted is not None: + impacted = (accreted.diff() > 0.0).to_numpy().nonzero()[0] + if len(impacted) > 0: + phi_history = phi_history.iloc[impacted[-1] + 1 :] self.crystallized = phi_history is not None and bool( (phi_history <= self.config.params.stop.solid.phi_crit).any() ) @@ -892,13 +908,24 @@ def start(self, *, resume: bool = False, offline: bool = False): # solve evolves it. Empty when no accretion module is selected. if self.impact_events: from proteus.accretion.common import due_events - from proteus.accretion.wrapper import apply_impact + from proteus.accretion.wrapper import ( + apply_impact, + discard_preimpact_snapshot, + ) time_now = self.hf_row['Time'] time_previous = time_now - self.interior_o.dt - for event in due_events(self.impact_events, time_previous, time_now): + landed = due_events(self.impact_events, time_previous, time_now) + for event in landed: apply_impact(self, event) + # The interior wrote this step's snapshot before the re-melt + # above, so it no longer describes the state the step ended in. + # Drop it, or a resume would load a mantle the impact melted + # while treating the impact as already applied. + if landed and is_snapshot: + discard_preimpact_snapshot(self) + # One-time structure baseline in the interior-fed callable # representation (dynamic and static runs share an identical start). # Static runs perform no further structure solves; dynamic runs diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 61c39314e..23630285c 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -1262,9 +1262,23 @@ def test_zephyrus_loss_module_without_the_law_fails_loudly(monkeypatch): cfg = SimpleNamespace(accretion=_impact_accretion(atmloss_module='zephyrus')) monkeypatch.setitem(sys.modules, 'zephyrus.collision', None) - with pytest.raises(ImportError, match='fwl-zephyrus'): + with pytest.raises(ImportError, match='fwl-zephyrus') as excinfo: _impact_loss_fraction(cfg, {'M_planet': 6.0e24}, _impact_event()) + # The message names the setting that asked for it, the module that is + # absent, and the action that fixes it, so it can be acted on without + # reading the dispatch. + message = str(excinfo.value) + assert 'atmloss_module' in message + assert 'zephyrus.collision' in message + assert 'upgrade' in message + + # With no loss module configured the same call is silent and loses + # nothing, so the error is specific to the selected module rather than + # raised on every impact. + off = SimpleNamespace(accretion=_impact_accretion(atmloss_module=None)) + assert _impact_loss_fraction(off, {'M_planet': 6.0e24}, _impact_event()) == 0.0 + def _rescaling_solve_structure(factor): """Mock of solve_structure that rescales the volatile budgets by ``factor``. @@ -1441,9 +1455,6 @@ def _solve(dirs, config, hf_all, hf_row, output): hf_row['M_ele'] = 9.9e21 hf_row['M_planet'] = hf_row['M_int'] + 9.9e21 - monkeypatch.setattr( - 'proteus.accretion.wrapper.remelt_mantle', lambda *a, **k: None, raising=False - ) monkeypatch.setattr( 'proteus.interior_energetics.wrapper.solve_structure', _solve, raising=False ) @@ -1751,3 +1762,84 @@ def test_the_impact_eccentricity_is_clamped_to_a_bound_orbit(monkeypatch, caplog ) assert quiet.config.orbit.eccentricity == pytest.approx(0.14, rel=1e-12) assert 'clamped' not in caplog.text + + +@pytest.mark.unit +def test_discard_preimpact_snapshot_drops_only_the_impact_steps_own_snapshot(tmp_path, caplog): + """A step that both wrote a snapshot and landed an impact discards it. + + Physical scenario: the interior writes its snapshot while the step is + solved, which is before the impacts falling in that step are applied at + the end of it. On such a step the snapshot holds the mantle from before + the re-melt while the helpfile row it shares a time with already carries + the impact's mass, orbit and volatile budgets. Resuming from that pair + would restore a mantle the impact had melted while treating the impact as + already applied, silently losing the re-melt. + + Contract clause: the stale snapshot is removed so the resume walks back to + the previous complete pair and applies the impact again in full. + + Verifies: + - The impact step's snapshot is removed for the interior that writes one. + - The previous step's snapshot survives, so the resume has a pair to land + on rather than being left with none. + - An interior that writes no snapshot leaves the directory untouched, so + the discard cannot delete another writer's file. + - A step with no snapshot on disk is a no-op rather than an error. + - The last remaining snapshot is kept and reported, because removing it + would leave the run with no interior state to resume from at all. + """ + from proteus.accretion.wrapper import discard_preimpact_snapshot + + def _handler(module, time=300.0): + return SimpleNamespace( + config=SimpleNamespace(interior_energetics=SimpleNamespace(module=module)), + directories={'output': str(tmp_path)}, + hf_row={'Time': time}, + ) + + data = tmp_path / 'data' + data.mkdir() + (data / '300_int.nc').write_text('pre-remelt') + (data / '200_int.nc').write_text('previous') + + discard_preimpact_snapshot(_handler('aragog')) + assert not (data / '300_int.nc').exists(), ( + 'the impact step kept its pre-remelt snapshot, so a resume would load ' + 'a mantle the impact had already melted' + ) + assert (data / '200_int.nc').read_text() == 'previous', ( + 'the previous complete snapshot was removed too, leaving the resume ' + 'with nothing to walk back to' + ) + + # The scalar interiors carry their state in the helpfile row, which is + # already post-impact, so they must not have files removed under them. + (data / '300_int.nc').write_text('not mine to delete') + for module in ('dummy', 'boundary', 'spider'): + discard_preimpact_snapshot(_handler(module)) + assert (data / '300_int.nc').read_text() == 'not mine to delete', ( + f"the '{module}' interior discarded a snapshot it does not write" + ) + + # A step that wrote no snapshot is the ordinary case, not an error. + discard_preimpact_snapshot(_handler('aragog', time=999.0)) + + # The last snapshot is kept: discarding it would leave nothing for the + # resume to land on, so the inconsistency is reported instead of the run + # being stripped of its only interior state. + for stale in data.glob('*_int.nc'): + stale.unlink() + (data / '300_int.nc').write_text('only one left') + + with caplog.at_level(logging.WARNING, logger='fwl.proteus.accretion.wrapper'): + discard_preimpact_snapshot(_handler('aragog')) + + assert (data / '300_int.nc').exists(), ( + 'the only interior snapshot was discarded, so the run has no state to ' + 'resume from and no interior history at its endpoint' + ) + assert 'only one' in caplog.text, ( + 'the kept snapshot predates the re-melt, so staying silent would hide ' + 'an inconsistent resume' + ) diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 01870698e..0dfca9f08 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -760,3 +760,44 @@ def test_setup_or_update_solver_tracks_stale_structure_steps(): interior_o.structure_stale = True AragogRunner.setup_or_update_solver(config, hf_row, interior_o, 1.0, dirs) assert interior_o._stale_struct_steps == 1 + + +@pytest.mark.unit +def test_discard_snapshot_removes_only_the_named_time(tmp_path): + """A snapshot is discarded by its own time, leaving the others in place. + + Contract clause: the discard is used when one step's snapshot no longer + describes the state that step ended in. It has to remove exactly that + step's file, because the resume walks back to the neighbouring snapshots + and would have nothing to land on if they went with it. + + Verifies: + - The named snapshot is gone and the call reports that it removed one. + - A snapshot at another time is untouched, so the removal is not a wipe. + - A time with no snapshot reports False instead of raising, which is the + ordinary case for a step that wrote nothing. + - The time is truncated toward zero, matching the writer's convention, so + a fractional time still finds its file. + """ + from proteus.interior_energetics.aragog import discard_snapshot + + data = tmp_path / 'data' + data.mkdir() + (data / '300_int.nc').write_text('stale') + (data / '200_int.nc').write_text('keep') + + assert discard_snapshot(str(tmp_path), 300.0) is True + assert not (data / '300_int.nc').exists() + assert (data / '200_int.nc').read_text() == 'keep', ( + 'discarding one step removed a neighbouring snapshot, leaving the ' + 'resume with nothing to walk back to' + ) + + # A missing snapshot is ordinary, not an error. + assert discard_snapshot(str(tmp_path), 300.0) is False + assert discard_snapshot(str(tmp_path), 999.0) is False + + # Fractional times truncate, matching '%d_int.nc' in the writer. + (data / '410_int.nc').write_text('stale') + assert discard_snapshot(str(tmp_path), 410.9) is True + assert not (data / '410_int.nc').exists() diff --git a/tests/test_proteus.py b/tests/test_proteus.py index 0acbce98d..7767becaa 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -1058,3 +1058,89 @@ def test_proteus_resume_keeps_crystallized_after_remelting(tmp_path): 'a run whose melt fraction never reached the threshold resumed as ' 'crystallized; the history search is matching too eagerly' ) + + +def _make_hf_df_with_impact(phi_history, accreted_rock): + """Helpfile frame carrying a melt-fraction history and an impact ledger. + + ``accreted_rock`` is the cumulative rock mass [kg] recorded on each row, + so a row where it rises above the previous one is a row on which a giant + impact landed. + """ + df = _make_hf_df() + df['Phi_global'] = phi_history + df['M_accreted_rock'] = accreted_rock + return df + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_proteus_resume_lifts_the_crystallization_latch_across_an_impact(tmp_path): + """A giant impact that remelts a crystallized mantle stays lifted on resume. + + Physical scenario: the mantle solidifies to the crystallization threshold, + a giant impact then remelts it to a magma ocean, and the run continues + molten until it is stopped. The impact clears the solidification latch, + so the uninterrupted run has outgassing running again from the impact + onwards. + + Contract clause: a resumed run must behave as the uninterrupted one would. + Searching the whole melt-fraction history would find the pre-impact dip + and restore a latch the run itself had lifted, freezing outgassing for the + rest of a run whose mantle is molten. + + Verifies: + - A dip before the impact does not resume frozen, because the impact + remelted the mantle. + - A dip after the impact does resume frozen, so the search is not simply + always clearing the flag. + - The impact's own row is excluded: it records the melt fraction from + before the remelt, so a threshold value there must not relatch. + - Without accreted rock the whole history is searched, so a run with no + accretion is unaffected. + """ + phi_crit = 0.01 + impact_on_row_3 = [0.0, 0.0, 0.0, 1.0e21, 1.0e21] + + def _resume(hf_df): + p = _make_proteus_instance(tmp_path) + p.config.params.stop.solid.freeze_volatiles = True + p.config.params.stop.solid.phi_crit = phi_crit + (tmp_path / 'data').mkdir(exist_ok=True) + _resume_with_patches(p, hf_df) + return p + + # Crystallized at row 2, impact at row 3, molten afterwards. + lifted = _resume(_make_hf_df_with_impact([1.0, 0.5, 0.005, 0.300, 0.900], impact_on_row_3)) + assert lifted.crystallized is False, ( + 'a mantle remelted by a giant impact resumed as crystallized, so ' + 'outgassing would stay stopped where the uninterrupted run has it ' + 'running again' + ) + + # Discrimination: the same impact, but the mantle solidifies again after + # it. The latch must be restored, or the check would be always False. + relatched = _resume( + _make_hf_df_with_impact([1.0, 0.5, 0.005, 0.300, 0.008], impact_on_row_3) + ) + assert relatched.crystallized is True, ( + 'a mantle that solidified again after the impact resumed as molten, so ' + 'the post-impact history is not being searched at all' + ) + + # Boundary: the impact row carries the melt fraction from before the + # remelt, so a threshold value on that row must not restore the latch. + on_impact_row = _resume( + _make_hf_df_with_impact([1.0, 0.5, 0.900, 0.005, 0.900], impact_on_row_3) + ) + assert on_impact_row.crystallized is False, ( + "the impact row's own pre-remelt melt fraction restored the latch; the " + 'search must start after the impact, not on it' + ) + + # A run with no accretion searches the whole history, unchanged. + no_accretion = _resume(_make_hf_df_with_impact([1.0, 0.5, 0.005, 0.300, 0.900], [0.0] * 5)) + assert no_accretion.crystallized is True, ( + 'a run that never had an impact stopped seeing its own crystallization ' + 'history; the impact search must not affect non-accretion runs' + ) From 5d4ce1c712a22dc169427b1824d7d6ced4a3321f Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Tue, 28 Jul 2026 22:30:08 +0200 Subject: [PATCH 32/71] Sharpen the assertions in the accretion tests Several of these tests checked only that a bad input was refused, without checking that the refusal says enough to act on, and one checked a clamp without checking the property that makes it a clamp. The velocity floor now confirms both velocities are reported and pins the tolerance from both sides, so it tells an exact comparison apart from one wide enough to swallow a real error. The timescale guard confirms both named parameters and the spacing to aim for are quoted, and that a usable timescale yields finite positive masses, which is the underflow the guard exists to prevent. The record and outcome checks confirm the whole required set is listed rather than the first gap found. The zephyrus loss module confirms the message names the setting, the missing module and the fix, and that the same call is silent when no loss module is configured. The timestep test confirms a distant impact returns exactly the step the controller chose on its own, which is what makes the clamp one-way rather than something the timeline can steer. Values quoted from error messages are matched on the number rather than its formatting, so rewording a message does not fail a test. Also drops a monkeypatch of remelt_mantle on the accretion module. It is imported there inside the function from interior_energetics, so the module attribute it created was never read and the patch beside it was already doing the work. --- tests/accretion/test_common.py | 19 +++++++++++- tests/accretion/test_dummy.py | 26 +++++++++++++++- tests/accretion/test_morrigan.py | 36 ++++++++++++++++++++-- tests/interior_energetics/test_timestep.py | 19 ++++++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/tests/accretion/test_common.py b/tests/accretion/test_common.py index c8a24c4a2..41beb649f 100644 --- a/tests/accretion/test_common.py +++ b/tests/accretion/test_common.py @@ -15,6 +15,8 @@ from __future__ import annotations +import re + import numpy as np import pytest @@ -139,9 +141,24 @@ def test_collision_velocity_cannot_fall_below_mutual_escape_velocity(): validate_timeline([_event(v_impact=2.00e4, v_esc=1.15e4)]) # Swapped fields, the realistic mistake, must be caught. - with pytest.raises(ValueError, match='below the mutual escape velocity'): + with pytest.raises(ValueError, match='below the mutual escape velocity') as excinfo: validate_timeline([_event(v_impact=1.15e4, v_esc=1.30e4)]) + # Both velocities appear, so the reader can see which pair was swapped + # without reopening the timeline file. Matched on the values rather than + # on their formatting, so reformatting the message does not fail this. + quoted = [float(n) for n in re.findall(r'[0-9.]+e[+-][0-9]+', str(excinfo.value))] + assert any(v == pytest.approx(1.15e4, rel=1e-6) for v in quoted) + assert any(v == pytest.approx(1.30e4, rel=1e-6) for v in quoted) + + # The floor carries a relative tolerance for round-trip formatting only. + # A velocity a hair under the escape velocity is absorbed, one clearly + # under it is not, which discriminates the tolerance from an exact + # comparison and from a tolerance wide enough to swallow real errors. + validate_timeline([_event(v_impact=1.15e4 * (1.0 - 1.0e-7), v_esc=1.15e4)]) + with pytest.raises(ValueError, match='below the mutual escape velocity'): + validate_timeline([_event(v_impact=1.15e4 * (1.0 - 1.0e-3), v_esc=1.15e4)]) + @pytest.mark.unit @pytest.mark.physics_invariant diff --git a/tests/accretion/test_dummy.py b/tests/accretion/test_dummy.py index dc2c6acf3..3417327e9 100644 --- a/tests/accretion/test_dummy.py +++ b/tests/accretion/test_dummy.py @@ -17,6 +17,7 @@ from __future__ import annotations import math +import re from types import SimpleNamespace import pytest @@ -271,9 +272,32 @@ def test_an_unusable_timescale_is_refused_with_an_actionable_message(): masses that only surface much later as an opaque solver failure. The module must reject it at generation time and name both parameters involved. """ - with pytest.raises(ValueError, match='timescale'): + with pytest.raises(ValueError, match='timescale') as excinfo: get_timeline(_config(timescale=1.0e-3, time_last=1.0e9, num_impacts=2)) + # Both parameters that produced the failure are named, along with the + # spacing to bring the timescale towards, so the message says what to + # change rather than only that something is wrong. + message = str(excinfo.value) + assert 'accretion.dummy.timescale' in message + assert 'time_last' in message + # The spacing to aim for is quoted. Matched on the value rather than its + # formatting, so reformatting the message does not fail this. + quoted = [float(n) for n in re.findall(r'[0-9.]+e[+-][0-9]+', message)] + assert any(v == pytest.approx(5.0e8, rel=1e-6) for v in quoted), ( + 'the impact spacing to aim for is not quoted' + ) + + # A usable timescale carries real mass in every impact. This is the + # failure the guard exists to prevent: without it the weights underflow, + # the renormalisation divides by zero and the masses come back NaN. + events = get_timeline(_config(timescale=5.0e8, time_last=1.0e9, num_impacts=2)) + masses = [event.M_impactor for event in events] + assert len(masses) == 2 + assert all(math.isfinite(m) and m > 0.0 for m in masses), ( + f'the accretion law produced unusable impactor masses: {masses}' + ) + @pytest.mark.unit @pytest.mark.physics_invariant diff --git a/tests/accretion/test_morrigan.py b/tests/accretion/test_morrigan.py index a8c55bf54..0cfe0a078 100644 --- a/tests/accretion/test_morrigan.py +++ b/tests/accretion/test_morrigan.py @@ -21,6 +21,7 @@ import pytest from proteus.accretion import morrigan as backend +from proteus.accretion.common import TIMELINE_COLUMNS from proteus.utils.constants import AU, M_earth pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] @@ -395,9 +396,27 @@ def test_a_missing_field_names_itself_rather_than_failing_obscurely(monkeypatch) ) monkeypatch.setattr(backend, 'morrigan', fake, raising=False) - with pytest.raises(ValueError, match='v_esc'): + with pytest.raises(ValueError, match='v_esc') as excinfo: backend.get_timeline(_config(selector='mass')) + # The message names the offending record and the whole required set, not + # just the one field, so a reader can see the contract that was broken + # rather than fixing one field at a time. + message = str(excinfo.value) + assert 'v_impact' in message, 'the required set is not quoted alongside the gap' + assert all(column in message for column in TIMELINE_COLUMNS) + + # A complete record of the same shape passes, so the check is keyed on the + # missing field and not on the record being rejected outright. + whole = SimpleNamespace( + run_system=lambda **kw: { + 'survivors': _SURVIVORS, + 'impacts': {1: [_one_impact_record()], 2: []}, + } + ) + monkeypatch.setattr(backend, 'morrigan', whole, raising=False) + assert len(backend.get_timeline(_config(selector='mass'))) == 1 + @pytest.mark.unit def test_an_outcome_missing_its_top_level_entries_is_refused(monkeypatch): @@ -414,5 +433,18 @@ def test_an_outcome_missing_its_top_level_entries_is_refused(monkeypatch): fake = SimpleNamespace(run_system=partial(_return_outcome, outcome)) monkeypatch.setattr(backend, 'morrigan', fake, raising=False) - with pytest.raises(ValueError, match=absent): + with pytest.raises(ValueError, match=absent) as excinfo: backend.get_timeline(_config(selector='mass')) + + # The message names both required entries and shows what did arrive, + # which is what points at the installed model version as the cause. + message = str(excinfo.value) + assert 'survivors' in message and 'impacts' in message + assert 'installed' in message, ( + 'the cause is not attributed, so the reader has no reason to ' + 'suspect their model version' + ) + # The keys that were present are reported, so the message discriminates + # a partly-shaped outcome from one that is empty. + present = 'impacts' if absent == 'survivors' else 'survivors' + assert present in message diff --git a/tests/interior_energetics/test_timestep.py b/tests/interior_energetics/test_timestep.py index dad2954d3..fbbd0909d 100644 --- a/tests/interior_energetics/test_timestep.py +++ b/tests/interior_energetics/test_timestep.py @@ -714,6 +714,25 @@ def test_a_distant_impact_does_not_lengthen_the_step(self): assert dt == pytest.approx(8.0e3, rel=1e-6), f'Expected 8e3, got {dt}' + # The controller's own step, with no impact scheduled at all. A + # distant impact must return exactly this, which is what makes the + # clamp one-way rather than a step the timeline gets to set. + unclamped = next_step( + config, + {}, + hf_row, + hf_all, + 1.0, + interior_o=_make_interior_o(t_next_impact=float('inf')), + ) + assert dt == pytest.approx(unclamped, rel=1e-12), ( + f'a distant impact moved the step from {unclamped} to {dt}, so the ' + 'timeline is steering the controller instead of only shortening it' + ) + + # It also stops short of the impact, the property the clamp exists for. + assert dt <= 2.0e4 + @pytest.mark.physics_invariant def test_an_imminent_impact_is_floored_at_the_minimum_step(self): """An impact inside the minimum step must not collapse dt. From 26e2fb7cd03155adf77dd94bdd4d2433aaeb50ab Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 29 Jul 2026 08:07:45 +0200 Subject: [PATCH 33/71] Cover the loop condition that drops an impact step's snapshot The interior writes its snapshot while a step is solved, which is before the impacts falling in that step are applied at the end of it, so the loop drops that snapshot when a step does both. The condition has two halves and nothing tested either of them: it could be reduced to the impact alone, or to the write alone, or moved ahead of the impact, without a single test failing. The new smoke test runs the real loop over a scheduled impact twice, once writing on every iteration and once with the write cadence set above the run length, and pins the discard to firing exactly once, at the impact time, with the accreted-rock ledger already carrying the impactor's mass. The run that writes on every iteration produces forty snapshots against its single impact, so a discard wired to the write alone is an order of magnitude away from the asserted count. Both runs use the dummy interior, which writes no snapshot of its own, so this covers the loop's decision to call the discard rather than the file removal it performs. --- tests/integration/test_smoke_accretion.py | 184 ++++++++++++++++++++++ 1 file changed, 184 insertions(+) diff --git a/tests/integration/test_smoke_accretion.py b/tests/integration/test_smoke_accretion.py index 6f0fa1c4e..a9c1325eb 100644 --- a/tests/integration/test_smoke_accretion.py +++ b/tests/integration/test_smoke_accretion.py @@ -18,6 +18,8 @@ - the accreted-rock ledger a resume reads back is written and monotonic - M_planet stays consistent with M_int + M_ele after the impact - the run does not trip the runtime M_atm <= M_planet assertion + - the stale-snapshot discard fires on an impact that lands on a data-write + iteration, once, after the impact has been applied, and on no other step Testing standards: - docs/How-to/testing.md @@ -39,6 +41,12 @@ pytestmark = [pytest.mark.smoke, pytest.mark.timeout(120)] +# Iteration count above which "once per impact" and "once per data write" +# are unambiguously different outcomes. The runs below write on every +# iteration, so a discard wired to the write alone would fire this many +# times against the single impact that actually lands. +MIN_WRITE_ITERATIONS = 5 + @pytest.mark.smoke @pytest.mark.physics_invariant @@ -184,3 +192,179 @@ def test_smoke_accretion_impact_lands_inside_the_coupled_loop(): assert np.all(hf['M_atm'].values <= hf['M_planet'].values), ( 'the atmosphere cannot outweigh the planet carrying it' ) + + +def _impact_runner(output_dir, *, write_mod, impact_time, delivered): + """Build an all-dummy runner with one giant impact scheduled. + + Parameters + ---------- + output_dir : pathlib.Path + Run directory, written straight into ``params.out.path``. + write_mod : int + Data-write cadence. 1 writes on every iteration; a value larger than + the run's iteration count writes only on the zeroth, which is how a + run whose impact lands on a non-writing step is built. + impact_time : float + Time of the single scheduled impact [yr]. + delivered : float + Mass the impactor delivers [M_earth]. + + Returns + ------- + Proteus + Configured runner; the caller starts it. + """ + runner = Proteus(config_path=PROTEUS_ROOT / 'input' / 'dummy.toml') + runner.config.params.out.path = str(output_dir) + runner.init_directories() + + runner.config.planet.tsurf_init = 2000.0 + + # The step ceiling is well below the impact time, so the run takes a + # double-figure number of steps to reach it rather than jumping over it in + # two. That is what makes "once per impact" and "once per write" tell + # apart below. + runner.config.params.stop.time.minimum = 1e2 + runner.config.params.stop.time.maximum = 1e5 + runner.config.params.dt.initial = 1e3 + runner.config.params.dt.minimum = 1e0 + runner.config.params.dt.maximum = 1e3 + + runner.config.params.out.write_mod = write_mod + # No relative-time guard on the writes, so write_mod alone decides which + # iterations are snapshots and the cadence stays exactly as configured. + runner.config.params.out.dt_write_rel = 0.0 + # None, not 0: the schema reads 0 as "plot once at completion", and the + # end-of-run block only skips plotting when this is None. + runner.config.params.out.plot_mod = None + runner.config.params.out.archive_mod = 'none' + + runner.config.accretion.module = 'dummy' + runner.config.accretion.dummy.num_impacts = 1 + runner.config.accretion.dummy.mass_accreted = delivered + runner.config.accretion.dummy.time_last = impact_time + runner.config.accretion.dummy.timescale = 3.0e3 + runner.config.accretion.dummy.eccentricity = 0.05 + runner.config.accretion.impactor_volatiles = 'dry' + + return runner + + +@pytest.mark.smoke +def test_the_snapshot_discard_fires_once_per_impact_and_only_on_a_write_step( + tmp_path, monkeypatch +): + """The loop discards a stale snapshot only on an impact step that wrote one. + + Physical scenario: a planet takes one giant impact partway through a run. + The interior writes its snapshot while the step is solved, before the + impact re-melts the mantle at the end of it, so on a step that does both + the snapshot on disk no longer describes the state the step ended in and + must be dropped. A step that wrote nothing has nothing to drop, and a + write with no impact holds a snapshot that is still current. + + Contract clause: the discard is conditioned on both halves, an impact + having landed and the step having been a data-write snapshot. Either half + alone is wrong: dropping the impact condition would discard a valid + snapshot on every write, and dropping the write condition would call the + discard on steps that never produced a file. + + Verifies: + - With a write on every iteration the discard fires exactly once, on the + impact, against a run that wrote many more snapshots than it carries + impacts. + - It fires at the impact time, and the accreted-rock ledger already + carries the impactor's mass at that moment, so the discard runs after + the impact was applied rather than before it. + - With the write cadence set above the run length, so the impact lands on + a step that wrote nothing, the discard is not called at all while the + same impact still lands and grows the planet. + + Scope. Both runs use the dummy interior, which writes no interior + snapshot, so this covers the loop's decision to call the discard rather + than the file removal it performs. The removal and the resume that + follows it are covered against real snapshots in + ``test_slow_accretion_resume.py``. + """ + from proteus.accretion import wrapper as accretion_wrapper + + impact_time = 4.0e3 + delivered = 0.1 # M_earth + calls: list[dict] = [] + + # The loop imports the discard from its module on every iteration, so + # patching the module attribute is what the loop picks up. The real + # function is still called, so nothing about the run changes. + real_discard = accretion_wrapper.discard_preimpact_snapshot + + def _record_and_call(handler): + calls.append( + { + 'time': float(handler.hf_row['Time']), + 'accreted': float(handler.hf_row.get('M_accreted_rock') or 0.0), + } + ) + return real_discard(handler) + + monkeypatch.setattr(accretion_wrapper, 'discard_preimpact_snapshot', _record_and_call) + + # A write on every iteration: the impact step is a snapshot step. + on_write = _impact_runner( + tmp_path / 'writes_every_step', + write_mod=1, + impact_time=impact_time, + delivered=delivered, + ) + on_write.start(resume=False, offline=True) + + # Every iteration wrote, so the iteration count is the number of + # snapshots this run produced. A discard wired to the write alone would + # have fired that many times. + n_writes = len(on_write.hf_all) + assert n_writes > MIN_WRITE_ITERATIONS, ( + f'the run wrote only {n_writes} snapshots, too few to tell a discard ' + 'fired once per impact from one fired on every write' + ) + assert len(calls) == 1, ( + f'the discard fired {len(calls)} times across {n_writes} snapshot ' + 'iterations carrying a single impact; it must fire once, on the impact' + ) + + landed = calls[0] + assert landed['time'] == pytest.approx(impact_time, rel=0, abs=1e-6), ( + f'the discard fired at {landed["time"]:.6e} yr against an impact at ' + f'{impact_time:.6e} yr; it is not firing on the impact step' + ) + # The ledger already carries the impactor's rock, so the impact was + # applied before the discard ran. A discard placed ahead of the impact + # would see zero here and would be dropping a snapshot that is still + # current. + assert landed['accreted'] == pytest.approx(delivered * M_earth, rel=1e-6), ( + f'the accreted-rock ledger read {landed["accreted"]:.6e} kg when the ' + f'discard ran, not the {delivered * M_earth:.6e} kg the impact adds; ' + 'the discard is running before the impact is applied' + ) + + # The same impact on a step that wrote nothing: the write cadence is set + # above the run length, so only the zeroth iteration is a snapshot. + calls.clear() + off_write = _impact_runner( + tmp_path / 'writes_once', + write_mod=10**6, + impact_time=impact_time, + delivered=delivered, + ) + off_write.start(resume=False, offline=True) + + assert len(calls) == 0, ( + f'the discard fired {len(calls)} times on a run whose impact step ' + 'wrote no snapshot, so it would remove a file written by an earlier step' + ) + # The impact still landed, so the absence above is the condition doing its + # work rather than a run that never reached its impact. + ledger = off_write.hf_all['M_accreted_rock'].fillna(0.0).to_numpy() + assert ledger[-1] == pytest.approx(delivered * M_earth, rel=1e-6), ( + f'the run ended with {ledger[-1]:.6e} kg of accreted rock, so its ' + 'impact never landed and the discard had nothing to fire on' + ) From 6943e72a80ccba112e3e02c7a069aaaa41fcee88 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 29 Jul 2026 08:07:46 +0200 Subject: [PATCH 34/71] Test that a run stopped on an impact resumes from before it Only Aragog writes an interior snapshot, so on every other interior the discard returns immediately and the resume has no interior half to walk back over. The accretion-plus-resume interaction was therefore not reachable from any existing test, even though it is the case the discard exists to protect: a run killed on the step that lands an impact. The new slow test runs the real Aragog interior across a scheduled impact, stops the run on it, and resumes with nothing in the resume path mocked. It pins the impact step leaving no snapshot while the previous one survives, the resume landing on that previous pair and re-applying the impact, the accreted rock and the planet mass showing exactly one impact across both legs, and the mantle carried past the impact being the one the impact melted rather than the cooler mantle the discarded snapshot held. The initial condition is the molten one, so the re-melt adds heat and the warming step is a signal nothing else in the configuration can produce. Two Aragog legs cost about ten minutes, so the file sits in the slow tier and runs in the nightly aragog shard on both platforms. --- .github/workflows/ci-nightly.yml | 2 + .../integration/test_slow_accretion_resume.py | 353 ++++++++++++++++++ 2 files changed, 355 insertions(+) create mode 100644 tests/integration/test_slow_accretion_resume.py diff --git a/.github/workflows/ci-nightly.yml b/.github/workflows/ci-nightly.yml index 2e2d6ed88..07e67c9d6 100644 --- a/.github/workflows/ci-nightly.yml +++ b/.github/workflows/ci-nightly.yml @@ -211,12 +211,14 @@ jobs: files: >- tests/integration/test_slow_aragog_calliope.py tests/integration/test_slow_aragog_atmodeller.py + tests/integration/test_slow_accretion_resume.py - shard: aragog tier: critical os: macos-latest files: >- tests/integration/test_slow_aragog_calliope.py tests/integration/test_slow_aragog_atmodeller.py + tests/integration/test_slow_accretion_resume.py # ---- shard: zalmoxis-dummy (real Zalmoxis + dummy # everything else; Linux-only because the test is # skipif(darwin) on macOS) ---- diff --git a/tests/integration/test_slow_accretion_resume.py b/tests/integration/test_slow_accretion_resume.py new file mode 100644 index 000000000..5521b5c7c --- /dev/null +++ b/tests/integration/test_slow_accretion_resume.py @@ -0,0 +1,353 @@ +"""Slow test: a run stopped on a giant impact resumes without losing the re-melt. + +The interior writes its snapshot while a step is solved, which is before the +impacts falling in that step are applied at the end of it. A run stopped on +such a step leaves a snapshot of the mantle from before the re-melt beside a +helpfile row that already carries the impact's mass and orbit. Resuming from +that pair would restore a mantle the impact had melted while treating the +impact as already applied, so the stale snapshot is discarded and the resume +walks back to the last step that has a complete pair, applying the impact +again in full. + +Nothing lighter reaches this. Only Aragog writes an interior snapshot, so on +the dummy interior the discard returns immediately and +``select_resumable_snapshot`` has no interior half to walk back over: the +whole mechanism is invisible. This file therefore runs the real Aragog +interior across a scheduled impact, stops on it, and resumes, with nothing in +the resume path mocked. That is what puts it in the slow tier. + +Contract clauses exercised: + +- The step that lands the impact leaves no interior snapshot of its own, + while the snapshot from the previous step survives to be resumed from. +- The resume is not told where to land. ``select_resumable_snapshot`` runs + unmocked, finds the trailing row unbacked, and truncates the helpfile to + the last complete pair. +- The impact is applied exactly once across the two legs, in the ledger the + resume reads back and in the planet mass the configuration carries. +- The mantle the resumed run carries forward is the one the impact melted, + not the cooler one the discarded snapshot held. + +Scope. The interior runs its production backend; every other module is on its +dummy backend, which keeps two Aragog legs inside the tier budget and leaves +the interior snapshot as the only state channel under test. The atmosphere is +dummy and writes no ``_atm.nc``, so the resume's atmosphere half imposes no +constraint and the interior half is what decides where the run lands. + +See also: +- docs/How-to/testing.md +- docs/Explanations/test_framework.md +""" + +from __future__ import annotations + +import shutil + +import numpy as np +import pytest +from helpers import PROTEUS_ROOT + +from proteus import Proteus +from proteus.utils.constants import M_earth +from proteus.utils.data import download_sufficient_data + +# Slow tier. Two real Aragog legs, about 10 minutes locally, most of it in +# the two solves of the molten initial condition: one at the start and one +# for the grown planet at the impact. The 3600 s ceiling leaves room for +# slower CI runners. +pytestmark = [pytest.mark.slow, pytest.mark.timeout(3600)] + +CONFIG = PROTEUS_ROOT / 'input' / 'dummy.toml' + +# Time of the single scheduled impact [yr]. The first leg stops here, so the +# impact lands on the last step the run takes and its snapshot is the one a +# resume would otherwise reach for. +IMPACT_TIME = 3.0e2 + +# Mass the impactor delivers [M_earth]. A dry impactor carries no volatiles, +# so all of it is rock and the expected ledger value is exact. +DELIVERED = 0.1 + +# Stop time for the resumed leg [yr]. Far enough past the impact for the +# mantle it melted to be carried forward over several steps, close enough to +# keep the second leg to a handful of Aragog solves. +LEG2_STOP_TIME = 5.0e2 + +# Step limits [yr]. The ceiling is a third of the impact time, so the run +# takes several steps to reach the impact and the step before it carries a +# snapshot of its own for the resume to land on. +DT_INITIAL = 1.0e2 +DT_MINIMUM = 1.0e1 +DT_MAXIMUM = 1.0e2 + +# Melting-curve folder in FWL_DATA. Required because the dummy structure +# module selects the shipped EOS rather than the PALEOS tables Zalmoxis +# generates, and Aragog refuses to guess a melting curve for it. +MELTING_DIR = 'Monteux-600' + + +# Scratch directories created by Proteus construction, cleared after the +# test. ``set_directories`` allocates one per runner and nothing in the +# framework removes it. +_RUNNER_TEMP_DIRS: list[str] = [] + + +@pytest.fixture(autouse=True) +def _remove_runner_temp_dirs(): + """Delete the scratch directories this file's runners allocate.""" + _RUNNER_TEMP_DIRS.clear() + yield + for path in _RUNNER_TEMP_DIRS: + shutil.rmtree(path, ignore_errors=True) + _RUNNER_TEMP_DIRS.clear() + + +def _config_with_output_path(output_dir): + """Write a copy of the dummy config that already names its output path. + + Building the runner from a config that carries the run directory avoids a + second ``init_directories`` call, and with it a second scratch directory + per runner. + """ + text = CONFIG.read_text() + patched = text.replace('path = "auto"', f'path = "{output_dir}"', 1) + assert patched != text, 'dummy config no longer carries the auto output path' + destination = output_dir.parent / f'{output_dir.name}_config.toml' + destination.write_text(patched) + return destination + + +def _make_runner(output_dir, stop_time): + """Build an Aragog runner with one giant impact scheduled. + + Parameters + ---------- + output_dir : pathlib.Path + Run directory, shared by both legs so the second resumes the first. + stop_time : float + Maximum simulation time for this leg [yr]. + + Returns + ------- + Proteus + Configured runner; the caller starts it. + """ + runner = Proteus(config_path=_config_with_output_path(output_dir)) + _RUNNER_TEMP_DIRS.append(runner.directories['temp']) + + runner.config.interior_energetics.module = 'aragog' + runner.config.interior_struct.melting_dir = MELTING_DIR + # The re-melt re-applies the interior initial condition, so it only adds + # heat if that condition is molten for the grown planet. This one is, at + # any mass and on any melting curve, which is what makes the re-melt + # visible as a warming step below. + runner.config.planet.temperature_mode = 'liquidus_super' + + runner.config.params.stop.solid.enabled = False + runner.config.params.stop.time.minimum = 0.0 + runner.config.params.stop.time.maximum = stop_time + runner.config.params.stop.iters.minimum = 1 + runner.config.params.stop.iters.maximum = 200 + + runner.config.params.dt.initial = DT_INITIAL + runner.config.params.dt.minimum = DT_MINIMUM + runner.config.params.dt.maximum = DT_MAXIMUM + + # A snapshot on every iteration, so the impact step writes one and the + # step before it leaves the pair the resume falls back to. + runner.config.params.out.write_mod = 1 + runner.config.params.out.dt_write_rel = 0.0 + # None, not 0: the schema reads 0 as "plot once at completion", and the + # end-of-run block only skips plotting when this is None. + runner.config.params.out.plot_mod = None + # Loose files, not tar archives, so the snapshots this test reads stay + # where the interior wrote them. + runner.config.params.out.archive_mod = 'none' + + runner.config.accretion.module = 'dummy' + runner.config.accretion.dummy.num_impacts = 1 + runner.config.accretion.dummy.mass_accreted = DELIVERED + runner.config.accretion.dummy.time_last = IMPACT_TIME + runner.config.accretion.dummy.timescale = 3.0e3 + runner.config.accretion.dummy.eccentricity = 0.05 + runner.config.accretion.impactor_volatiles = 'dry' + + return runner + + +def _snapshot_times(output_dir): + """Simulation times of the interior snapshots on disk [yr], ascending.""" + return sorted( + int(path.name.split('_int.nc')[0]) for path in (output_dir / 'data').glob('*_int.nc') + ) + + +@pytest.mark.slow +@pytest.mark.physics_invariant +def test_a_run_stopped_on_an_impact_resumes_from_before_it(tmp_path): + """A resumed run re-applies the impact its last snapshot no longer described. + + Physical scenario: a magma-ocean planet takes one giant impact, which + delivers rock and re-melts the mantle, and the run stops on that step. A + second process resumes the run from the output directory, as + ``proteus start -r`` does. + + Verifies, on the leg that stops on the impact: + - The last stored row is the impact row, carrying the delivered rock. + - No interior snapshot remains for that time, because the one the step + wrote describes the mantle from before the re-melt. + - An earlier snapshot survives, so the run is left resumable rather than + stripped of its interior state. + - The re-melt heated the mantle, which is what makes the discarded + snapshot stale rather than merely redundant. + + Verifies, on the resumed leg: + - The run advances past the impact time and stores no duplicate times, so + the truncated rows were recomputed rather than appended alongside. + - The impact is applied exactly once across both legs, in the accreted + rock ledger and in the planet mass the configuration carries. + - The mantle carried past the impact is hotter than the mantle before it, + in a run that cools on every other step. A resume that had loaded the + discarded snapshot would continue from the cooler pre-re-melt state + while the helpfile row claimed the impact had landed, so this is the + check that the walk-back happened. + - The resumed leg discards its own impact-step snapshot in turn, so the + behaviour is a property of the step rather than of the first run. + """ + outdir = tmp_path / 'accretion_resume' + outdir.mkdir() + + leg1 = _make_runner(outdir, IMPACT_TIME) + mass_before = float(leg1.config.planet.mass_tot) + + # Aragog needs its lookup tables and melting curves. Fetch them here + # rather than during the run, which is started offline so a slow network + # cannot stall the solve. + leg1.config.params.offline = False + try: + download_sufficient_data(leg1.config, clean=False) + finally: + leg1.config.params.offline = True + + leg1.start(resume=False, offline=True) + stored = leg1.hf_all.copy() + + # The first leg stopped on the impact, so the impact row is the last one + # and the snapshot beside it is the stale one. Without this the checks + # below would be about an ordinary trailing row. + assert float(stored.iloc[-1]['Time']) == pytest.approx(IMPACT_TIME, rel=0, abs=1e-6), ( + f'first leg ended at {float(stored.iloc[-1]["Time"]):.6e} yr, not on the ' + f'impact at {IMPACT_TIME:.6e} yr; the resume would not start from an impact step' + ) + delivered_kg = DELIVERED * M_earth + assert float(stored.iloc[-1]['M_accreted_rock']) == pytest.approx(delivered_kg, rel=1e-6), ( + 'the last stored row does not carry the impactor rock, so the impact ' + 'did not land on the step the run stopped on' + ) + + # A resumable history needs more rows than the initialisation loops write. + assert len(stored) > leg1.loops['init_loops'] + 1, ( + f'first leg produced only {len(stored)} rows, too short to resume from' + ) + + # The impact step left no snapshot, and an older one survived it. + snapshots = _snapshot_times(outdir) + assert int(IMPACT_TIME) not in snapshots, ( + f'the impact step kept its interior snapshot (times on disk: {snapshots}); ' + 'a resume would load a mantle the impact had already melted' + ) + assert any(time < IMPACT_TIME for time in snapshots), ( + f'no interior snapshot older than the impact survived (times on disk: ' + f'{snapshots}); the run has nothing to walk back to' + ) + + # The mantle cools into the impact and the impact warms it. That contrast + # is what makes the discarded snapshot stale rather than merely redundant, + # and it is the signal the resume checks below read. Only the three rows + # before the impact are taken: the opening step settles the solver against + # the coupled surface flux and can move either way before the cooling + # trend sets in. + t_magma = stored['T_magma'].to_numpy() + cooling_before = t_magma[len(stored) - 4 : len(stored) - 1] + assert np.all(np.diff(cooling_before) < 0.0), ( + f'the mantle was not cooling into the impact (last pre-impact values ' + f'{np.round(cooling_before, 1).tolist()} K), so a warming step is not ' + 'the anomaly this test reads it as' + ) + assert t_magma[-1] > t_magma[-2], ( + f'the impact step ended at {t_magma[-1]:.1f} K against {t_magma[-2]:.1f} K ' + 'before it, so the re-melt added no heat and the resume checks below ' + 'could not tell the two mantles apart' + ) + pre_impact_T = float(t_magma[-2]) + + # Resume. Nothing in the resume path is mocked: the walk-back is + # select_resumable_snapshot reading what the first leg left on disk. + leg2 = _make_runner(outdir, LEG2_STOP_TIME) + leg2.start(resume=True, offline=True) + resumed = leg2.hf_all + + times = resumed['Time'].to_numpy() + assert times.max() > IMPACT_TIME, ( + f'the resumed run ended at {times.max():.6e} yr, no further than the ' + 'impact; nothing was carried past the re-melt' + ) + # The truncated rows were recomputed in place. A resume that appended + # instead would leave two rows at the same time. + evolution = times[times > 0.0] + assert len(np.unique(evolution)) == len(evolution), ( + 'the resumed helpfile stores a simulation time twice, so the rows the ' + 'resume walked back over were appended rather than recomputed' + ) + + # The impact landed exactly once across both legs. + ledger = resumed['M_accreted_rock'].fillna(0.0).to_numpy() + assert np.all(np.diff(ledger) >= 0.0), 'the accreted-rock ledger must not decrease' + assert ledger[-1] == pytest.approx(delivered_kg, rel=1e-6), ( + f'the run ended with {ledger[-1]:.6e} kg of accreted rock against the ' + f'{delivered_kg:.6e} kg one impact delivers; the resume applied it ' + 'twice or not at all' + ) + # Discrimination: applying it on both legs would double the ledger, which + # is five orders of magnitude outside the tolerance above. + assert abs(2.0 * delivered_kg - ledger[-1]) > 0.5 * delivered_kg + assert float(leg2.config.planet.mass_tot) == pytest.approx( + mass_before + DELIVERED, rel=1e-6 + ), ( + f'the resumed run carries {float(leg2.config.planet.mass_tot):.6f} M_earth ' + f'against the {mass_before + DELIVERED:.6f} M_earth one impact grows the ' + 'planet to; the restored mass and the re-applied impact do not agree' + ) + + # The mantle carried past the impact is the one the impact melted. The + # resumed run walked back to a state cooler than the pre-impact row and + # re-solved forward, so every row it wrote would stay below that row if + # the re-melt had been lost: nothing else here can warm the interior. + # Read as a maximum over the whole post-impact stretch rather than at the + # impact row alone, because the re-melt resets the solver at the end of + # the step and the row it lands on is written before that reset. + post_impact = resumed[resumed['Time'] >= IMPACT_TIME] + assert len(post_impact) > 1, ( + 'the resumed run stored no row past the impact step, so nothing was ' + 'evolved from the mantle the impact melted' + ) + warmest_after = float(post_impact['T_magma'].max()) + assert warmest_after > pre_impact_T, ( + f'the mantle never rose above {pre_impact_T:.1f} K after the impact ' + f'(warmest row {warmest_after:.1f} K), so the resumed run kept cooling ' + 'the mantle from before the re-melt instead of the one the impact melted' + ) + + # The resumed leg discarded its own impact-step snapshot in turn, so the + # discard is a property of any step that lands an impact rather than of + # the first run. + resumed_snapshots = _snapshot_times(outdir) + assert int(IMPACT_TIME) not in resumed_snapshots, ( + f'the resumed run left a snapshot at the impact time (times on disk: ' + f'{resumed_snapshots}); a second restart would resume from a stale mantle' + ) + assert any(time > IMPACT_TIME for time in resumed_snapshots), ( + f'the resumed run wrote no snapshot past the impact (times on disk: ' + f'{resumed_snapshots}), so a further restart would have to recompute ' + 'the impact a third time' + ) From d62006f904f7a8ad3b7c621295c1bb52c0d8f625 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 29 Jul 2026 08:33:51 +0200 Subject: [PATCH 35/71] Keep a run going when the interior stops on a phase-change event Aragog stops its integration part-way through a step when the melt fraction hits its per-cell cap or the bottom cell reaches the liquidus. The state up to that point is a valid solution and the interval it advanced is what the coupling clock follows, so a short step is not a failure. CVODE reports that stop as a root flag, which Aragog already maps to a successful status, but the scipy integrator reports it as a terminal-event status that the retry ladder scored as a failed solve. Retrying could never help, because the event fires again at the same place however small the step is, so a run that reached the crystallization front spent six attempts on every such step and gave up at the third one in a row. The ladder now accepts a step the terminal event shortened, subject to the same core-temperature sanity guard as a full step, and only when the step actually advanced: a step reporting no advance, or a negative one, still exhausts the ladder, since the coupling clock is set from that advance. Accepting those steps on its own trades a loud failure for a quiet one. A run that can only take them advances a sliver of each interval it asks for while every solve reports success, and on a test case that is 196 of 200 iterations covering a tenth of a year between them. So the interior now also refuses to keep doing it: over its last twenty steps it has to have covered at least a percent of the time those steps were given, and if it has not the run ends with a message giving the time advanced against the time requested and pointing at the CVODE solver that integrates through the front. The measure is the ground covered over a window rather than a run of consecutive short steps, because a run that alternates between stopping at the front and stepping normally is going nowhere just the same and would never break such a count. It is raised as its own error type and passed up rather than absorbed, because the fallback that keeps the previous interior state for a step clears its failure streak on the next success, and a stall is made of successes. Both ends of a run at the crystallization front now behave: on the melt-fraction cap the coupled stack uses, a run that previously died at 485 yr reaches 400 yr and keeps stepping; on a cap ten times tighter, where the interior covers 0.03 yr of every 3140 yr it asks for, the run stops on the twentieth step instead of writing hundreds of rows worth a thousandth of a year each. Also adds a diagnostic for the environment that produces this: `proteus doctor` now reports whether the SUNDIALS CVODE wrapper is importable, since without it Aragog integrates with scipy Radau and the only sign is a per-solve warning in the run log, which is easy to miss until a coupled run has already spent hours on it. A wrapper that imports but fails to load its extension is reported too, because a presence check alone passes on an ABI mismatch. --- src/proteus/doctor.py | 55 ++++ src/proteus/interior_energetics/aragog.py | 163 ++++++++++- src/proteus/interior_energetics/common.py | 9 + src/proteus/interior_energetics/wrapper.py | 11 +- tests/interior_energetics/test_aragog.py | 310 +++++++++++++++++++++ tests/interior_energetics/test_wrapper.py | 69 +++++ tests/test_doctor.py | 75 +++++ 7 files changed, 689 insertions(+), 3 deletions(-) diff --git a/src/proteus/doctor.py b/src/proteus/doctor.py index 5b9fbc30f..1538f1c95 100644 --- a/src/proteus/doctor.py +++ b/src/proteus/doctor.py @@ -403,6 +403,50 @@ def check_julia() -> CheckResult: ) +def check_cvode() -> CheckResult: + """Check that the SUNDIALS CVODE solver is available to Aragog. + + Aragog integrates the interior with CVODE when + ``interior_energetics.aragog.solver_method = "cvode"``, the production + setting and the same solver SPIDER uses. Without the wrapper it falls + back to scipy Radau, which stops on its own melt-fraction cap at the + crystallization front and so takes far shorter steps through it. The + fallback is reported here rather than only in the run log, where it is + a per-solve warning that is easy to miss until a long coupled run has + already spent hours on it. + """ + if importlib.util.find_spec('scikits_odes_sundials') is None: + return CheckResult( + name='cvode', + category='environment', + status=WARN, + message='not installed; Aragog integrates with scipy Radau instead', + fix_cmd='bash tools/get_cvode.sh', + ) + try: + importlib.import_module('scikits_odes_sundials.cvode') + except Exception as exc: + # Installed but not loadable is its own failure: the wrapper is + # compiled against the SUNDIALS C library, so a version or ABI + # mismatch imports the package and then fails on the extension. + return CheckResult( + name='cvode', + category='environment', + status=WARN, + message=( + f'installed but does not load ({type(exc).__name__}); ' + 'Aragog integrates with scipy Radau instead' + ), + fix_cmd='bash tools/get_cvode.sh', + ) + return CheckResult( + name='cvode', + category='environment', + status=PASS, + message='available for the Aragog interior', + ) + + def check_python_package(name: str, spec: Requirement | None) -> CheckResult: """Check a Python package against the pyproject.toml version spec.""" try: @@ -642,6 +686,17 @@ def run_all_checks() -> list[CheckResult]: message=f'check error: {exc}', ) ) + try: + results.append(check_cvode()) + except Exception as exc: + results.append( + CheckResult( + name='cvode', + category='environment', + status=FAIL, + message=f'check error: {exc}', + ) + ) # Data try: diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 9b0e40bc3..cb8e1475d 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -141,6 +141,19 @@ def _cached_entropy_eos_jax(eos_dir_str: str): # takes precedence. Sensitivity-tested across the m-series grid. _ZALMOXIS_DEFAULT_PHI_STEP_CAP = 0.1 +# How much of the time the interior is given it has to actually cover, and +# over how many steps that is judged. A step the phase-change event cuts short +# is fine on its own; a run that covers under a percent of everything it asks +# for is not going anywhere, however healthy each individual solve is. The +# share is read over a window rather than per step, and over a run of steps +# rather than consecutive ones, because a run can alternate between stopping +# at the front and stepping normally and still be stalled: what matters is the +# ground covered, not how the short steps are spaced. Twenty steps is long +# enough that an ordinary step or two cannot hide a stall and short enough to +# catch one within seconds rather than after a night of wall time. +_STEP_PROGRESS_MIN_SHARE = 0.01 +_STEP_PROGRESS_WINDOW = 20 + # Default per-cell temperature and entropy step caps auto-enabled for the # coupled zalmoxis stack, alongside the melt-fraction cap. The melt-fraction # cap goes blind once a cell is fully solid, so it cannot bound the core- @@ -379,6 +392,18 @@ def _estimate_T_pot(out) -> float: return float(out.T_magma) +class InteriorStalledError(RuntimeError): + """The interior is no longer carrying the run forward. + + Raised when step after step stops at the same phase change having advanced + almost nothing. Distinct from the solver failures the wrapper absorbs by + keeping the previous interior state for a step: those are transient, and + the run is expected to step past them, while this one repeats for as long + as the front is there. The wrapper lets it through so the run ends where + an operator sees it, rather than continuing to write rows that go nowhere. + """ + + class AragogRunner: def __init__( self, @@ -2068,8 +2093,25 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: float(hf_row.get('Time', 0.0)), ) - # Status check: did CVODE accept the step? - if out.status == 0: + # Status check: did the solver accept the step? + # + # Status 0 is a step integrated to its requested end. Status 1 + # is a terminal event: the melt-fraction cap or the liquidus + # crossing at the bottom cell stopped the integration part-way + # and the state up to that point is valid, which is why Aragog + # reports it rather than raising. CVODE returns the same cap as + # a root flag that Aragog maps to status 0, so a shortened step + # is already the accepted outcome on the production solver; the + # scipy fallback surfaces it as status 1 instead. Retrying it + # gains nothing, because the event fires again at the same + # place however small the step, and spends the whole ladder on + # a step that was never wrong. The coupling advances by the + # interval the solver actually integrated, so a short step is + # carried correctly. A step that advanced nothing is a + # different case and is not accepted: the loop would stall at + # that time forever. + stopped_on_event = out.status == 1 and float(out.dt_actual) > 0.0 + if out.status == 0 or stopped_on_event: # Sanity check: reject suspiciously large T_core jumps # that indicate the solver "succeeded" with garbage. # Applies on ALL attempts (not just retries): @@ -2096,6 +2138,26 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: ) # Fall through to the retry/exhaustion branch below else: + attempted_dt = float(solver.parameters.solver.end_time) - t_start + if stopped_on_event: + log.info( + 'Aragog stopped on its terminal event after ' + '%.3e yr of the %.3e yr step: the state is ' + 'valid up to the event, so the coupling ' + 'continues from there.', + float(out.dt_actual), + attempted_dt, + ) + # Weighed against what the coupling asked for, not + # against this attempt's interval. The ladder halves + # the interval on every rejected attempt, so a step + # accepted on a retry would otherwise be scored + # against an interval already cut down by up to a + # factor of thirty-two, and the steps that needed a + # retry are exactly the ones a stall is made of. + self._track_step_progress( + interior_o, float(out.dt_actual), dt_requested, hf_row + ) if attempt > 1: log.info( 'Aragog retry succeeded on attempt %d ' @@ -2117,6 +2179,23 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: 'status=0 but the T_core jump exceeded the ' f'{sanity_dT_core:.0f} K sanity threshold on every attempt' ) + elif out.status == 1 and float(out.dt_actual) > 0.0: + # The step advanced, so what rejected it on every + # attempt was the core-temperature guard above, not + # the terminal event itself. + reason = ( + 'the solver stopped on its terminal event and the ' + f'T_core jump exceeded the {sanity_dT_core:.0f} K ' + 'sanity threshold on every attempt' + ) + elif out.status == 1: + # Accepted above whenever it advanced the state, so + # reaching here means the terminal event fired at the + # start of every attempt and the step never moved. + reason = ( + 'the solver stopped on its terminal event without ' + 'advancing the state on any attempt' + ) else: reason = f'CVODE status={out.status}' log.error( @@ -2177,6 +2256,86 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: return out + @staticmethod + def _track_step_progress(interior_o, dt_actual, dt_attempted, hf_row) -> None: + """Refuse to keep taking steps that leave the run where it started. + + A step the terminal event cuts short is a valid solve, and one of them + is nothing to worry about: the interior meets the phase change, stops + at it, and the next step carries on from there. A run that covers + almost none of the time it asks for is a different matter. Every solve + still reports success, so nothing marks it as a failure, and the + endpoint is a night of wall time spent a few years into the evolution. + + Judged as the ground covered over the last + :data:`_STEP_PROGRESS_WINDOW` steps rather than step by step, and over + a run of steps rather than consecutive ones. A run can alternate + between stopping at the front and stepping normally and still be going + nowhere, so counting only unbroken runs of short steps would let + exactly that pattern through. Full steps enter the window on the same + terms, which is what lets a stiff patch the interior works through + leave nothing behind. + + The wrapper absorbs an ordinary solver failure by keeping the previous + interior state for that step, on the expectation that the run steps + past whatever caused it. A stall is not that: it repeats for as long + as the front is there, and each absorbed one resets the failure streak + that would otherwise end the run. This is raised as its own type for + that reason, and the wrapper lets it through. + + Parameters + ---------- + interior_o : Interior_t + Interior state, whose progress window is updated in place. + dt_actual : float + Interval the step advanced [yr]. + dt_attempted : float + Interval the coupling asked this step to cover [yr], before any + shortening the retry ladder applied. + hf_row : dict + Current helpfile row, read for the time to report. + + Raises + ------ + InteriorStalledError + When the window is full and the interior covered less than + :data:`_STEP_PROGRESS_MIN_SHARE` of the time it was given. + """ + window = getattr(interior_o, 'aragog_step_progress', None) + if window is None: + window = [] + interior_o.aragog_step_progress = window + window.append((float(dt_actual), float(dt_attempted))) + del window[:-_STEP_PROGRESS_WINDOW] + + share = dt_actual / dt_attempted if dt_attempted > 0.0 else 0.0 + if share < _STEP_PROGRESS_MIN_SHARE: + log.warning( + ' that is %.2f%% of the interval this step asked for', + 100.0 * share, + ) + + if len(window) < _STEP_PROGRESS_WINDOW: + return + advanced = sum(step for step, _ in window) + requested = sum(asked for _, asked in window) + covered = advanced / requested if requested > 0.0 else 0.0 + if covered >= _STEP_PROGRESS_MIN_SHARE: + return + + # Cleared so a resumed run starts its own window rather than + # inheriting a verdict it cannot check. + interior_o.aragog_step_progress = [] + raise InteriorStalledError( + f'Over its last {_STEP_PROGRESS_WINDOW} steps the interior advanced ' + f'{advanced:.3e} yr of the {requested:.3e} yr those steps were given ' + f'({100.0 * covered:.3f}%), reaching t={hf_row.get("Time", 0.0):.3e} yr. ' + 'The run is not crossing the phase change, it is stopping at it. The ' + 'scipy integrator does this where SUNDIALS CVODE integrates through: ' + 'check `proteus doctor` for the CVODE solver and install it with ' + '`bash tools/get_cvode.sh` if it is missing.' + ) + @staticmethod def _build_helpfile_output( out: SolverOutput, diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index c0fe03dd9..3f476e69a 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -566,6 +566,15 @@ def __init__(self, nlev_b: int, spider_dir=None, eos_dir=None): self.spider_fail_count = 0 self.aragog_fail_count = 0 + # Rolling record of what the interior's recent steps covered, as + # (advanced, requested) pairs in years. Kept separately from the + # failure counters above because these are all valid solves; what a + # run of them need not be is progress, and a run that covers almost + # none of the time it asks for is stalled at a phase change rather + # than crossing it. Read as a total over the window, so a normal step + # between short ones cannot disguise a stall. + self.aragog_step_progress: list[tuple[float, float]] = [] + # True when the interior is running on a fallback (previous-step) # structure because the last Zalmoxis re-solve did not converge; set on # that fall-back and cleared on the next successful re-solve. Downstream diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 7ee51ae16..72c180596 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -2222,7 +2222,7 @@ def run_interior( sim_time, output = ReadSPIDER(dirs, config, hf_row['R_int'], interior_o) elif config.interior_energetics.module == 'aragog': - from proteus.interior_energetics.aragog import AragogRunner + from proteus.interior_energetics.aragog import AragogRunner, InteriorStalledError runner = AragogRunner(config, dirs, hf_row, hf_all, interior_o) try: @@ -2233,6 +2233,15 @@ def run_interior( write_data=write_data, ) interior_o.aragog_fail_count = 0 + except InteriorStalledError: + # Not absorbed like the failures below. The fallback there keeps + # the previous interior state for one step, on the expectation + # that the run steps past what caused it, and clears the failure + # streak as soon as one step succeeds. A stall is made of steps + # that do succeed, so it would clear that streak every time and + # the run would keep writing rows that go nowhere. + UpdateStatusfile(dirs, 21) + raise except RuntimeError as e: interior_o.aragog_fail_count += 1 log.warning( diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 0dfca9f08..9f0106b5f 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -15,11 +15,14 @@ from __future__ import annotations +from types import SimpleNamespace from unittest.mock import MagicMock, create_autospec, patch import numpy as np import pytest +from proteus.interior_energetics.aragog import InteriorStalledError + pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] @@ -801,3 +804,310 @@ def test_discard_snapshot_removes_only_the_named_time(tmp_path): (data / '410_int.nc').write_text('stale') assert discard_snapshot(str(tmp_path), 410.9) is True assert not (data / '410_int.nc').exists() + + +def _retry_ladder_runner( + *, + status, + dt_actual, + T_core=4000.0, + mass_tot=1.0, + dt_requested=100.0, + first_attempt_T_core=None, +): + """Build an AragogRunner whose solver returns one fixed result. + + The solver is a stand-in for the Aragog side of the call: the retry ladder + reads the solve result, the requested interval and the entropy hot-start + hooks, so the stub carries exactly those. Every attempt returns the same + result, which is what a step stopped by the same physical event on every + retry looks like. + + Parameters + ---------- + status : int + Solver status to report. 0 is a step integrated to its requested end, + 1 a terminal event, and a negative value an integration failure. + dt_actual : float + Interval the solver advanced [yr]. + T_core : float, optional + Core temperature the solve returns [K]. + mass_tot : float, optional + Planet mass [M_earth], which scales the core-temperature jump guard. + dt_requested : float, optional + Interval the step is given [yr]. + first_attempt_T_core : float, optional + Core temperature the first attempt returns [K]. Set it above the + sanity threshold to have that attempt rejected, so the accepted + result comes from a retry. + + Returns + ------- + tuple + The runner, an interior-state stub carrying the crawl counter, and an + ``attempts`` list the solver appends to on every ``solve()`` call. + """ + from proteus.interior_energetics.aragog import AragogRunner + + attempts: list[float] = [] + states = [SimpleNamespace(status=status, T_core=T_core, dt_actual=dt_actual)] + if first_attempt_T_core is not None: + # A first attempt the core-temperature guard rejects, so the accepted + # result comes from a retry whose interval the ladder already halved. + states.insert( + 0, SimpleNamespace(status=status, T_core=first_attempt_T_core, dt_actual=dt_actual) + ) + solver = SimpleNamespace( + parameters=SimpleNamespace( + solver=SimpleNamespace(start_time=0.0, end_time=dt_requested) + ), + _atol_sf=1.0, + get_state=lambda: states[min(len(attempts), len(states)) - 1], + get_current_dSdr_cmb=lambda: -1.0e-6, + set_initial_dSdr_cmb=lambda value: None, + set_initial_entropy=lambda S: None, + reset=lambda: None, + ) + solver.solve = lambda: attempts.append(float(solver.parameters.solver.end_time)) + + runner = AragogRunner.__new__(AragogRunner) + runner.aragog_solver = solver + runner._config = MagicMock() + runner._config.planet.mass_tot = mass_tot + interior_o = SimpleNamespace(aragog_step_progress=[], _last_entropy=None) + return runner, interior_o, attempts + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_a_step_stopped_by_the_terminal_event_is_accepted_as_it_stands(): + """A step the solver cut short at a physical event is kept, not retried. + + Physical scenario: the mantle reaches the onset of crystallization at the + bottom of the magma ocean, and the interior solver stops there rather than + integrating the melt fraction through the phase change in one step. The + state up to that point is a valid solution of the same equations; the step + is simply shorter than the coupling asked for. + + Contract clause: the coupling advances by the interval actually + integrated, so a shortened step is carried correctly. Retrying it would + spend the whole ladder on a step that was never wrong, because the same + event fires again at the same place however small the step is. + + Verifies: + - The result is returned on the first attempt, so the solver is called + once rather than run down the ladder. + - The advance is positive and no longer than the step requested, which is + what lets the coupling clock follow the interior rather than run ahead + of it. + - A step that covers a usable share of its interval leaves no crawl count + behind, so an isolated shortened step costs the run nothing. + - A step integrated to its requested end is still accepted the same way, + so the ordinary path did not move. + """ + runner, interior_o, attempts = _retry_ladder_runner(status=1, dt_actual=8.0) + out = runner._solve_with_retry({'Time': 2.15e5, 'T_cmb': 4000.0}, interior_o) + + assert len(attempts) == 1, ( + f'the shortened step was retried {len(attempts)} times; the event that ' + 'stopped it fires again at the same place, so the ladder cannot help' + ) + # Time advances, and by no more than was asked for. A zero or negative + # advance would leave the coupled loop standing still or moving backwards. + assert out.dt_actual > 0.0 + assert out.dt_actual <= 100.0 + assert interior_o.aragog_step_progress == [(8.0, 100.0)], ( + 'the step was not recorded against what it was given, so a stall ' + 'cannot be read from the ground the run covers' + ) + + full_step, full_interior, full_attempts = _retry_ladder_runner(status=0, dt_actual=100.0) + out_full = full_step._solve_with_retry({'Time': 2.15e5, 'T_cmb': 4000.0}, full_interior) + assert len(full_attempts) == 1 + assert out_full.dt_actual == pytest.approx(100.0, rel=1e-12) + + +@pytest.mark.unit +def test_a_step_that_never_advanced_is_still_refused(): + """The ladder still refuses results that carry no usable state. + + Contract clause: accepting a shortened step is conditioned on the step + having advanced. A terminal event that fires at the start of every attempt + returns no new state at all, and accepting it would leave the coupled loop + stalled at the same time forever, so it must exhaust the ladder and hand + over to the wrapper's skip-step fallback. + + Verifies: + - A terminal event with no advance runs the full ladder and raises, naming + the no-advance case rather than reporting a bare status. + - A negative advance is refused the same way. It would otherwise move the + coupling clock backwards, since the clock is set from the advance the + solver reports. + - An integration failure is refused as before, so the acceptance is scoped + to the terminal event rather than to any non-zero status. + - A shortened step whose core temperature jumped past the sanity threshold + is refused too, and the run is told that is what rejected it rather than + being sent after an event that did nothing wrong. + """ + stalled, interior_o, attempts = _retry_ladder_runner(status=1, dt_actual=0.0) + with pytest.raises(RuntimeError, match='without advancing') as excinfo: + stalled._solve_with_retry({'Time': 2.15e5, 'T_cmb': 4000.0}, interior_o) + assert len(attempts) == 6, ( + f'the ladder stopped after {len(attempts)} attempts; a step that never ' + 'advanced must use its retries before the run gives up on it' + ) + assert 'terminal event' in str(excinfo.value) + + backwards, back_interior, back_attempts = _retry_ladder_runner(status=1, dt_actual=-4.0) + with pytest.raises(RuntimeError): + backwards._solve_with_retry({'Time': 2.15e5, 'T_cmb': 4000.0}, back_interior) + assert len(back_attempts) == 6, ( + 'a step reporting a negative advance was accepted; the coupling clock ' + 'is set from that advance, so the run would step backwards in time' + ) + + failed, failed_interior, failed_attempts = _retry_ladder_runner(status=-1, dt_actual=8.0) + with pytest.raises(RuntimeError, match='status=-1'): + failed._solve_with_retry({'Time': 2.15e5, 'T_cmb': 4000.0}, failed_interior) + assert len(failed_attempts) == 6 + + # The core-temperature jump guard applies to the shortened step as well: + # 12000 K against a 4000 K prior state is a corrupted solve whatever the + # status says, and the message has to say so rather than blame the event. + jumped, jumped_interior, jumped_attempts = _retry_ladder_runner( + status=1, dt_actual=8.0, T_core=12000.0 + ) + with pytest.raises(RuntimeError, match='T_core jump') as jump_info: + jumped._solve_with_retry({'Time': 2.15e5, 'T_cmb': 4000.0}, jumped_interior) + assert len(jumped_attempts) == 6 + assert 'without advancing' not in str(jump_info.value), ( + 'the step advanced 8 yr on every attempt, so reporting it as one that ' + 'never advanced sends anyone reading the abort after the wrong thing' + ) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_a_run_that_covers_almost_none_of_its_time_is_stopped(): + """Steps that cover almost nothing, over a run of them, end the run. + + Physical scenario: the interior meets the crystallization front and the + integrator stops at it nearly every time it is called, advancing a sliver + of the interval it was given. Every solve succeeds, so nothing reports a + failure, while the run covers a few years of evolution per thousand it + asks for and never crosses the front. + + Contract clause: a shortened step is accepted because it is real progress. + A run that covers under a percent of the time it asks for is not, and it + has to stop loudly rather than spend a night of wall time going nowhere. + The measure is the ground covered over a window of steps, not a run of + consecutive short ones, because a normal step every so often would + otherwise clear the count while the run still goes nowhere. + + Verifies: + - The run survives while the window is filling, so a stiff patch it works + through costs nothing. + - It is stopped once the window is full and the covered share is below the + threshold, with a message giving the time advanced against the time + requested and naming the solver that integrates through the front. + - A crawl interrupted by an ordinary step every other step is stopped too, + which a consecutive-run count would let through. + - A run that covers a usable share is left alone, however many of its + steps the event shortened. + """ + from proteus.interior_energetics.aragog import ( + _STEP_PROGRESS_MIN_SHARE, + _STEP_PROGRESS_WINDOW, + ) + + hf_row = {'Time': 4.85e2, 'T_cmb': 4000.0} + # 2.3e-4 yr of a 100 yr step, the advance a real run showed at the front. + crawl = 2.3e-4 + assert crawl / 100.0 < _STEP_PROGRESS_MIN_SHARE, 'the probe step is not a crawl' + + runner, interior_o, _ = _retry_ladder_runner(status=1, dt_actual=crawl) + for step in range(_STEP_PROGRESS_WINDOW - 1): + runner._solve_with_retry(hf_row, interior_o) + assert len(interior_o.aragog_step_progress) == step + 1, ( + 'the progress window is not filling, so the stall would be read ' + 'from the wrong number of steps' + ) + + with pytest.raises(InteriorStalledError, match='steps the interior advanced') as excinfo: + runner._solve_with_retry(hf_row, interior_o) + message = str(excinfo.value) + assert 'cvode' in message.lower(), ( + 'the stop does not name the solver that integrates through the front, ' + 'so the operator is left without the remedy' + ) + assert 'get_cvode.sh' in message + + # A crawl broken by an ordinary step every other step covers 100 yr of + # every 20000 yr it asks for. That is still going nowhere, and a count of + # consecutive short steps would never reach its limit here. + alternating = SimpleNamespace(aragog_step_progress=[], _last_entropy=None) + crawler, _, _ = _retry_ladder_runner(status=1, dt_actual=crawl) + stepper, _, _ = _retry_ladder_runner(status=1, dt_actual=1.0) + with pytest.raises(InteriorStalledError): + for step in range(_STEP_PROGRESS_WINDOW): + which = stepper if step % 2 else crawler + which._solve_with_retry(hf_row, alternating) + + # A run that covers half of what it asks for is left alone, even though + # every one of its steps was cut short by the event. + healthy = SimpleNamespace(aragog_step_progress=[], _last_entropy=None) + halver, _, _ = _retry_ladder_runner(status=1, dt_actual=50.0) + for _ in range(2 * _STEP_PROGRESS_WINDOW): + halver._solve_with_retry(hf_row, healthy) + assert len(healthy.aragog_step_progress) == _STEP_PROGRESS_WINDOW, ( + 'the window grew past its length, so an old stretch of the run would ' + 'keep weighing on the verdict' + ) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_progress_is_weighed_against_what_the_coupling_asked_for(): + """A step accepted on a retry is scored against the coupling's interval. + + Contract clause: the stall measure compares the time the interior covered + against the time the coupling gave it. The retry ladder halves that + interval on every rejected attempt, so scoring against the attempt's own + interval would credit a step that needed five retries with covering + thirty-two times more of the run than it did. Those are the steps a stall + is made of, so the measure has to hold the original interval. + + Verifies: + - A step rejected once and accepted on the halved retry records the + interval the coupling asked for, not the halved one. + - The advance recorded is the one the solver reported, so only the + denominator is affected. + """ + from proteus.interior_energetics.aragog import _STEP_PROGRESS_MIN_SHARE + + asked = 100.0 + # Under the threshold against the interval the coupling asked for, over it + # against the halved retry interval, so the two readings disagree. + advanced = 0.8 + runner, interior_o, attempts = _retry_ladder_runner( + status=1, + dt_actual=advanced, + dt_requested=asked, + first_attempt_T_core=12000.0, + ) + runner._solve_with_retry({'Time': 4.85e2, 'T_cmb': 4000.0}, interior_o) + + assert len(attempts) == 2, ( + f'the step was accepted on attempt {len(attempts)}; this test needs a ' + 'rejected first attempt so the retry halves the interval' + ) + assert interior_o.aragog_step_progress == [(advanced, asked)], ( + f'the step was recorded as {interior_o.aragog_step_progress}, scoring ' + f'{advanced} yr against the halved retry interval rather than the ' + f'{asked} yr the coupling asked for, which makes a stalling run look ' + 'twice as healthy for every retry it takes' + ) + # The recorded share is what the stall measure reads, and here it is + # under the threshold: against the halved interval it would not be. + assert advanced / asked < _STEP_PROGRESS_MIN_SHARE + assert advanced / (0.5 * asked) > _STEP_PROGRESS_MIN_SHARE diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index c3f4b6a9e..230a72911 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -6147,3 +6147,72 @@ def test_the_remelt_injection_is_weighed_against_the_impact_energy(caplog): ) assert 'outside' not in caplog.text + + +@pytest.mark.unit +def test_a_stalled_interior_ends_the_run_instead_of_being_absorbed(): + """A stall is passed up; an ordinary solver failure is still absorbed. + + Contract clause: the wrapper absorbs a failed interior step by keeping the + previous state for that step, because the run is expected to move past + whatever caused it, and it clears the failure streak on the next success. + A stalled interior is made of steps that succeed, so absorbing it would + clear that streak every time and the run would go on writing rows that + carry it nowhere. It is raised as its own type and passed up. + + Verifies: + - The stall reaches the caller rather than being turned into a + keep-previous-state step. + - The consecutive-failure counter is untouched by it, so it cannot be + confused with a solver failure streak. + - The status file records the interior-model error code, so an outside + observer sees why the run stopped. + - An ordinary RuntimeError from the same call is still absorbed, which is + what makes the distinction meaningful rather than a blanket change. + """ + from proteus.interior_energetics.aragog import InteriorStalledError + from proteus.interior_energetics.wrapper import run_interior + + config = _make_run_interior_config(prevent_warming=False, module='aragog') + hf_all, hf_row = _make_run_interior_state() + + def _drive(error): + interior_o = _mock_interior_o() + interior_o.ic = 2 + runner = MagicMock() + runner.run_solver.side_effect = error + with ( + patch('proteus.interior_energetics.aragog.AragogRunner', return_value=runner), + patch('proteus.interior_energetics.wrapper.UpdateStatusfile') as status, + patch('proteus.interior_energetics.wrapper.update_planet_mass'), + patch('proteus.interior_energetics.timestep.next_step', return_value=10.0), + ): + raised = None + try: + run_interior({}, config, hf_all, dict(hf_row), interior_o, MagicMock()) + except Exception as exc: # noqa: BLE001 - the type is the assertion + raised = exc + return raised, interior_o, status + + stalled, stalled_interior, stalled_status = _drive( + InteriorStalledError('the interior has taken 10 consecutive steps') + ) + assert isinstance(stalled, InteriorStalledError), ( + f'the stall was absorbed and the run continued (raised {stalled!r}); ' + 'every following step would stall the same way' + ) + assert stalled_interior.aragog_fail_count == 0, ( + 'the stall was counted as a solver failure, so a later genuine ' + 'failure streak would abort one step early' + ) + assert 21 in [call.args[1] for call in stalled_status.call_args_list], ( + 'the status file does not record the interior-model error code, so a ' + 'stalled run looks the same from outside as one still going' + ) + + absorbed, absorbed_interior, _ = _drive(RuntimeError('retry ladder exhausted')) + assert absorbed is None, ( + f'an ordinary solver failure was passed up ({absorbed!r}) instead of ' + 'being absorbed by the keep-previous-state fallback' + ) + assert absorbed_interior.aragog_fail_count == 1 diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 43c6e7974..7f24ba3ad 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -41,6 +41,7 @@ _run_fix_command, _Tee, _write_failure_log, + check_cvode, check_env_var, check_fwl_data, check_git_module, @@ -1615,3 +1616,77 @@ def test_doctor_cli_exits_zero_when_clean(self, tmp_path, monkeypatch): assert result.exit_code == 0 # Discrimination: no failure exit means no support prompt was printed. assert 'dev@proteus-framework.org' not in result.output + + +class TestCheckCvode: + """check_cvode reports whether Aragog can use its production integrator.""" + + def test_pass_when_the_wrapper_imports(self): + """An importable wrapper passes and suggests nothing. + + Contract clause: the check exists to surface a silent fallback, so on + a healthy install it must be quiet. A fix command on a passing check + would put an unnecessary conda build in front of `proteus update`. + """ + with ( + patch('proteus.doctor.importlib.util.find_spec', return_value=object()), + patch('proteus.doctor.importlib.import_module', return_value=object()), + ): + r = check_cvode() + assert r.status == PASS + assert r.fix_cmd is None + assert r.category == 'environment' + + def test_warn_when_the_wrapper_is_absent(self): + """A missing wrapper warns and names the script that installs it. + + Contract clause: without the wrapper Aragog integrates with scipy + Radau, which is a different solver, so the operator has to be told + before a long coupled run rather than only in the per-solve log line. + """ + with patch('proteus.doctor.importlib.util.find_spec', return_value=None): + r = check_cvode() + assert r.status == WARN + assert r.fix_cmd == 'bash tools/get_cvode.sh' + # Runnable from the repo root, so `proteus update` can apply it + # rather than only printing it. + assert r.auto_fixable is True + # The message says what the run does instead, not just that something + # is missing. + assert 'Radau' in r.message + + def test_warn_when_the_wrapper_is_installed_but_does_not_load(self): + """A wrapper that imports and then fails warns, naming the failure. + + Physical scenario for the operator: the wrapper is compiled against + the SUNDIALS C library, so an ABI or version mismatch leaves the + package importable by name while the extension fails to load. That + state passes a presence check and still falls back to Radau, so it + has to be caught here. + """ + with ( + patch('proteus.doctor.importlib.util.find_spec', return_value=object()), + patch( + 'proteus.doctor.importlib.import_module', + side_effect=ImportError('libsundials_cvode.so: cannot open'), + ), + ): + r = check_cvode() + assert r.status == WARN + assert 'ImportError' in r.message + assert r.fix_cmd == 'bash tools/get_cvode.sh' + + def test_the_check_is_wired_into_the_diagnose_run(self): + """`proteus doctor` runs the check rather than only defining it. + + A check that is never called is the failure mode this guards: the + function can be correct and the operator still never sees it. + """ + with ( + patch('proteus.doctor._dependency_specs', return_value={}), + patch('proteus.doctor._module_pins', return_value={}), + ): + results = run_all_checks() + cvode = [r for r in results if r.name == 'cvode'] + assert len(cvode) == 1, f'expected one cvode check, found {len(cvode)}' + assert cvode[0].category == 'environment' From 4337d41cc9e2e8dbc2c4786d3345dbb4fd501b0a Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 29 Jul 2026 10:06:56 +0200 Subject: [PATCH 36/71] Match a resumed row to the snapshot that was written for it Snapshot files are named on the simulation time rounded to a whole year, while the helpfile keeps it in full. Two rows written inside one year therefore land on the same filename and one overwrites the other, so the name alone cannot say which row a file belongs to. The resume selector took it as though it could: it accepted a row whenever a file with that row's derived name opened, and moved aside whatever existed when a row's pair was incomplete. Two ways that goes wrong. A run killed between an interior write and the helpfile row it belongs to leaves a file whose name rounds onto the previous row and whose contents are the next step's mantle, and the resume continues from a state the helpfile has no row for, with nothing to signal it. And a row dropped for an incomplete pair took the shared file with it, stripping the backing from a row that would otherwise have been resumable. Both interior writers already record the time they wrote, Aragog as a `time` variable in the netCDF and SPIDER as `time_years` in its JSON, so the selector now reads it back and matches it against the row. A file recording a different time is not that row's half, whatever its name, and is neither resumed from nor moved aside on that row's behalf. A file that records no time is accepted on its name exactly as before, which is what a directory written before the field existed looks like, and what the atmosphere half does today since AGNI writes no time into its own snapshot. The margin is sized from the helpfile's own serialisation, which holds eleven significant digits and so moves a time by up to 4.94e-11 of its magnitude; four times that clears the round trip and stays as tight as the stored data allows. Because it is relative, it has to be bounded as well: at a Gyr an unbounded version would grow to a whole year and accept every neighbouring row, which is where runs spend most of their time. Past a few Gyr the helpfile cannot separate two rows inside one filename at all, and there the file is accepted on its name rather than a resumable row being refused. On a real run stopped on a giant impact and resumed, the recorded times match their rows and the resume lands where it did before. The naming itself is unchanged, so several rows inside one year still keep only the last one's snapshot and a resume still walks back to it. That half is issue #795. --- src/proteus/utils/coupler.py | 115 ++++++++++++++++++++- tests/utils/test_coupler.py | 188 +++++++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+), 4 deletions(-) diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index 32718f939..4f6cffea1 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -1272,6 +1272,94 @@ def _snapshot_readable(path: str) -> bool: return _netcdf_readable(path) +def _snapshot_time(path: str) -> float | None: + """Simulation time a snapshot file records for itself [yr], if it does. + + The writers name their files on the time rounded to a whole year, so the + name cannot tell two steps inside one year apart. Both interior writers + also record the time they wrote: Aragog's netCDF carries a ``time`` + variable and SPIDER's JSON a ``time_years`` entry. Reading it back is what + lets a resume tell whether a file is the row's own state or one a later + step left under the same name. + + Parameters + ---------- + path : str + Snapshot file to read. + + Returns + ------- + float or None + The recorded time, or None when the file records none, which is what + a directory written before the field existed looks like. + """ + # Imported outside the try for the same reason as the readability probe: + # a missing netCDF4 must raise rather than read as "no file records a + # time", which would quietly restore the name-only behaviour everywhere. + from netCDF4 import Dataset + + try: + if path.endswith('.json'): + with open(path) as fh: + recorded = json.load(fh).get('time_years') + return None if recorded is None else float(recorded) + with Dataset(path) as ds: + if 'time' not in ds.variables: + return None + return float(ds['time'][0]) + except Exception: + # Unreadable is not this function's call to make: the readability + # probe reports that, and reporting it here as well would turn a + # corrupt file into a silently skipped one. + return None + + +def _snapshot_belongs_to(path: str, time: float) -> bool: + """Whether a snapshot is the one written for a simulation time. + + True when the file records that time, and also when it records none: a + file without the field cannot be told apart from its neighbours, so it is + accepted on its name, which is the behaviour every directory written + before the field existed relies on. True as well once the simulation time + is large enough that the helpfile's own precision cannot separate two rows + inside one filename, which is a few Gyr in. + + Parameters + ---------- + path : str + Snapshot file to check. + time : float + Simulation time of the helpfile row [yr]. + + Returns + ------- + bool + Whether the file can be this row's half. + """ + recorded = _snapshot_time(path) + if recorded is None: + return True + + # The row's time has been through the helpfile, which serialises at + # '%.10e' and so holds eleven significant digits: a round trip moves it by + # up to 4.94e-11 of its own magnitude. The margin has to clear that, and a + # factor of four does, while staying as tight as the stored data allows. + resolution = 5.0e-11 * max(1.0, abs(time)) + tolerance = 4.0 * resolution + + # What the margin must stay under is the one-year bucket the filenames are + # keyed on, since two rows sharing a name are what this tells apart. Past + # a few Gyr the helpfile's own resolution is itself a good fraction of a + # year, so no margin can both clear the round trip and separate two rows + # inside one bucket. There the file is accepted on its name, the behaviour + # this check refines rather than replaces, instead of rejecting rows that + # are perfectly resumable. + if tolerance >= 0.5: + return True + + return abs(recorded - time) <= tolerance + + def _interior_snapshot_names(time: float, interior_module: str) -> list[str]: """Interior snapshot filename candidates for a simulation time, per writer. @@ -1346,6 +1434,16 @@ def select_resumable_snapshot( an adjacent row's atmosphere file. See ``_interior_snapshot_names`` / ``_atm_snapshot_names``. + Every convention keys the name on a whole year, so rows less than a year + apart derive the same filename and one overwrites the other. The name + alone therefore cannot say which row a file belongs to. The interior + writers record the time they wrote inside the file (a ``time`` variable in + the netCDF, ``time_years`` in SPIDER's JSON), so where that is present it + is what the row is matched against: a file left by a different step is not + accepted as this row's half, and the walk continues past it. A file that + carries no recorded time, which is what a directory written before the + field existed looks like, is accepted on its name as before. + Parameters ---------- output_dir : str @@ -1384,6 +1482,7 @@ def select_resumable_snapshot( dropped: list[int] = [] quarantined: list[tuple[str, str]] = [] # (moved_to, original) for rollback keep_idx = None + for i in range(len(times) - 1, -1, -1): t = times[i] int_paths = [ @@ -1396,15 +1495,23 @@ def select_resumable_snapshot( ) # An empty interior candidate list means the interior module writes no # snapshot (dummy/boundary): that half imposes no resume constraint. - int_ok = (not int_paths) or any(_snapshot_readable(p) for p in int_paths) - atm_ok = (not require_atm) or any(_snapshot_readable(p) for p in atm_paths) + # A file that records a different time is another step's, so it does + # not count as this row's half however well its name fits. + int_ok = (not int_paths) or any( + _snapshot_readable(p) and _snapshot_belongs_to(p, t) for p in int_paths + ) + atm_ok = (not require_atm) or any( + _snapshot_readable(p) and _snapshot_belongs_to(p, t) for p in atm_paths + ) if int_ok and atm_ok: keep_idx = i break # Incomplete pair: move whichever candidate halves exist aside so the - # interior / atmosphere latest-file globs cannot pick them up. + # interior / atmosphere latest-file globs cannot pick them up. A file + # that records a different time is left where it is: it belongs to + # another step, and dropping this row must not take it down as well. for p in int_paths + atm_paths: - if os.path.exists(p): + if os.path.exists(p) and _snapshot_belongs_to(p, t): dst = p + '.incomplete' os.replace(p, dst) quarantined.append((dst, p)) diff --git a/tests/utils/test_coupler.py b/tests/utils/test_coupler.py index 8b2454baa..982d896d4 100644 --- a/tests/utils/test_coupler.py +++ b/tests/utils/test_coupler.py @@ -52,7 +52,9 @@ _interior_snapshot_names, _netcdf_readable, _populate_energy_residual, + _snapshot_belongs_to, _snapshot_readable, + _snapshot_time, get_proteus_directories, print_citation, print_module_configuration, @@ -3136,3 +3138,189 @@ def test_a_helpfile_missing_physical_state_is_refused_not_zero_filled(): loaded = ReadHelpfileFromCSV(tmpdir) assert loaded['M_accreted_rock'].iloc[-1] == pytest.approx(0.0, abs=1e-30) + + +def _write_timed_nc(path: str, time: float | None) -> str: + """Create a valid interior snapshot recording ``time``, or none at all.""" + from netCDF4 import Dataset + + with Dataset(path, 'w') as ds: + ds.createDimension('x', 1) + if time is not None: + ds.createVariable('time', 'f8') + ds['time'][0] = float(time) + return path + + +@pytest.mark.unit +def test_snapshot_time_reads_what_the_writer_recorded(tmp_path): + """The recorded time is read back from either writer, or reported absent. + + Contract clause: the snapshot filenames are keyed on a whole year, so the + name cannot tell two steps inside one year apart. Both interior writers + record the time they wrote, and reading it back is what lets a resume + tell a row's own state from one a neighbouring step left behind. A file + that records nothing has to be reported as such rather than guessed at, + because that is what every directory written before the field existed + looks like. + + Verifies: + - The netCDF ``time`` variable and SPIDER's ``time_years`` entry are both + read, including a fractional time the filename cannot express. + - A file of either kind without the field reports None rather than zero, + which would otherwise read as a snapshot from the start of the run. + - A corrupt file and a missing one report None instead of raising, so the + readability probe stays the one place that judges those. + """ + assert _snapshot_time(_write_timed_nc(str(tmp_path / 'a_int.nc'), 70.8)) == pytest.approx( + 70.8, rel=1e-12 + ) + assert _snapshot_time(_write_timed_nc(str(tmp_path / 'b_int.nc'), None)) is None + + spider = str(tmp_path / 'c.json') + with open(spider, 'w') as fh: + json.dump({'time_years': 70.2, 'data': {}}, fh) + assert _snapshot_time(spider) == pytest.approx(70.2, rel=1e-12) + assert _snapshot_time(_write_valid_json(str(tmp_path / 'd.json'))) is None + + assert _snapshot_time(_write_corrupt_nc(str(tmp_path / 'e_int.nc'))) is None + assert _snapshot_time(str(tmp_path / 'missing_int.nc')) is None + + +@pytest.mark.unit +def test_snapshot_belongs_to_matches_the_row_it_was_written_for(tmp_path): + """A file counts as a row's own only when it records that row's time. + + Contract clause: a step less than a year from its neighbour writes to the + same filename, so a file found under a row's name may be another step's. + Matching on the recorded time is what separates them, and a file that + records nothing keeps the old behaviour of being accepted on its name. + + Verifies: + - The row's own time matches and a neighbouring step's does not, at a + separation the filename itself cannot resolve. + - A file with no recorded time is accepted, so directories written before + the field existed still resume. + - The tolerance admits the helpfile's own serialisation round trip and + still rejects a step a thousandth of a year away. + """ + own = _write_timed_nc(str(tmp_path / 'own_int.nc'), 70.2) + other = _write_timed_nc(str(tmp_path / 'other_int.nc'), 70.8) + legacy = _write_timed_nc(str(tmp_path / 'legacy_int.nc'), None) + + assert _snapshot_belongs_to(own, 70.2) is True + assert _snapshot_belongs_to(other, 70.2) is False, ( + 'a snapshot written 0.6 yr later was accepted as this row, which is ' + 'the mismatch the whole-year filename cannot rule out' + ) + assert _snapshot_belongs_to(legacy, 70.2) is True + + # The helpfile round-trips Time through '%.10e', so a restored row differs + # from the written value in about the eleventh digit; that must still match. + assert _snapshot_belongs_to(own, float('%.10e' % 70.2)) is True + # A step a thousandth of a year away is a different step, not a round trip. + assert _snapshot_belongs_to(own, 70.201) is False + + # The margin is relative to the time, because the helpfile's precision is, + # so it has to be checked where a run actually ends up. At 1 Gyr a round + # trip moves the row by about 0.05 yr and must still match, while a step + # 0.7 yr away shares the same filename and must not: a margin that grew to + # a whole year there would accept every neighbour and leave the check + # doing nothing exactly where runs spend most of their time. + gyr = 1.0e9 + far = _write_timed_nc(str(tmp_path / 'gyr_int.nc'), gyr) + assert _snapshot_belongs_to(far, float('%.10e' % gyr)) is True + assert _snapshot_belongs_to(far, gyr + 0.7) is False + assert _snapshot_belongs_to(far, gyr + 0.2) is False + + # Past a few Gyr the helpfile cannot resolve two rows inside one filename + # at all, so the file is accepted on its name rather than a row that is + # perfectly resumable being refused. + beyond = 1.0e10 + unresolvable = _write_timed_nc(str(tmp_path / 'beyond_int.nc'), beyond) + assert _snapshot_belongs_to(unresolvable, beyond + 0.7) is True + + +@pytest.mark.unit +def test_select_resumable_snapshot_rejects_a_later_steps_snapshot(tmp_path): + """A snapshot left by a step the helpfile never recorded is not resumed from. + + Physical scenario: the interior writes its snapshot during a step and the + helpfile row is written at the end of it, so a run killed in between + leaves a file whose name rounds onto the previous row while its contents + are the next step's mantle. Resuming there would continue from a state + the helpfile has no row for, and nothing in the filename says so. + + Verifies: + - The row is rejected and the walk continues to an earlier complete one. + - The same directory with the file recording the row's own time resumes at + that row, so the rejection is the recorded time doing its work rather + than the row being unusable for another reason. + """ + data = tmp_path / 'data' + data.mkdir() + for t in (0, 1, 2): + _write_timed_nc(str(data / f'{t}_int.nc'), float(t)) + # Named for the 70.2 row, holding the state written at 70.8. + _write_timed_nc(str(data / '70_int.nc'), 70.8) + + out, dropped = select_resumable_snapshot( + str(tmp_path), _hf_times([0, 1, 2, 70.2]), require_atm=False, interior_module='aragog' + ) + assert dropped == [70] + assert out.iloc[-1]['Time'] == pytest.approx(2.0), ( + f'resumed at {out.iloc[-1]["Time"]} from a snapshot written 0.6 yr later, ' + 'so the interior would continue from a state the helpfile has no row for' + ) + + # Discrimination: the same row with its own snapshot is resumable. + _write_timed_nc(str(data / '70_int.nc'), 70.2) + kept, none_dropped = select_resumable_snapshot( + str(tmp_path), _hf_times([0, 1, 2, 70.2]), require_atm=False, interior_module='aragog' + ) + assert none_dropped == [] + assert kept.iloc[-1]['Time'] == pytest.approx(70.2) + + +@pytest.mark.unit +def test_select_resumable_snapshot_leaves_another_steps_file_in_place(tmp_path): + """Dropping a row does not take a file that belongs to a different step. + + Contract clause: a row without a complete pair has its own snapshot halves + moved aside so the modules' latest-file globs cannot pick them up. A file + that records a different time is not one of those halves, whatever its + name suggests, and removing it would destroy state the run may still need. + + Verifies: + - The dropped row's own atmosphere half is quarantined and swept, as + before. + - The interior file recording another step's time survives untouched, and + still holds that step's time afterwards. + """ + data = tmp_path / 'data' + data.mkdir() + for t in (0, 1, 2): + _write_timed_nc(str(data / f'{t}_int.nc'), float(t)) + _write_timed_nc(str(data / f'{t}_atm.nc'), float(t)) + _write_timed_nc(str(data / '70_int.nc'), 70.8) # a later step's interior + _write_timed_nc(str(data / '70_atm.nc'), 70.2) # the dropped row's own half + + out, dropped = select_resumable_snapshot( + str(tmp_path), + _hf_times([0, 1, 2, 70.2]), + require_atm=True, + interior_module='aragog', + atmos_module='agni', + ) + + assert dropped == [70] + assert out.iloc[-1]['Time'] == pytest.approx(2.0) + assert not (data / '70_atm.nc').exists(), ( + "the dropped row's own atmosphere half was left where a latest-file " + 'glob can still reach it' + ) + assert (data / '70_int.nc').is_file(), ( + 'dropping the row removed a snapshot belonging to a different step, ' + 'which is state no other file carries' + ) + assert _snapshot_time(str(data / '70_int.nc')) == pytest.approx(70.8, rel=1e-12) From 1e400ad33ab25abed30d15cc8d04060e509d0536 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 29 Jul 2026 12:31:27 +0200 Subject: [PATCH 37/71] Cover the SPIDER half of the resume snapshot match The resume matches a row against the time its snapshot records, and for SPIDER that time is `time_years` inside the JSON, the same field the coupling clock is read from. Nothing exercised that path: the netCDF side was covered from both ends while the JSON side was read only through the helper, so a reader that silently returned nothing for every SPIDER run would have gone unnoticed and every such run would have quietly gone back to matching on the filename. The new tests drive the selector over a SPIDER history: a JSON recording another step's time is not accepted for the row, the same directory with the row's own time resumes there, a JSON with no recorded time is accepted on its name as an older output directory needs, and a dropped row leaves a JSON belonging to another step where it is. The two times in those fixtures round into the same year, which is the collision SPIDER can actually produce, since it names the file for the rounded time and records the achieved one from the same value in the same write. The recorded time is read whether it arrives as a JSON number, which is what SPIDER writes, or as a string, which the surrounding file uses for other quantities. --- tests/utils/test_coupler.py | 132 ++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tests/utils/test_coupler.py b/tests/utils/test_coupler.py index 982d896d4..0665455ff 100644 --- a/tests/utils/test_coupler.py +++ b/tests/utils/test_coupler.py @@ -3324,3 +3324,135 @@ def test_select_resumable_snapshot_leaves_another_steps_file_in_place(tmp_path): 'which is state no other file carries' ) assert _snapshot_time(str(data / '70_int.nc')) == pytest.approx(70.8, rel=1e-12) + + +def _write_spider_json(path: str, time: float | str | None) -> str: + """Create a SPIDER-shaped interior snapshot recording ``time_years``. + + SPIDER writes the achieved time at the top level of its JSON, which is + what ``ReadSPIDER`` reads back in place of the rounded filename. Passing + None omits the field, which is what an older output directory looks like. + """ + payload: dict = {'step': 7, 'data': {'S': [1.0, 2.0]}} + if time is not None: + payload['time_years'] = time + with open(path, 'w') as fh: + json.dump(payload, fh) + return path + + +@pytest.mark.unit +def test_snapshot_time_reads_a_spider_json_however_it_stores_the_number(tmp_path): + """SPIDER's recorded time is read whether it is a number or a string. + + Contract clause: the interior half of a SPIDER resume is a JSON file, and + the field the resume matches on is the same one ``ReadSPIDER`` uses for + the coupling clock, where it is read through a ``float`` for the same + reason. SPIDER writes it as a JSON number today; the surrounding file + carries other quantities as strings, so the reader takes either and a run + does not fall back to matching on the filename if that ever changes. + + Verifies: + - A numeric and a string ``time_years`` both read back as the same float. + - A file without the field reports None, so it is accepted on its name + rather than being read as a snapshot from time zero. + """ + numeric = _write_spider_json(str(tmp_path / 'a.json'), 70.2) + stringy = _write_spider_json(str(tmp_path / 'b.json'), '70.2') + legacy = _write_spider_json(str(tmp_path / 'c.json'), None) + + assert _snapshot_time(numeric) == pytest.approx(70.2, rel=1e-12) + assert _snapshot_time(stringy) == pytest.approx(70.2, rel=1e-12) + assert _snapshot_time(legacy) is None + + +@pytest.mark.unit +def test_select_resumable_snapshot_matches_a_spider_row_to_its_own_json(tmp_path): + """A SPIDER row resumes from the JSON written for it, not a neighbour's. + + Physical scenario: SPIDER names its snapshot for the time rounded to a + whole year and records the time it achieved inside, so two steps rounding + into the same year land on one file and the later one overwrites it. A + run killed between a write and the helpfile row it belongs to leaves that + file under a row whose state it does not hold, and resuming on the name + alone hands the run a mantle from a step the helpfile has no row for. + + Verifies: + - A JSON recording another step's time is not accepted for this row, and + the walk continues to a row whose own snapshot is there. + - The same directory with the JSON recording the row's own time resumes at + that row, so the rejection is the recorded time and not the row being + unusable. + - A JSON with no recorded time is accepted on its name, so output written + before the field was read still resumes. + """ + data = tmp_path / 'data' + data.mkdir() + for t in (0, 1, 2): + _write_spider_json(str(data / f'{t}.json'), float(t)) + # Named for the 70.2 row, holding the step SPIDER achieved at 70.4. + _write_spider_json(str(data / '70.json'), 70.4) + + out, dropped = select_resumable_snapshot( + str(tmp_path), _hf_times([0, 1, 2, 70.2]), require_atm=False, interior_module='spider' + ) + assert dropped == [70] + assert out.iloc[-1]['Time'] == pytest.approx(2.0), ( + f'resumed at {out.iloc[-1]["Time"]} from a JSON recording 70.4, so SPIDER ' + 'would restart from a state the helpfile has no row for' + ) + + _write_spider_json(str(data / '70.json'), 70.2) + kept, none_dropped = select_resumable_snapshot( + str(tmp_path), _hf_times([0, 1, 2, 70.2]), require_atm=False, interior_module='spider' + ) + assert none_dropped == [] + assert kept.iloc[-1]['Time'] == pytest.approx(70.2) + + _write_spider_json(str(data / '70.json'), None) + legacy, legacy_dropped = select_resumable_snapshot( + str(tmp_path), _hf_times([0, 1, 2, 70.2]), require_atm=False, interior_module='spider' + ) + assert legacy_dropped == [] + assert legacy.iloc[-1]['Time'] == pytest.approx(70.2) + + +@pytest.mark.unit +def test_select_resumable_snapshot_leaves_another_spider_steps_json_in_place(tmp_path): + """Dropping a SPIDER row does not take a JSON written for another step. + + Contract clause: a row without a complete pair has its own halves moved + aside so the module's latest-file glob cannot pick them up. A JSON that + records a different time is another step's, however closely its rounded + name fits this row, and removing it would destroy the only copy of that + step's interior state. + + Verifies: + - The row is dropped and its own atmosphere half is swept. + - The JSON recording another step's time is still on disk afterwards and + still records that step. + """ + data = tmp_path / 'data' + data.mkdir() + for t in (0, 1, 2): + _write_spider_json(str(data / f'{t}.json'), float(t)) + _write_timed_nc(str(data / f'{t}_atm.nc'), float(t)) + _write_spider_json(str(data / '70.json'), 70.4) # another step's interior + _write_timed_nc(str(data / '70_atm.nc'), 70.2) # the dropped row's own half + + out, dropped = select_resumable_snapshot( + str(tmp_path), + _hf_times([0, 1, 2, 70.2]), + require_atm=True, + interior_module='spider', + atmos_module='agni', + ) + + assert dropped == [70] + assert out.iloc[-1]['Time'] == pytest.approx(2.0) + assert not (data / '70_atm.nc').exists() + assert (data / '70.json').is_file(), ( + 'dropping the row deleted a SPIDER snapshot belonging to a different ' + 'step, which is state no other file carries' + ) + assert _snapshot_time(str(data / '70.json')) == pytest.approx(70.4, rel=1e-12) From 38be2693a34e6ccfb0f8eddb46d10041f2bfdbc4 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 29 Jul 2026 21:13:22 +0200 Subject: [PATCH 38/71] Start the accretion resume test from a condition it can afford The test asked for the interior condition that is molten at any planet mass, which is solved for rather than stated: the solver root-finds an adiabat against the melting curve, regenerating the EOS table on every evaluation, and it does that once at the start and again for the grown planet at the impact. On my machine that is two minutes. On a CI runner each evaluation costs over two, and the first solve alone spent fifty-three minutes across forty-six of them, so the file ran into the hour ceiling on both platforms and took the rest of the nightly shard down with it. A flat profile at 4000 K needs no solve and is molten for this planet, which is the property the re-melt depends on, so the run now asserts the mantle starts fully molten instead of the configuration promising it. The impact still adds heat, 3.6e30 J, about a tenth of the collision's kinetic energy, and the mantle it leaves behind is still 150 K above the one before it, which is what the resume checks read. The interior EOS table is also generated coarse. It is rebuilt for the grown planet at the impact, and this file reads which snapshot a resume takes rather than the table's own accuracy; the trajectory keeps its shape and every quantity asserted here is unchanged. Together that is 97 s locally against 459 s. The other Aragog tests in the same nightly shard run about twenty times slower on a runner than they do here, which puts this one around half an hour there, inside the tier ceiling and in line with what that shard already carries. --- .../integration/test_slow_accretion_resume.py | 56 ++++++++++++++++--- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/tests/integration/test_slow_accretion_resume.py b/tests/integration/test_slow_accretion_resume.py index 5521b5c7c..09806df90 100644 --- a/tests/integration/test_slow_accretion_resume.py +++ b/tests/integration/test_slow_accretion_resume.py @@ -30,7 +30,10 @@ Scope. The interior runs its production backend; every other module is on its dummy backend, which keeps two Aragog legs inside the tier budget and leaves -the interior snapshot as the only state channel under test. The atmosphere is +the interior snapshot as the only state channel under test. The mantle starts +on a flat temperature profile and the EOS table is generated coarse, both to +keep the wall time down; the run asserts the first is molten, and the second +changes no quantity this file reads. The atmosphere is dummy and writes no ``_atm.nc``, so the resume's atmosphere half imposes no constraint and the interior half is what decides where the run lands. @@ -51,10 +54,10 @@ from proteus.utils.constants import M_earth from proteus.utils.data import download_sufficient_data -# Slow tier. Two real Aragog legs, about 10 minutes locally, most of it in -# the two solves of the molten initial condition: one at the start and one -# for the grown planet at the impact. The 3600 s ceiling leaves room for -# slower CI runners. +# Slow tier. Two real Aragog legs, about 90 s locally. A CI runner takes +# roughly twenty times that on this kind of work, measured against the other +# Aragog tests in the same nightly shard, which puts the file around half an +# hour there and well inside the 3600 s ceiling. pytestmark = [pytest.mark.slow, pytest.mark.timeout(3600)] CONFIG = PROTEUS_ROOT / 'input' / 'dummy.toml' @@ -85,6 +88,17 @@ # generates, and Aragog refuses to guess a melting curve for it. MELTING_DIR = 'Monteux-600' +# Initial mantle temperature [K]. Hot enough that the whole mantle starts +# molten on this planet, which the run itself confirms below, and hot enough +# that re-applying it at the impact adds heat rather than removing it. +TSURF_INIT = 4000.0 + +# Interior EOS table resolution. Coarse on purpose: the table is regenerated +# for the grown planet at the impact, and this test reads which snapshot a +# resume takes rather than the table's own accuracy. +LOOKUP_NP = 150 +LOOKUP_NS = 60 + # Scratch directories created by Proteus construction, cleared after the # test. ``set_directories`` allocates one per runner and nothing in the @@ -138,10 +152,22 @@ def _make_runner(output_dir, stop_time): runner.config.interior_energetics.module = 'aragog' runner.config.interior_struct.melting_dir = MELTING_DIR # The re-melt re-applies the interior initial condition, so it only adds - # heat if that condition is molten for the grown planet. This one is, at - # any mass and on any melting curve, which is what makes the re-melt - # visible as a warming step below. - runner.config.planet.temperature_mode = 'liquidus_super' + # heat while that condition is hotter than the mantle it replaces. A flat + # profile at TSURF_INIT is, for this planet, and the test asserts the + # mantle it produces is fully molten rather than assuming it. The mode + # that guarantees a molten condition at any mass instead solves for it, + # which costs a root-find over the melting curve on every mass change and + # is what makes it too slow to run here. + runner.config.planet.temperature_mode = 'isothermal' + runner.config.planet.tsurf_init = TSURF_INIT + + # The interior EOS table is generated per planet mass, so the impact pays + # for a second one. This test is about which snapshot a resume reads, not + # about EOS fidelity, and the coarse table costs a third of the wall time + # while leaving the trajectory's shape and every quantity asserted below + # unchanged. + runner.config.interior_struct.zalmoxis.lookup_nP = LOOKUP_NP + runner.config.interior_struct.zalmoxis.lookup_nS = LOOKUP_NS runner.config.params.stop.solid.enabled = False runner.config.params.stop.time.minimum = 0.0 @@ -206,6 +232,8 @@ def test_a_run_stopped_on_an_impact_resumes_from_before_it(tmp_path): the truncated rows were recomputed rather than appended alongside. - The impact is applied exactly once across both legs, in the accreted rock ledger and in the planet mass the configuration carries. + - The mantle starts fully molten, so re-applying the initial condition at + the impact is a re-melt rather than a cooling reset. - The mantle carried past the impact is hotter than the mantle before it, in a run that cools on every other step. A resume that had loaded the discarded snapshot would continue from the cooler pre-re-melt state @@ -261,6 +289,16 @@ def test_a_run_stopped_on_an_impact_resumes_from_before_it(tmp_path): f'{snapshots}); the run has nothing to walk back to' ) + # The initial condition has to be molten for the re-melt to add heat, and + # the run says whether it is rather than the configuration being trusted: + # a flat profile is only molten for a planet whose melting curve it clears, + # and this is the planet it was chosen for. + assert float(stored.iloc[0]['Phi_global']) == pytest.approx(1.0, rel=1e-9), ( + f'the mantle starts at melt fraction {float(stored.iloc[0]["Phi_global"]):.4f}, ' + f'so {TSURF_INIT:.0f} K is not molten for this planet and the impact would ' + 'reset it to a state that is not a magma ocean' + ) + # The mantle cools into the impact and the impact warms it. That contrast # is what makes the discarded snapshot stale rather than merely redundant, # and it is the signal the resume checks below read. Only the three rows From 7bbae2159b7620ddaa4c33ce332019b74435449c Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 30 Jul 2026 18:01:45 +0200 Subject: [PATCH 39/71] Name the front when the interior stalls on CVODE The stall message assumed the run had fallen back to the scipy integrator and told the reader to install CVODE. A coupled run that already integrates with CVODE hits the same stall at the solidus, and there the message sends them after a package that is present while the actual cause goes unnamed. A giant-impact run meets this reliably: the re-melt returns the mantle to fully molten, and it stalls on the way back down through the front. The remedy now depends on what the run actually used. Configured for scipy keeps the install remedy, and so does a run configured for CVODE whose wrapper does not load: the wrapper is compiled against the SUNDIALS C library, so a version or ABI mismatch is found by a package lookup and still drops Aragog to scipy, which is why the check loads the submodule rather than only locating it. Configured for CVODE with a wrapper that loads names the phase boundary instead, and points at the run log for which of the step caps or the liquidus crossing stopped the integration, since those are separate limits and the log records which one fired. --- src/proteus/interior_energetics/aragog.py | 68 +++++++++++++++-- tests/interior_energetics/test_aragog.py | 89 +++++++++++++++++++++++ 2 files changed, 151 insertions(+), 6 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index cb8e1475d..e66603d9d 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -2,6 +2,7 @@ from __future__ import annotations # noqa: I001 import glob +import importlib.util import inspect import logging import os @@ -154,6 +155,31 @@ def _cached_entropy_eos_jax(eos_dir_str: str): _STEP_PROGRESS_MIN_SHARE = 0.01 _STEP_PROGRESS_WINDOW = 20 + +def _cvode_loads() -> bool: + """Report whether CVODE is importable, extension included. + + Locating the package is not enough to know a run is integrating with it. + The wrapper is compiled against the SUNDIALS C library, so a version or + ABI mismatch leaves a package that is found and then fails on import, and + Aragog quietly falls back to the scipy integrator. Loading the submodule + is the same test the solver itself and `proteus doctor` apply, which is + what keeps a stall on a broken build from being reported as a healthy one. + + Returns + ------- + bool + True when ``scikits_odes_sundials.cvode`` imports. + """ + if importlib.util.find_spec('scikits_odes_sundials') is None: + return False + try: + importlib.import_module('scikits_odes_sundials.cvode') + except Exception: + return False + return True + + # Default per-cell temperature and entropy step caps auto-enabled for the # coupled zalmoxis stack, alongside the melt-fraction cap. The melt-fraction # cap goes blind once a cell is fully solid, so it cannot bound the core- @@ -2256,8 +2282,7 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: return out - @staticmethod - def _track_step_progress(interior_o, dt_actual, dt_attempted, hf_row) -> None: + def _track_step_progress(self, interior_o, dt_actual, dt_attempted, hf_row) -> None: """Refuse to keep taking steps that leave the run where it started. A step the terminal event cuts short is a valid solve, and one of them @@ -2330,10 +2355,41 @@ def _track_step_progress(interior_o, dt_actual, dt_attempted, hf_row) -> None: f'Over its last {_STEP_PROGRESS_WINDOW} steps the interior advanced ' f'{advanced:.3e} yr of the {requested:.3e} yr those steps were given ' f'({100.0 * covered:.3f}%), reaching t={hf_row.get("Time", 0.0):.3e} yr. ' - 'The run is not crossing the phase change, it is stopping at it. The ' - 'scipy integrator does this where SUNDIALS CVODE integrates through: ' - 'check `proteus doctor` for the CVODE solver and install it with ' - '`bash tools/get_cvode.sh` if it is missing.' + 'The run is not crossing the phase change, it is stopping at it. ' + + self._stall_remedy() + ) + + def _stall_remedy(self) -> str: + """Name the remedy that fits the integrator this run actually used. + + Falling back to scipy and stopping at a sharp front are different + problems with the same symptom, and only one of them is fixed by + installing a solver. Reporting the install remedy to a run that + already integrates with CVODE sends the reader after a package that + is present, so the two cases are separated here and the message names + the front when the solver is not the cause. + + Returns + ------- + str + The remedy sentence for the configured and available integrator. + """ + method = str(self._config.interior_energetics.aragog.solver_method or '') + + if method != 'cvode' or not _cvode_loads(): + return ( + 'The scipy integrator does this where SUNDIALS CVODE integrates ' + 'through: check `proteus doctor` for the CVODE solver and install ' + 'it with `bash tools/get_cvode.sh` if it is missing.' + ) + return ( + 'CVODE is the integrator here and it loads, so a missing solver is ' + 'not the cause. Every step is being cut short at a phase boundary: ' + 'the melt-fraction, temperature and entropy step caps and the ' + 'liquidus crossing at the bottom cell each stop the integration, and ' + 'the run log records which one fired. Where the melt fraction ' + 'collapses across less than one radial cell at the solidus, adding ' + 'radial levels does not thin the front.' ) @staticmethod diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 9f0106b5f..e2282599f 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1041,6 +1041,10 @@ def test_a_run_that_covers_almost_none_of_its_time_is_stopped(): 'so the operator is left without the remedy' ) assert 'get_cvode.sh' in message + assert f'{crawl * _STEP_PROGRESS_WINDOW:.3e} yr' in message, ( + 'the stop does not report the time actually advanced, which is the ' + 'number that separates a stall from a slow patch' + ) # A crawl broken by an ordinary step every other step covers 100 yr of # every 20000 yr it asks for. That is still going nowhere, and a count of @@ -1065,6 +1069,91 @@ def test_a_run_that_covers_almost_none_of_its_time_is_stopped(): ) +@pytest.mark.unit +def test_a_stall_names_the_front_when_cvode_is_already_integrating(): + """The stall remedy matches the integrator the run actually used. + + Physical scenario: a mantle re-melted by a giant impact cools back down + through the solidus, and the interior stops at that front step after step. + On the scipy fallback the same symptom means the production integrator is + missing; on CVODE it means the front itself is the limit. + + Contract clause: the two cases have the same symptom and different + remedies, so the message has to separate them. Reporting the install + remedy to a run that already integrates with CVODE sends the reader after + a package that is present, and the front goes unnamed. + + Verifies: + - With CVODE configured and loading, the message names the front and does + not tell the reader to install a solver they already have. + - With the scipy integrator configured, the install remedy is kept. + - With CVODE configured but the package absent, the install remedy is + kept, since that run really did fall back. + - With the package present but its compiled extension failing to import, + the install remedy is kept too. A version or ABI mismatch is found by a + package lookup and still drops Aragog to scipy, so treating the lookup + as proof of a working solver would withhold the one remedy that fixes + it, in exactly the case this message exists to separate. + """ + from proteus.interior_energetics.aragog import AragogRunner + + def remedy(method, *, found=True, imports=True): + runner = AragogRunner.__new__(AragogRunner) + runner._config = SimpleNamespace( + interior_energetics=SimpleNamespace(aragog=SimpleNamespace(solver_method=method)) + ) + with ( + patch( + 'proteus.interior_energetics.aragog.importlib.util.find_spec', + return_value=object() if found else None, + ), + patch( + 'proteus.interior_energetics.aragog.importlib.import_module', + side_effect=None + if imports + else ImportError('libsundials_cvode.so.6: cannot open'), + ), + ): + return runner._stall_remedy() + + on_cvode = remedy('cvode') + assert 'get_cvode.sh' not in on_cvode, ( + 'the stop tells a run that already has CVODE to install it, which ' + 'sends the reader after a package that is present' + ) + assert 'solidus' in on_cvode, ( + 'the stop does not name the front, so a run on the production solver ' + 'is left with no cause at all' + ) + + on_scipy = remedy('bdf') + assert 'get_cvode.sh' in on_scipy, ( + 'a run on the scipy integrator lost the remedy that actually fixes it' + ) + + # Configured for CVODE but the wrapper is missing: Aragog falls back to + # scipy, so this run is the install case however it was configured. + absent = remedy('cvode', found=False) + assert 'get_cvode.sh' in absent, ( + 'a run configured for CVODE without the wrapper installed silently ' + 'falls back to scipy, and the install remedy is the one it needs' + ) + + # Found but broken: the discriminating case. A package lookup alone + # cannot tell this apart from a working build, and Aragog runs scipy + # either way. + broken = remedy('cvode', found=True, imports=False) + assert 'get_cvode.sh' in broken, ( + 'a CVODE wrapper whose compiled extension fails to load reads as a ' + 'working solver, so the stall is blamed on the front while the run ' + 'is actually on scipy and the install remedy is withheld' + ) + assert broken == absent, ( + 'a broken build and a missing one both drop Aragog to scipy, so they ' + 'have to reach the same remedy' + ) + + @pytest.mark.unit @pytest.mark.physics_invariant def test_progress_is_weighed_against_what_the_coupling_asked_for(): From e1ec189c7ade68a93e091403ebc8dfb7cdeddd4c Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 30 Jul 2026 19:48:17 +0200 Subject: [PATCH 40/71] Take the domain colours from the theme stylesheet I had copied the domain colours, the phase ramp and the status colours into our own copy of the theme stylesheet. That copy is meant to stay byte-identical to the shared one so a theme update is a plain copy, and transcribing values into it broke that. Visual language 1.3.0 carries them, so this drops our transcription and syncs the file to the release. No value changes: every declaration the transcription added is present in the release at the same value, the solar deepening on light surfaces included, and the release adds the three accents on top. Nothing on these pages referenced the transcribed properties, since the diagrams carry their colours as literal values, so the rendered site is unchanged. --- docs/stylesheets/extra.css | 41 ++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 599e31895..49cdb28fd 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -40,22 +40,26 @@ --pt-basalt: #0E131B; --pt-line-d: #1A2230; - /* Module domain colours. One hue per physical domain, held stable across - every artefact in the ecosystem: the website, the overview figures, and - these pages. A module inherits the colour of the domain it acts on, so a - reader who learns the mapping once can carry it between the flowchart, the - planet schematic and the module tables. Source of truth for these values is - the ecosystem site's design tokens; keep them identical. */ + /* accents: sporadic highlight and extra data-series range. Solar is the + bright tone for dark surfaces; the light block below swaps in the deep + one, which is also available by name for a mark that must stay deep. */ + --pt-solar: #E0A32E; + --pt-solar-deep: #C8860F; + --pt-verdant: #57A05C; + + /* module domains: one hue per physical domain, identical in every artifact + of the ecosystem, so a reader who learns the mapping on one page carries + it to the next. A module takes the colour of the domain it acts on. */ --pt-dom-interior: #E23D28; /* SPIDER, Aragog, Zalmoxis */ --pt-dom-outgassing: #A03123; /* CALLIOPE, Atmodeller */ - --pt-dom-tidal: #593E74; /* LovePy, Obliqua; the red-to-blue midpoint, CVD-safe */ + --pt-dom-tidal: #593E74; /* LovePy, Obliqua */ --pt-dom-chem: #1B6FA8; /* VULCAN, ZEPHYRUS */ --pt-dom-atmos: #4FA3D9; /* AGNI, JANUS */ - --pt-dom-stellar: #E0A32E; /* MORS; solar gold, deepened on light for contrast */ - --pt-dom-accretion: #A38F7A; /* Morrigan; clay, sits clear of the reds and the blues */ + --pt-dom-stellar: #E0A32E; /* MORS; deepens on light, see the light block */ + --pt-dom-accretion: #A38F7A; /* Morrigan */ - /* Diverging phase ramp, magma through void to ocean, for data that runs from - molten to frozen. Ordered, so an index maps to a position on the ramp. */ + /* phase ramp, magma through void to ocean, for data running molten to + frozen. Ordered, so an index maps to a position along the ramp. */ --pt-p1: #E23D28; --pt-p2: #8E1F12; --pt-p3: #3A120C; @@ -66,8 +70,8 @@ --pt-p8: #4FA3D9; --pt-p9: #A8D4E8; - /* Status, kept apart from the domain hues so a red module and a failure - never have to be told apart by colour alone. */ + /* status, held apart from the domain hues so a red module and a failure + never have to be told apart by colour alone */ --pt-positive: #2E8B57; --pt-warning: #C77726; --pt-danger: #C2362B; @@ -77,12 +81,6 @@ --md-code-font: "Spline Sans Mono", ui-monospace, "SF Mono", Menlo, monospace; } -/* Solar deepens on the light scheme for adequate contrast, the one domain - colour that differs between schemes. */ -[data-md-color-scheme="default"] { - --pt-dom-stellar: #C8860F; -} - /* ---------- DARK (scheme: slate) — the primary mode ---------- */ [data-md-color-scheme="slate"] { --md-default-bg-color: var(--pt-void); @@ -133,6 +131,11 @@ --md-code-hl-string-color: var(--pt-abyss); --md-code-hl-number-color: var(--pt-ocean); --md-code-hl-comment-color: #7A8894; + + /* the one domain colour that differs by scheme: the bright tone reaches + only 2.0:1 on Paper, and deepening lifts it to 2.8:1 */ + --pt-solar: #C8860F; + --pt-dom-stellar: #C8860F; } /* ---------- typography ---------- */ From 56a9d6bd0458aecfaa95d6ba0a5988e1274e58f1 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 30 Jul 2026 23:03:52 +0200 Subject: [PATCH 41/71] Make the front-page buttons visible in the dark scheme The dark scheme uses the same colour for the primary foreground and the page background, and the theme fills a primary button with the former, so the three buttons on the front page rendered as text on a fill of exactly the page colour. Dark is the scheme we serve by default, so most readers arriving at the front page saw no buttons at all. The pill now inverts to the light foreground tone there, as it already does on our module sites. The subtitle rule joins the same file, so a heading followed by a subtitle reads the same here as on the module sites. The contact address in the code of conduct moves to dev@proteus-framework.org. --- CODE_OF_CONDUCT.md | 2 +- docs/Community/CODE_OF_CONDUCT.md | 2 +- docs/javascripts/header-links.js | 21 ++++++++++--------- docs/overrides/main.html | 34 +++++++++++++++++++++++++++++++ docs/stylesheets/layout.css | 22 ++++++++++++++++++++ 5 files changed, 69 insertions(+), 12 deletions(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index fed508192..500fddb55 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -contact@formingworlds.space. +dev@proteus-framework.org. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/docs/Community/CODE_OF_CONDUCT.md b/docs/Community/CODE_OF_CONDUCT.md index fed508192..500fddb55 100644 --- a/docs/Community/CODE_OF_CONDUCT.md +++ b/docs/Community/CODE_OF_CONDUCT.md @@ -60,7 +60,7 @@ representative at an online or offline event. Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at -contact@formingworlds.space. +dev@proteus-framework.org. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the diff --git a/docs/javascripts/header-links.js b/docs/javascripts/header-links.js index b32465ccf..9d976cf1f 100644 --- a/docs/javascripts/header-links.js +++ b/docs/javascripts/header-links.js @@ -2,19 +2,20 @@ function wire() { const homepage = "https://proteus-framework.org/"; const logo = document.querySelector(".md-header__button.md-logo"); - if (logo) logo.href = homepage; + let docsHome = location.origin + "/"; + if (logo) { + // The theme points the logo at this site's own home page. Remember that + // before repointing the logo at the framework home, so the title can use + // it and no page has to know its own name. + if (!logo.dataset.docsHome) logo.dataset.docsHome = logo.href; + docsHome = logo.dataset.docsHome; + logo.href = homepage; + } const title = document.querySelector(".md-header__title[data-md-component='header-title']"); - if (title && !title.dataset.spiderWired) { - title.dataset.spiderWired = "1"; + if (title && !title.dataset.titleWired) { + title.dataset.titleWired = "1"; title.style.cursor = "pointer"; - - // always go to /PROTEUS/ when hosted there, else "/" (mkdocs serve) - const href = location.href; - const docsHome = href.includes("/PROTEUS/") - ? href.split("/PROTEUS/")[0] + "/PROTEUS/" - : location.origin + "/"; - title.addEventListener("click", (e) => { if (e.target.closest("a, button, input, label")) return; window.location.assign(docsHome); diff --git a/docs/overrides/main.html b/docs/overrides/main.html index a103249b5..626749013 100644 --- a/docs/overrides/main.html +++ b/docs/overrides/main.html @@ -51,4 +51,38 @@ } })(); + + {% endblock %} diff --git a/docs/stylesheets/layout.css b/docs/stylesheets/layout.css index 7d66f8a11..27d453ea7 100644 --- a/docs/stylesheets/layout.css +++ b/docs/stylesheets/layout.css @@ -174,6 +174,28 @@ text-decoration: none !important; } +/* Primary buttons: the dark scheme reuses the primary color as the page + background, so the pill inverts to the light foreground tone there. */ +[data-md-color-scheme="slate"] .md-typeset .md-button--primary { + background-color: var(--md-primary-bg-color); + border-color: var(--md-primary-bg-color); + color: var(--pt-void); +} +[data-md-color-scheme="slate"] .md-typeset .md-button--primary:hover, +[data-md-color-scheme="slate"] .md-typeset .md-button--primary:focus { + background-color: var(--pt-ice); + border-color: var(--pt-ice); + color: var(--pt-void); +} + +/* Front-page subtitle: the line under the module name, a size down and in the + muted foreground so it reads as a caption rather than a second heading */ +.md-typeset .subtitle { + font-size: 1.0rem; + color: var(--md-default-fg-color--light); + margin-top: -0.1rem; +} + /* Header title doubles as a home link (see javascripts/header-links.js) */ .md-header__title[data-md-component="header-title"] { cursor: pointer; From 38851564abc22ae115d89c1d1892d2d30505a652 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Fri, 31 Jul 2026 07:09:16 +0200 Subject: [PATCH 42/71] Let an impact re-wet a planet that had run dry A run latches into the desiccated path once its whole volatile inventory is gone, and that path zeroes every volatile column it is handed. An impactor arriving with volatiles gives the planet an inventory again, so the impact now lifts the latch and the desiccation check re-decides on the next iteration. Without this the delivery was erased by the next outgassing call and the planet stayed dry however wet the impactors were. A dry impactor delivers nothing, so it leaves the latch alone. The conserved-element set is now read off the definition of M_ele, volatile elements plus noble gases, with the rock-forming elements taken as the complement. That ties what an impact conserves to what the whole-planet mass is built from, rather than to the list of elements that happen to have a vapour species, which is a narrower thing that can drift. Also covers two gaps: that a below-threshold atmosphere leaves the dissolved inventory alone, which only discriminates on the outgassing reservoir, and that the row an impact hands on satisfies the mass and surface-pressure invariants the iteration closes with. --- src/proteus/accretion/wrapper.py | 37 ++++--- tests/accretion/test_wrapper.py | 171 ++++++++++++++++++++++++++++--- tests/escape/test_wrapper.py | 56 ++++++++++ 3 files changed, 238 insertions(+), 26 deletions(-) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index eadc6f6b6..543ccbb92 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -5,7 +5,7 @@ import os from typing import TYPE_CHECKING -from proteus.utils.constants import AU, M_earth, element_list, vap_element_list +from proteus.utils.constants import AU, M_earth, element_list, noble_gases, vol_element_list if TYPE_CHECKING: from proteus.accretion.common import ImpactEvent @@ -13,19 +13,19 @@ log = logging.getLogger('fwl.' + __name__) -# Rock-forming elements, whose mass grows through the structure solve -# (mass_tot and the equation of state) rather than the volatile budgets. Taken -# from the element registry's own rock-forming set, which is the same set -# ``update_planet_mass`` leaves out of M_ele, so the two stay in step when an -# element is added there. -_ROCK_ELEMENTS = tuple(vap_element_list) +# Elements whose whole-planet budget is conserved across an impact's mass +# growth, and which the strip and the delivery are sized from. This is the set +# ``update_planet_mass`` sums into M_ele, so what an impact conserves and what +# the whole-planet mass is built from are the same elements by construction. +# The noble gases are in it, so a planet-matching impactor carries them in +# proportion like every other volatile. +_VOLATILE_ELEMENTS = tuple(e for e in element_list if e in vol_element_list or e in noble_gases) -# Every other tracked element's whole-planet budget is conserved across an -# impact's mass growth. Derived from the tracked-element registry so an -# element added there is conserved by default unless declared rock-forming. -# The set includes the noble gases, so a planet-matching impactor carries -# them in proportion like every other volatile. -_VOLATILE_ELEMENTS = tuple(e for e in element_list if e not in _ROCK_ELEMENTS) +# Everything else is rock-forming: its mass grows through the structure solve +# (mass_tot and the equation of state) rather than through a budget, which is +# why M_ele leaves it out. Taken as the complement rather than as its own list, +# so an element cannot be counted in both channels or in neither. +_ROCK_ELEMENTS = tuple(e for e in element_list if e not in _VOLATILE_ELEMENTS) # Elements configurable through the per-element ppmw fields. The ppmw mode # can only deliver these; the planet-matching mode covers the full volatile @@ -361,6 +361,17 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: handler.crystallized = False log.info(' solidification latch cleared: the mantle is molten again') + # An impactor that brought volatiles to a planet that had run dry gives it + # an inventory again, so lift the one-way desiccation latch too. The + # desiccated path zeroes every volatile column it is given, so leaving the + # latch set would erase the delivery on the next outgassing call and the + # planet would stay dry no matter how wet the impactors were. The latch is + # only lifted, not re-decided: the desiccation check runs again on the next + # iteration and re-sets it if the delivery was too small to matter. + if delivered and getattr(handler, 'desiccated', False): + handler.desiccated = False + log.info(' desiccation latch cleared: the impact delivered volatiles') + # Move the orbit by the impact's proportional change in semi-major axis and # its post-impact eccentricity, writing both the configuration and the row. # Both elements are applied as the change this impact made, not as the diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 49ae57dbd..3a10bffed 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -248,6 +248,7 @@ def _impact_handler( eccentricity=0.1, tsurf_init=4000.0, crystallized=False, + desiccated=False, accretion=None, ): """Build the minimal handler shape apply_impact reads and mutates. @@ -280,6 +281,7 @@ def _impact_handler( hf_all=None, interior_o=SimpleNamespace(impact_reset=False), crystallized=crystallized, + desiccated=desiccated, directories={'output': '/tmp/unused'}, ) @@ -354,6 +356,58 @@ def test_impact_on_a_crystallised_planet_reopens_outgassing(monkeypatch): assert handler.crystallized is False +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_a_wet_impact_on_a_desiccated_planet_restores_its_inventory(monkeypatch): + """A volatile-bearing impact clears the one-way desiccation latch. + + Once a planet loses its whole volatile inventory the run latches into the + desiccated path, which zeroes every volatile column it is handed. An + impactor that arrives carrying volatiles gives the planet an inventory + again, so that latch has to lift with the delivery: otherwise the next + outgassing call erases what the impact just delivered and the planet stays + dry however wet the impactors are. + + The latch is lifted, not re-decided. The desiccation check runs again on + the following iteration and re-sets it if the delivery was too small to + count, so nothing here asserts the planet is wet, only that it is allowed + to be re-evaluated. + + Edge case: a dry impactor delivers nothing, so there is no inventory to + restore and the latch must stay set. That is the discriminating half; a + fix that cleared the latch on every impact would pass the first assertion + and fail this one. + """ + from proteus.accretion.wrapper import apply_impact + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', lambda *a, **k: None + ) + + # 1000 ppmw of hydrogen on a 6.4e23 kg impactor is 6.4e20 kg delivered, + # far above any threshold the desiccation check applies. + wet = _impact_handler(desiccated=True, accretion=_impact_accretion(H=1.0e3)) + assert wet.desiccated is True # latched before the impact + apply_impact(wet, _impact_event()) + + assert wet.desiccated is False, ( + 'the planet stayed latched as desiccated after an impact delivered ' + 'volatiles, so the desiccated path will zero the delivery on the next ' + 'outgassing call' + ) + assert wet.hf_row['H_kg_total'] == pytest.approx(6.4e20, rel=1e-12) + + # A dry impactor brings nothing, so the planet is still dry and the latch + # must hold. + dry = _impact_handler(desiccated=True) + apply_impact(dry, _impact_event()) + assert dry.desiccated is True, ( + 'a dry impact lifted the desiccation latch, so the run resumes ' + 'outgassing a planet that still has no volatiles' + ) + assert dry.hf_row.get('H_kg_total', 0.0) == pytest.approx(0.0) + + @pytest.mark.unit @pytest.mark.physics_invariant def test_impact_moves_the_orbit_in_both_the_config_and_the_row(monkeypatch): @@ -1861,18 +1915,23 @@ def test_the_rock_and_volatile_element_sets_partition_the_registry(): The registry already draws that line for the rest of the model: ``update_planet_mass`` sums M_ele over the volatile elements and the noble - gases and deliberately leaves the rock-forming elements out, because rock - vapour puts their mass in the atmosphere without debiting the interior. - The accretion module must draw it in the same place, so this pins the two - sets against the registry rather than against a copy of it. + gases and leaves the rock-forming elements out, because rock vapour puts + their mass in the atmosphere without debiting the interior. The accretion + module must draw it in the same place, so this pins the conserved set + against the M_ele definition rather than against a copy of it, and takes + the rock set as the complement. Verifies: - - The rock set is exactly the registry's rock-forming set. - - The two sets are disjoint and together cover every tracked element. - The conserved set is exactly what M_ele sums over, so nothing an impact conserves is left out of the planet mass and nothing it grows is in. + - The two sets are disjoint and together cover every tracked element. + - Every element the registry calls rock-forming is outside the conserved + set, including those added after the accretion module was written. """ + import inspect + from proteus.accretion.wrapper import _ROCK_ELEMENTS, _VOLATILE_ELEMENTS + from proteus.interior_energetics.wrapper import update_planet_mass from proteus.utils.constants import ( element_list, noble_gases, @@ -1883,7 +1942,6 @@ def test_the_rock_and_volatile_element_sets_partition_the_registry(): rock = set(_ROCK_ELEMENTS) conserved = set(_VOLATILE_ELEMENTS) - assert rock == set(vap_element_list) assert conserved == set(vol_element_list) | set(noble_gases) # A partition: no element travels by both routes, and none is dropped. @@ -1891,9 +1949,10 @@ def test_the_rock_and_volatile_element_sets_partition_the_registry(): assert rock | conserved == set(element_list) # Discrimination: the rock-forming set is not a subset of some smaller - # hard-coded group. Every element the registry calls rock-forming is - # excluded from the conserved budgets, including any added after the - # accretion module was written. + # hard-coded group. Al, Ti, Ca and K are rock-forming and were added to the + # registry after the accretion module was written; a copied four-element + # tuple would conserve them as volatile budgets. + assert {'Al', 'Ti', 'Ca', 'K'} <= rock for element in vap_element_list: assert element not in conserved, ( f"'{element}' is rock-forming in the element registry but is " @@ -1901,6 +1960,92 @@ def test_the_rock_and_volatile_element_sets_partition_the_registry(): 'counted both in the rock the impact adds and in the budget' ) - # The conserved set is what the planet mass is built from, so an impact - # cannot conserve a budget the planet mass does not see. - assert conserved == set(vol_element_list + noble_gases) + # The conserved set is the one M_ele is summed over, read off the source of + # that sum rather than restated here, so the two cannot drift apart. + m_ele_source = inspect.getsource(update_planet_mass) + assert 'for e in vol_element_list + noble_gases:' in m_ele_source, ( + 'update_planet_mass no longer sums M_ele over vol_element_list + ' + 'noble_gases, so what an impact conserves and what the whole-planet ' + 'mass is built from may now be different sets' + ) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_the_row_an_impact_leaves_satisfies_the_runtime_mass_invariants(monkeypatch): + """The main loop's own invariant checks pass on a post-impact row. + + Contract clause: every iteration ends by asserting that the atmosphere is + no heavier than the planet and that the summed per-species atmospheric + masses still equal ``M_vol_atm``. An impact rewrites ``M_planet`` through + the mass anchor and rewrites the per-element budgets through the strip and + the delivery, all inside the same iteration those checks close, so the row + it hands on has to satisfy them rather than relying on the outgassing step + to repair it. + + The impact here both strips a heavy atmosphere and delivers a wet + impactor's volatiles, so the two channels that move mass in opposite + directions are exercised together. + + Edge case: the same checks are run on the pre-impact row first, so a row + that was already failing them cannot be mistaken for one the impact fixed. + """ + from proteus.accretion.wrapper import apply_impact + from proteus.utils.coupler import ( + assert_mass_conservation, + assert_surface_pressure_consistency, + ) + + handler = _impact_handler( + accretion=_impact_accretion( + atmloss_module='constant', atmloss_frac=0.4, impactor_volatiles='ppmw', H=500.0 + ) + ) + hf_row = handler.hf_row + _atm_state(hf_row, H=(3.0e19, 4.0e20), O=(2.0e19, 3.0e20)) + # The gas-species columns the invariant sums over, consistent with the + # per-element atmospheric masses above: H2O carries both H and O. + hf_row['H2O_kg_atm'] = 5.0e19 + hf_row['M_vol_atm'] = 5.0e19 + hf_row['M_atm'] = 5.0e19 + hf_row['M_ele'] = 7.0e20 + hf_row['M_int'] = 5.9736e24 + hf_row['M_planet'] = hf_row['M_int'] + hf_row['M_ele'] + hf_row['P_surf'] = 120.0 + hf_row['P_vol'] = 120.0 + hf_row['P_vap'] = 0.0 + hf_row['outgas_mass_thresh'] = 0.0 + + config = SimpleNamespace(outgas=SimpleNamespace(mass_thresh=1.0e10, vapourise=False)) + handler.config.outgas = config.outgas + + # The starting row already satisfies both checks, so anything raised after + # the impact is the impact's doing. + assert_mass_conservation(hf_row, require_atm_le_planet=True) + assert_surface_pressure_consistency(config, hf_row) + + def _solve(dirs, cfg, hf_all, row, output): + row['M_int'] = cfg.planet.mass_tot * 5.9736e24 + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', _solve, raising=False + ) + + apply_impact(handler, _impact_event()) + + # Neither invariant is breached by the row the impact hands on. + assert_mass_conservation(hf_row, require_atm_le_planet=True) + assert_surface_pressure_consistency(config, hf_row) + + # Discrimination: the checks above ran against a row both channels moved, + # not a copy of the starting one. Hydrogen closes as + # 4.0e20 - 0.4 * 3.0e19 (the strip, 40% of the atmospheric H) + # + 3.2e20 - 0.4 * 0.075 * 3.2e20 (delivery, less the impactor's own + # atmospheric part lost in the + # collision at the target's 7.5% + # atmospheric fraction) + # = 6.984e20 kg. A run that skipped either channel lands elsewhere. + assert hf_row['H_kg_total'] == pytest.approx(6.984e20, rel=1e-12) + # Both strips are booked as loss: 40% of the H and of the O atmosphere. + assert hf_row['esc_kg_cumulative'] == pytest.approx(2.0e19, rel=1e-12) + assert hf_row['M_planet'] > 5.9736e24, 'the planet did not grow' diff --git a/tests/escape/test_wrapper.py b/tests/escape/test_wrapper.py index 16640328c..26e2edb03 100644 --- a/tests/escape/test_wrapper.py +++ b/tests/escape/test_wrapper.py @@ -1133,3 +1133,59 @@ def test_run_escape_zephyrus_zeroes_elemental_rates_when_unfract_raises(): # Scale guard: 1.234e5 kg/s is a plausible XUV-limited MLR (~kg/s for # an Earth-like XUV setup), not 1.234e+15 (units flipped) or 0.0. assert 1e3 < hf_row['esc_rate_total'] < 1e7 + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_a_thin_atmosphere_does_not_erase_the_dissolved_inventory(): + """A below-threshold atmosphere leaves the whole-planet budgets alone. + + Physical scenario: a wet planet whose atmosphere has almost all dissolved + back into the magma ocean. The escape reservoir is the atmosphere, which is + what sizes the loss, but what the function returns is written straight into + ``_kg_total`` by its only caller (``run_escape``). Returning the + atmospheric masses when the atmosphere is too thin to lose anything would + therefore overwrite the whole-planet totals with them and delete the + dissolved inventory, which escape never touched. + + Discrimination: the returned budgets are compared against the totals, and + the totals are eleven orders of magnitude above the atmospheric masses, so + a returned reservoir dict cannot pass on a rounding edge. The same call + with ``reservoir='bulk'`` cannot discriminate at all, because there the two + dicts are the same by construction, which is why this uses ``'outgas'``. + """ + from proteus.escape.wrapper import calc_new_elements + + # Dissolved-dominated: 1e21 kg of H in the planet, 1e5 kg of it in the air. + hf_row = { + 'esc_rate_total': 1e4, # kg s-1 + 'H_kg_total': 1.0e21, + 'C_kg_total': 1.0e19, + 'N_kg_total': 1.0e18, + 'S_kg_total': 1.0e17, + 'O_kg_total': 1.0e20, + 'H_kg_atm': 1.0e5, + 'C_kg_atm': 1.0e4, + 'N_kg_atm': 1.0e3, + 'S_kg_atm': 1.0e2, + 'O_kg_atm': 1.0e4, + } + totals_before = {e: v for e, v in hf_row.items() if e.endswith('_kg_total')} + + # The atmosphere sums to ~1.1e5 kg, far below the threshold. + tgt = calc_new_elements(hf_row, dt=500.0, reservoir='outgas', min_thresh=1.0e10) + + for element in ('H', 'C', 'N', 'S', 'O'): + assert tgt[element] == pytest.approx(totals_before[f'{element}_kg_total'], rel=1e-12), ( + f'{element} came back as its atmospheric mass rather than its ' + 'whole-planet total, so the caller would write the thin atmosphere ' + 'over the dissolved inventory and desiccate the planet on paper' + ) + # Discrimination: the atmospheric value is nowhere near the total. + assert tgt[element] > 1.0e3 * hf_row[f'{element}_kg_atm'] + + # An element the run does not track comes back as zero rather than raising. + assert tgt['Kr'] == pytest.approx(0.0) + + # The row itself is not mutated by the sizing call. + assert hf_row['H_kg_total'] == pytest.approx(1.0e21, rel=1e-12) From 116cc2bdcdaa05f4cdeff9beb467aef8ed114514 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sat, 1 Aug 2026 06:44:32 +0200 Subject: [PATCH 43/71] Refuse accretion on the boundary interior, and test the two step caps together The boundary interior does not forward its interior state object to the time-stepper, so the cap that shortens a step to land exactly on a scheduled impact never fires there. Every impact would then be applied at the end of whatever step the controller happened to choose, late by an unbounded amount, and a long step spanning two impacts would collapse both onto the same moment. Nothing refused the pairing, so it was reachable from a configuration file and failed silently. It now fails at configuration load, next to the SPIDER refusal, and the two give different reasons because they are different defects. The list of interiors that cannot carry an impact is a table, so adding one is a line rather than a rewritten sentence. A step can now be shortened by a scheduled impact or by the edge of the stellar flux scaling window, and nothing exercised both at once: every impact test switched the scaling off and every scaling test left the impact time infinite. Three cases cover it. The nearer event decides where the step lands, and whichever event is further away is still ahead of the run rather than stepped over. The flag the main loop reads follows the scaling window alone, so an impact cannot defeat the refresh cadence. --- src/proteus/config/_config.py | 48 ++++++-- tests/config/test_accretion.py | 34 +++++ tests/interior_energetics/test_timestep.py | 137 ++++++++++++++++++++- 3 files changed, 203 insertions(+), 16 deletions(-) diff --git a/src/proteus/config/_config.py b/src/proteus/config/_config.py index da8efd6f7..9ee8798f4 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -125,24 +125,46 @@ def check_module_dependencies(instance, attribute, value): raise ImportError(f'{msg}\n Original error: {e}') from e -def check_accretion_interior_compatibility(instance, attribute, value): - """Reject accretion runs on an interior that cannot re-melt after an impact. +# Interiors that cannot carry a giant impact, and the reason each cannot, keyed +# by `interior_energetics.module`. One entry per line so a new interior adds a +# line rather than editing a sentence. +_ACCRETION_INCOMPATIBLE_INTERIORS = { + 'spider': 'SPIDER has no supported re-melt path', + 'boundary': ( + 'the boundary interior does not forward its interior state object to ' + 'the time-stepper, so the step cannot be shortened to land on a ' + 'scheduled impact' + ), +} + - A giant impact fully re-melts the mantle, and the SPIDER interior keeps its - state in a restart file written by the external binary with no validated - re-melt path, so the combination is refused here at configuration load - rather than at the first impact, which can be many hours into a run. +def check_accretion_interior_compatibility(instance, attribute, value): + """Reject accretion runs on an interior that cannot apply an impact. + + A giant impact re-melts the mantle and has to be applied at the state the + timeline places it at. An interior that cannot do both is refused here at + configuration load rather than at the first impact, which can be many hours + into a run. SPIDER keeps its state in a restart file written by the external + binary; the boundary interior never forwards its interior state object to the + time-stepper, so the step cannot be capped to land on the impact and impacts + would be applied late by an unbounded amount, or several would collapse onto + the end of one long step. """ - if ( - instance.accretion.module is not None - and instance.interior_energetics.module == 'spider' - ): + if instance.accretion.module is None: + return + + interior = instance.interior_energetics.module + reason = _ACCRETION_INCOMPATIBLE_INTERIORS.get(interior) + if reason is not None: raise ValueError( "accretion.module = '" + str(instance.accretion.module) - + "' cannot run with interior_energetics.module = 'spider': a giant " - 'impact re-melts the mantle and SPIDER has no supported re-melt path. ' - "Use interior_energetics.module = 'aragog' (or 'dummy' for a test)." + + "' cannot run with interior_energetics.module = '" + + str(interior) + + "': a giant impact re-melts the mantle and has to be applied where " + 'the timeline places it, but ' + + reason + + ". Use interior_energetics.module = 'aragog' (or 'dummy' for a test)." ) diff --git a/tests/config/test_accretion.py b/tests/config/test_accretion.py index 7ae2470b4..fc12d8d68 100644 --- a/tests/config/test_accretion.py +++ b/tests/config/test_accretion.py @@ -356,6 +356,40 @@ def test_accretion_on_spider_is_refused_at_config_load(): check_accretion_interior_compatibility(_compat_instance(None, 'spider'), None, None) +@pytest.mark.unit +def test_accretion_on_the_boundary_interior_is_refused_at_config_load(): + """An accretion run on the boundary interior is rejected before it starts. + + That interior does not pass its state to the time-stepper, so the step is + never shortened to land on a scheduled impact and every impact would be + applied late by however far the controller happened to step. The refusal + names the offending interior so the message is actionable, and it does not + reach for the reason the SPIDER refusal gives, which is a different defect. + """ + from proteus.config._config import check_accretion_interior_compatibility + + for module in ('morrigan', 'dummy', 'timeline'): + with pytest.raises(ValueError, match='land on a scheduled impact'): + check_accretion_interior_compatibility( + _compat_instance(module, 'boundary'), None, None + ) + + # Discrimination: the two refusals give different reasons, so a check that + # collapsed them into one message would pass the assertion above and fail here. + with pytest.raises(ValueError, match='boundary'): + check_accretion_interior_compatibility( + _compat_instance('morrigan', 'boundary'), None, None + ) + with pytest.raises(ValueError) as spider_err: + check_accretion_interior_compatibility( + _compat_instance('morrigan', 'spider'), None, None + ) + assert 'land on a scheduled impact' not in str(spider_err.value) + + # The boundary interior is only refused when accretion is actually on. + check_accretion_interior_compatibility(_compat_instance(None, 'boundary'), None, None) + + @pytest.mark.unit def test_accretion_with_rock_vapour_is_refused_at_config_load(): """An accretion run that also vapourises rock is rejected before it starts. diff --git a/tests/interior_energetics/test_timestep.py b/tests/interior_energetics/test_timestep.py index 5351b4f7a..4640a7120 100644 --- a/tests/interior_energetics/test_timestep.py +++ b/tests/interior_energetics/test_timestep.py @@ -36,6 +36,9 @@ def _make_config( dt_max: float = 1.0e7, phi_crit: float = 0.05, max_growth_factor: float = 0.0, + bol_scale: float = 1.0, + bol_scale_start: float | None = None, + bol_scale_duration: float = 0.0, ): """Build a minimal duck-typed config that ``next_step`` reads from. @@ -74,13 +77,24 @@ def _make_config( time=stop_time, ) params = SimpleNamespace(dt=dt, stop=stop) - star = SimpleNamespace(bol_scale=1.0, bol_scale_start=None, bol_scale_duration=0.0) + star = SimpleNamespace( + bol_scale=bol_scale, + bol_scale_start=bol_scale_start, + bol_scale_duration=bol_scale_duration, + ) return SimpleNamespace(params=params, star=star) -def _make_hf_all(n_rows: int = 10, dt_prev: float = 1.0e3, phi: float = 1.0): +def _make_hf_all( + n_rows: int = 10, dt_prev: float = 1.0e3, phi: float = 1.0, age_star: float = 0.0 +): """Build a minimal ``hf_all`` DataFrame long enough that ``next_step`` - enters the adaptive branch (``LBAVG + 5 = 8`` rows required).""" + enters the adaptive branch (``LBAVG + 5 = 8`` rows required). + + ``age_star`` is the stellar age the bolometric-scaling clamp measures its + window against. It is constant down the column because the clamp reads only + the last row. + """ times = np.arange(n_rows, dtype=float) * dt_prev f_atm = np.full(n_rows, 1.0e4) phi_col = np.full(n_rows, float(phi)) @@ -91,6 +105,7 @@ def _make_hf_all(n_rows: int = 10, dt_prev: float = 1.0e3, phi: float = 1.0): 'Phi_global': phi_col, 'esc_rate_total': np.zeros(n_rows), 'F_int': f_atm.copy(), + 'age_star': np.full(n_rows, float(age_star)), } ) @@ -766,3 +781,119 @@ def test_an_imminent_impact_is_floored_at_the_minimum_step(self): # Positivity, and the deliberate overshoot that the floor implies. assert dt > 0.0 assert hf_row['Time'] + dt > t_impact + + +class TestImpactAndBolscaleClampsTogether: + """Verify the step when a giant impact and a stellar-scaling edge compete. + + Two independent events can shorten the same step: a scheduled impact, + and the moment the bolometric scaling of the stellar flux switches on or + off. Both caps only ever shorten dt, so the nearer event decides where + the step lands, and the run reaches the further one on a later step. + + The controller's own choice throughout is 1.6 * 5e3 = 8e3 yr, so every + value asserted below is well clear of it and of the other event's time. + """ + + # Window start in Gyr; the clamp reads it as 5.0e8 yr of stellar age. + BOL_START_GYR = 0.5 + AGE_INI_YR = 5.0e8 + TIME_NOW = 1.0e5 + CONTROLLER_DT = 8.0e3 + + def _setup(self, dt_to_edge, t_next_impact): + """Place the scaling edge ``dt_to_edge`` years ahead and schedule an + impact, returning everything ``next_step`` needs.""" + config = _make_config( + bol_scale=2.0, + bol_scale_start=self.BOL_START_GYR, + bol_scale_duration=0.5, + ) + hf_all = _make_hf_all( + n_rows=12, dt_prev=5.0e3, phi=1.0, age_star=self.AGE_INI_YR - dt_to_edge + ) + hf_row = {'Time': self.TIME_NOW, 'F_atm': 1.0e4, 'Phi_global': 1.0} + interior_o = _make_interior_o(t_next_impact=t_next_impact) + return config, hf_all, hf_row, interior_o + + @pytest.mark.physics_invariant + def test_the_nearer_impact_wins_and_the_step_stops_on_it(self): + """An impact closer than the scaling edge decides the step. + + The impact has to be applied at the state the timeline places it at, + because it grows the planet and re-melts its mantle. The scaling edge + carries no such requirement: it is a property of stellar age alone and + is recovered on the following step. + """ + from proteus.interior_energetics.timestep import next_step + + dt_to_edge = 6.0e3 + t_impact = self.TIME_NOW + 3.0e3 + config, hf_all, hf_row, interior_o = self._setup(dt_to_edge, t_impact) + + dt = next_step(config, {}, hf_row, hf_all, 1.0, interior_o=interior_o) + + # The step ends on the impact, not on the scaling edge. + assert hf_row['Time'] + dt == pytest.approx(t_impact, rel=1e-12) + # Discrimination: an inert impact cap lands the step on the edge at + # 6e3, and the controller left alone chooses 8e3, so neither produces + # 3e3. This case says nothing about the scaling cap, which the next + # case pins: with the scaling cap inert the impact cap alone still + # returns 3e3 here. + assert dt < dt_to_edge + assert dt < self.CONTROLLER_DT + # The invariant the impact cap exists for: never step past the impact. + assert hf_row['Time'] + dt <= t_impact + + @pytest.mark.physics_invariant + def test_the_nearer_scaling_edge_wins_without_overshooting_the_impact(self): + """A scaling edge closer than the impact decides the step instead. + + The step lands on the edge, and because the impact is further away it + is still ahead of the run rather than stepped over, which is what the + one-way nature of both caps guarantees. + """ + from proteus.interior_energetics.timestep import next_step + + dt_to_edge = 4.0e3 + t_impact = self.TIME_NOW + 6.0e3 + config, hf_all, hf_row, interior_o = self._setup(dt_to_edge, t_impact) + + dt = next_step(config, {}, hf_row, hf_all, 1.0, interior_o=interior_o) + + assert dt == pytest.approx(dt_to_edge, rel=1e-9) + # The impact is still pending, which is the property that fails if the + # controller's 8e3 step were to survive: it would overshoot by 2e3. + assert hf_row['Time'] + dt < t_impact + assert dt < self.CONTROLLER_DT + + def test_the_clamp_flag_tracks_the_scaling_edge_and_not_the_impact(self): + """The flag the main loop reads reports the scaling edge alone. + + The main loop forces an off-cadence stellar refresh whenever the flag + is raised. It must therefore follow the bolometric window and not any + other cap, or every impact would defeat the refresh cadence. + + When the impact cap then pulls the step short of the edge the flag + stays raised, and the extra refresh that causes is harmless: the + scaling factor is a function of stellar age, so recomputing it early + returns the same pre-edge value the run already had. + """ + from proteus.interior_energetics.timestep import next_step + + t_impact = self.TIME_NOW + 3.0e3 + + # Edge near, impact nearer: the edge bound the step before the impact + # cap moved it, so the flag is raised. + config, hf_all, hf_row, interior_o = self._setup(6.0e3, t_impact) + next_step(config, {}, hf_row, hf_all, 1.0, interior_o=interior_o) + assert interior_o.timestep_clamped is True + + # Same impact, but the window opens 4e8 yr out, far beyond any step. + # The impact still decides dt, and the flag must stay down: this is + # what separates "the scaling edge bound the step" from "something + # bound the step". + config, hf_all, hf_row, interior_o = self._setup(4.0e8, t_impact) + dt = next_step(config, {}, hf_row, hf_all, 1.0, interior_o=interior_o) + assert interior_o.timestep_clamped is False + assert hf_row['Time'] + dt == pytest.approx(t_impact, rel=1e-12) From 6984a0a447097ea459ceda1a4e85dc5f469191de Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sat, 1 Aug 2026 22:37:36 +0200 Subject: [PATCH 44/71] Take the rock-forming elements as a complement rather than a constant Nothing in the accretion module iterates over the rock-forming elements: an impact sizes its stripping and its delivery from the conserved volatile budgets, and the rock it adds arrives through the structure solve instead. The constant naming that set was therefore never read outside the test asserting it, and the two statements the test drew from it, that the sets are disjoint and that together they cover the registry, held by construction whatever the registry contained. The test now takes the complement itself, which is where that reasoning belongs, and asserts the two things that can actually fail: that the conserved set is exactly what M_ele sums over, and that every element the registry calls rock-forming stays out of it. The comment in the module still says where the line falls and why nothing iterates over the far side of it. --- src/proteus/accretion/wrapper.py | 10 +++++----- tests/accretion/test_wrapper.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index 543ccbb92..4d563ceaa 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -21,11 +21,11 @@ # proportion like every other volatile. _VOLATILE_ELEMENTS = tuple(e for e in element_list if e in vol_element_list or e in noble_gases) -# Everything else is rock-forming: its mass grows through the structure solve -# (mass_tot and the equation of state) rather than through a budget, which is -# why M_ele leaves it out. Taken as the complement rather than as its own list, -# so an element cannot be counted in both channels or in neither. -_ROCK_ELEMENTS = tuple(e for e in element_list if e not in _VOLATILE_ELEMENTS) +# Every other element in the registry is rock-forming. Its mass grows through +# the structure solve (mass_tot and the equation of state) rather than through a +# budget, which is why M_ele leaves it out and why nothing here iterates over it: +# rock is the complement of the set above, never a list of its own, so an element +# cannot be counted in both channels or in neither. # Elements configurable through the per-element ppmw fields. The ppmw mode # can only deliver these; the planet-matching mode covers the full volatile diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index 3a10bffed..d50fba6f9 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -1930,7 +1930,7 @@ def test_the_rock_and_volatile_element_sets_partition_the_registry(): """ import inspect - from proteus.accretion.wrapper import _ROCK_ELEMENTS, _VOLATILE_ELEMENTS + from proteus.accretion.wrapper import _VOLATILE_ELEMENTS from proteus.interior_energetics.wrapper import update_planet_mass from proteus.utils.constants import ( element_list, @@ -1939,14 +1939,18 @@ def test_the_rock_and_volatile_element_sets_partition_the_registry(): vol_element_list, ) - rock = set(_ROCK_ELEMENTS) conserved = set(_VOLATILE_ELEMENTS) + # Rock is whatever the conserved set leaves behind. Taking the complement + # here rather than reading a second list is the point: an element cannot + # then be counted in both channels or in neither, whatever the registry + # grows next. + rock = set(element_list) - conserved assert conserved == set(vol_element_list) | set(noble_gases) - # A partition: no element travels by both routes, and none is dropped. - assert rock & conserved == set() - assert rock | conserved == set(element_list) + # The conserved set is drawn from the registry, so an element the registry + # tracks cannot fall outside both channels. + assert conserved <= set(element_list) # Discrimination: the rock-forming set is not a subset of some smaller # hard-coded group. Al, Ti, Ca and K are rock-forming and were added to the From f8591724ac9168930faeb6c1bef10ad6c74168c2 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Mon, 3 Aug 2026 09:19:14 +0200 Subject: [PATCH 45/71] Pin the impact heat against a closed-form integral The re-melt injection is added to both sides of the energy budget, so the conservation residual is invariant to its value and stays clean even if the booked magnitude is badly wrong. Nothing else checked the number, which meant a factor error in the quadrature would have reached a science run silently. These tests evaluate the solver's own heat-content quadrature against an EOS whose capacitance is affine in entropy, where the integral has an exact closed form. Trapezoidal quadrature is exact on a linear integrand, so the match is pinned at 1e-12 rather than at a discretisation tolerance. Four formulas a wrong implementation would plausibly use are asserted to sit outside that tolerance; they come out 14% to 100% away, so the test discriminates rather than merely passing. A second test covers the sign convention, since a negative booking is clamped to zero upstream and an inverted sign would silently book nothing at every impact. --- tests/interior_energetics/test_wrapper.py | 136 ++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index f4a645ad1..c81d57dbc 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -6237,3 +6237,139 @@ def _drive(error): 'being absorbed by the keep-previous-state fallback' ) assert absorbed_interior.aragog_fail_count == 1 + + +# ---------------------------------------------------------------------------- +# Closed-form magnitude of the impact heat. +# +# The re-melt injection is added to both sides of the coupler's energy budget, +# so E_residual_cons_frac is invariant to its value and cannot detect a wrong +# magnitude. These tests pin the quadrature that produces it against an +# analytic integral instead. +# ---------------------------------------------------------------------------- + + +class _LinearCapacitanceEOS: + """EOS whose rho*T is linear in entropy, so the heat integral is closed form. + + Density is uniform and temperature is affine in specific entropy, + ``T(S) = a + b*S``, independent of pressure. The heat-content integrand + ``rho*T`` is then linear in ``S`` and + + int_{S0}^{Sf} rho (a + b S) dS = rho [a (Sf - S0) + b (Sf^2 - S0^2) / 2] + + exactly. Trapezoidal quadrature is exact on a linear integrand, so the + solver's value must match this to floating-point precision rather than to + a discretisation tolerance. + """ + + def __init__(self, rho: float, a: float, b: float): + self.rho, self.a, self.b = rho, a, b + + def density(self, P, S): + return np.full_like(np.asarray(S, dtype=float), self.rho) + + def temperature(self, P, S): + return self.a + self.b * np.asarray(S, dtype=float) + + def exact_heat(self, S0, Sf, vol): + """Closed-form ``Sum_i V_i int rho T dS`` for the same inputs.""" + S0, Sf, vol = (np.asarray(x, dtype=float) for x in (S0, Sf, vol)) + cell = self.rho * (self.a * (Sf - S0) + 0.5 * self.b * (Sf**2 - S0**2)) + return float(np.sum(cell * vol)) + + +def _heat_content_probe(eos, P, vol, S0, Sf, n_quad=16): + """Run the real solver quadrature against a bare attribute carrier.""" + from aragog.solver.entropy_solver import EntropySolver + + carrier = SimpleNamespace( + entropy_eos=eos, + _P_stag_flat=np.asarray(P, dtype=float), + _volume_flat=np.asarray(vol, dtype=float), + ) + return EntropySolver._step_heat_content(carrier, S0, Sf, n_quad=n_quad) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +@pytest.mark.reference_pinned +def test_impact_heat_quadrature_matches_the_closed_form_integral(): + """The booked injection equals the analytic integral of rho*T dS by volume. + + Pins the magnitude of the impact heat, which the conservation residual + cannot check because the term enters both sides of the budget and cancels. + The reference is the exact integral for an EOS whose capacitance is affine + in entropy, not a re-statement of the implementation. + """ + pytest.importorskip('aragog') + + # Entropies spanning a real cooled-to-molten jump, deliberately unequal per + # cell and off any round number, so a per-cell error cannot cancel in the sum. + S0 = np.array([2411.0, 2530.5, 2688.25, 2802.0, 2955.75, 3101.5]) + Sf = np.array([3897.0, 3902.5, 3915.25, 3928.0, 3944.75, 3960.5]) + P = np.linspace(1.4e11, 2.0e9, S0.size) + # Shell volumes falling with radius, spanning a decade so the volume + # weighting is discriminating rather than a near-uniform average. + vol = np.array([4.1e18, 6.3e18, 9.8e18, 1.6e19, 2.7e19, 4.4e19]) + + # b != 0 is what makes the integral differ from any single-point estimate. + eos = _LinearCapacitanceEOS(rho=4200.0, a=350.0, b=1.05) + expected = eos.exact_heat(S0, Sf, vol) + + got = _heat_content_probe(eos, P, vol, S0, Sf) + assert got == pytest.approx(expected, rel=1e-12) + + # An impact deposits energy into the mantle. + assert got > 0.0 + + # Discrimination guards. Each is a formula a wrong implementation would + # plausibly use; every one must sit far outside the tolerance above. + dS = Sf - S0 + end_point = float(np.sum(eos.rho * (eos.a + eos.b * Sf) * dS * vol)) + start_point = float(np.sum(eos.rho * (eos.a + eos.b * S0) * dS * vol)) + no_volume = float(np.sum(eos.rho * (eos.a * dS + 0.5 * eos.b * (Sf**2 - S0**2)))) + no_density = float(np.sum((eos.a * dS + 0.5 * eos.b * (Sf**2 - S0**2)) * vol)) + for name, wrong in ( + ('end-point capacitance', end_point), + ('start-point capacitance', start_point), + ('missing volume weight', no_volume), + ('missing density', no_density), + ): + assert abs(wrong - expected) > 1e-3 * abs(expected), ( + f'{name} is within tolerance of the correct value, so this test ' + 'cannot discriminate it' + ) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_impact_heat_is_antisymmetric_and_vanishes_on_no_jump(): + """Cooling books the negation of heating, and an unchanged profile books zero. + + The re-melt clamps a negative booking to zero upstream, so the sign + convention of the quadrature itself is what decides whether a real + injection is ever booked at all. + """ + pytest.importorskip('aragog') + + S0 = np.array([2450.0, 2601.5, 2777.25, 2903.0]) + Sf = np.array([3888.0, 3901.5, 3919.25, 3937.0]) + P = np.linspace(1.2e11, 3.0e9, S0.size) + vol = np.array([5.2e18, 8.9e18, 1.5e19, 2.6e19]) + eos = _LinearCapacitanceEOS(rho=4050.0, a=410.0, b=0.97) + + heating = _heat_content_probe(eos, P, vol, S0, Sf) + cooling = _heat_content_probe(eos, P, vol, Sf, S0) + assert heating > 0.0 > cooling + assert cooling == pytest.approx(-heating, rel=1e-12) + + # Edge case: a mantle already at the molten profile absorbs nothing. + unchanged = _heat_content_probe(eos, P, vol, Sf, Sf) + assert unchanged == pytest.approx(0.0, abs=1e-6 * abs(heating)) + + # Error contract: no EOS attached is a documented zero, not a crash. + from aragog.solver.entropy_solver import EntropySolver + + bare = SimpleNamespace(entropy_eos=None, _P_stag_flat=P, _volume_flat=vol) + assert EntropySolver._step_heat_content(bare, S0, Sf) == 0.0 From f21ac75f0757b473351931ba828a016dd4c8f2e0 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Mon, 3 Aug 2026 16:43:28 +0200 Subject: [PATCH 46/71] Record the trajectory again for the accretion columns Merging main brought in the recording made for the four columns #809 added, which does not carry the two columns the accretion path writes. A reference that is missing a column holds nothing to anything for that quantity, so the run is recorded again here rather than the two recordings being reconciled by hand. The new reference carries all six: atm_converged, atm_levels_stale, T_xuv and g_xuv from main, plus M_accreted_rock and step_dE_impact_J from the accretion path. Everything else is unchanged, 761 columns agreeing across 56 rows. --- tests/integration/golden_run.tsv | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/integration/golden_run.tsv b/tests/integration/golden_run.tsv index 75d118646..e8f781ac7 100644 --- a/tests/integration/golden_run.tsv +++ b/tests/integration/golden_run.tsv @@ -11,7 +11,7 @@ # storing those once is most of the difference in file size. # # rows = 56 -# columns = 761 +# columns = 763 # config_digest = 8a747319ccc1f94d1ea3043a040c56a013f5be35860a783726e5db23f326d98a Time series 0.0 0.0 0.0 1.0 2.0 22.365174359386017 44.453182559019396 68.42270480764319 94.44852427876276 122.7233441587675 153.45983413030177 186.89293884956194 223.28248618547008 262.91613913992376 306.1127426542948 353.2261251654001 404.64942509788676 460.82002483423685 522.2251895441276 589.4085261538343 662.9773994160411 743.6114684179313 832.0725391123349 929.2159680792423 1029.225260238923 1129.2355524915254 1229.2468448470504 1329.259137315499 1429.272429906872 1529.2867226311712 1629.3020154983974 1729.3183085185524 1829.3356017016376 1929.3538950576547 2029.3731885966051 2129.3934823284912 2229.4147762633147 2329.4370704110775 2429.4603647817817 2529.4846593854295 2629.509954232023 2729.5362493315656 2829.563544694059 2929.591840329506 3029.6211362479094 3129.6514324592717 3229.6827289735966 3329.7150258008865 3429.7483229511445 3529.782620434374 3629.8179182605786 3729.8542164397613 3829.891514981926 3929.9298138970757 4029.9691131952145 4130.009412886347 semimajorax const 74798935350.0 @@ -93,6 +93,7 @@ step_dE_Q_tidal_cons_J const 0.0 step_solver_residual_J const 0.0 step_dE_compression_J const 0.0 step_dE_state_heat_J const 0.0 +step_dE_impact_J const 0.0 E_state_heat_cons_J const 0.0 dE_predicted_cons_J const 0.0 E_residual_cons_J const 0.0 @@ -126,6 +127,7 @@ O_res const 0.0 O_vapourised_kg const 0.0 M_vol_initial series 0.0 0.0 0.0 0.0 0.0 0.0 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 3.634835485187904e+23 esc_kg_cumulative series 0.0 0.0 0.0 0.0 0.0 0.0 69704452756075.03 145346512287392.06 227477752341572.38 316706297926096.1 413703283518665.1 519210138067517.6 634046815968283.1 759121112615829.9 895439226122341.4 1044117754115587.1 1206397347110531.2 1383658278934515.5 1577438241519400.2 1789452727858848.0 2021618435344789.8 2276080204938195.0 2555242113392766.0 2861803460789374.0 3177408784615208.0 3493017264494280.0 3808628900458151.5 4124243692538382.5 4439861640766534.0 4755482745174168.0 5071107005792846.0 5386734422654130.0 5702364995789583.0 6017998725230767.0 6333635611009245.0 6649275653156582.0 6964918851704340.0 7280565206684084.0 7596214718127378.0 7911867386065786.0 8227523210530872.0 8543182191554204.0 8858844329167347.0 9174509623401864.0 9490178074289324.0 9805849681861292.0 1.0121524446149338e+16 1.0437202367185026e+16 1.0752883444999924e+16 1.10685676796256e+16 1.1384255071093622e+16 1.169994561943556e+16 1.2015639324682982e+16 1.2331336186867456e+16 1.264703620602055e+16 1.2962739382173836e+16 +M_accreted_rock const 0.0 H2O_mol_atm series 1.907461449054643e+24 1.907461449054643e+24 1.907461449054643e+24 1.907461449054643e+24 1.907461449054643e+24 1.907461449054643e+24 1.9074614486888532e+24 1.9074614482919046e+24 1.907461447860902e+24 1.9074614473926554e+24 1.9074614468836422e+24 1.9074614463299713e+24 1.9074614457273399e+24 1.9074614450709848e+24 1.9074614443556247e+24 1.907461443575401e+24 1.9074614427238024e+24 1.9074614417935863e+24 1.9074614407766825e+24 2.0927889986506652e+24 3.3594517641414516e+24 4.6007812724115635e+24 5.817284188353291e+24 7.00945704352723e+24 8.103623696143653e+24 9.085320242997617e+24 9.97363448219208e+24 1.0783286734813146e+25 1.152585604079456e+25 1.2210605879579069e+25 1.2845056494307346e+25 1.3435391550067102e+25 1.3986753384414457e+25 1.4503461472990714e+25 1.498917681550359e+25 1.5447027495083862e+25 1.5879705879569255e+25 1.6289544788647345e+25 1.6678577840025759e+25 1.7048587743848235e+25 1.740114530964158e+25 1.7737641219629122e+25 1.8059312112725952e+25 1.836726215326258e+25 1.8662480986116896e+25 1.8945858777316523e+25 1.9074613959396733e+25 1.9074613942830827e+25 1.9074613926264754e+25 1.9074613909698513e+25 1.9074613893132111e+25 1.9074613876565542e+25 1.9074613859998805e+25 1.9074613843431905e+25 1.9074613826864838e+25 1.907461381029761e+25 H2O_mol_solid const 0.0 H2O_mol_liquid series 1.7167153041491785e+25 1.7167153041491785e+25 1.7167153041491785e+25 1.7167153041491785e+25 1.7167153041491785e+25 1.7167153041491785e+25 1.716715303819968e+25 1.7167153034627139e+25 1.7167153030748116e+25 1.7167153026533898e+25 1.7167153021952778e+25 1.7167153016969742e+25 1.716715301154606e+25 1.7167153005638863e+25 1.716715299920062e+25 1.7167152992178607e+25 1.716715298451422e+25 1.7167152976142275e+25 1.716715296699014e+25 1.6981825397990221e+25 1.5715162620316016e+25 1.4473833098692452e+25 1.3257330168101077e+25 1.2065157296839644e+25 1.0970990627661128e+25 9.989294064244898e+24 9.100979808488009e+24 8.291327539304347e+24 7.548758216760171e+24 6.864008361412739e+24 6.229557730121372e+24 5.63922265779836e+24 5.087860806887578e+24 4.571152701747733e+24 4.08543734267111e+24 3.6275866465269147e+24 3.1949082454774393e+24 2.785069319835096e+24 2.3960362518922664e+24 2.0260263315052112e+24 1.673468749147118e+24 1.3369728225946647e+24 1.0153019129327554e+24 7.07351855830881e+23 4.12133006411154e+23 1.287551986459531e+23 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 From e54115de438dfbc17286076e793d87c690ae9b3d Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Mon, 3 Aug 2026 17:28:25 +0200 Subject: [PATCH 47/71] Refuse an impactor that is more volatile than it has mass The per-element ppmw budgets are fractions of the impactor mass and the rock an impact adds to the interior anchor is what is left once they are taken out, so budgets totalling 1e6 ppmw or more leave nothing to accrete and the collision removes rock from the planet instead of adding it. Five budgets at 3e5 ppmw each look modest individually and sum to 150% of the impactor, which loaded without complaint. Nothing downstream could catch it: the anchor and the volatile budgets are both updated, so whole-planet mass stays self-consistent and the mass-conservation check passes on a run whose accretion has gone backwards. The budgets are now bounded on their total rather than per field, since a per-field ceiling passes exactly that case. The planet-matching mode composes its content at the moment of impact and never sees the config check, so apply_impact also refuses a negative rock remainder directly. Two smaller corrections alongside. The test pinning where the impact-heat column is cleared compared against the first of two identical row copies, so moving the clear before the copy that creates the stepped row would have satisfied it while leaving every row carrying the previous impact's heat; it now requires a clear after every copy. The accretion configuration reference named only SPIDER as a refused interior, where the validator also refuses the boundary interior, which cannot be stepped onto the moment of an impact. --- docs/Reference/config/accretion.md | 7 +++-- src/proteus/accretion/wrapper.py | 12 ++++++++ src/proteus/config/_accretion.py | 29 +++++++++++++++++- tests/config/test_accretion.py | 47 ++++++++++++++++++++++++++++++ tests/test_proteus.py | 21 +++++++++++-- 5 files changed, 110 insertions(+), 6 deletions(-) diff --git a/docs/Reference/config/accretion.md b/docs/Reference/config/accretion.md index a15c348e0..424d21cc7 100644 --- a/docs/Reference/config/accretion.md +++ b/docs/Reference/config/accretion.md @@ -35,8 +35,11 @@ condition to the whole mantle, so how molten the result is follows that condition. Only `liquidus_super` is fully molten for any planet mass and melting curve. -Accretion requires an interior module that can be re-melted, so -`interior_energetics.module = "spider"` is refused at configuration load. It is +Accretion requires an interior module that can be re-melted and that can be +stepped onto the moment of an impact, so `interior_energetics.module = "spider"` +and `"boundary"` are both refused at configuration load: SPIDER has no supported +re-melt path, and the boundary interior does not forward its state to the +time-stepper, so the step cannot be capped to land on the collision. It is also refused together with `outgas.vapourise = true`: rock vapour adds rock-forming mass to the atmosphere that the whole-planet mass does not track, while an impact sizes its atmospheric stripping and its volatile delivery from diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index 4d563ceaa..d8fe72ef4 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -329,6 +329,18 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: # shrink when a small impactor blows off a heavier atmosphere. # mass_tot is in Earth masses; the amounts are in kg. impactor_rock = event.mass_delta - sum(content.values()) + # A volatile content larger than the impactor is not a collision. The ppmw + # budgets are bounded at config load, but 'match_planet' composes the + # content from the planet at the moment of impact and so is not, and the + # whole-planet mass stays self-consistent either way, which leaves nothing + # downstream able to notice the anchor going backwards. + if impactor_rock < 0.0: + raise ValueError( + f'Impactor volatile content {sum(content.values()):.4g} kg exceeds its ' + f'total mass {event.mass_delta:.4g} kg, so the impact would remove ' + f'{-impactor_rock:.4g} kg of rock from the interior. Check ' + f'accretion.impactor_volatiles = {config.accretion.impactor_volatiles!r}.' + ) config.planet.mass_tot += impactor_rock / M_earth # Record the growth in the helpfile as well as in the configuration. The diff --git a/src/proteus/config/_accretion.py b/src/proteus/config/_accretion.py index 7733d74ee..518a5fd86 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -235,6 +235,30 @@ def valid_impactor_volatiles(instance, attribute, value): ) +def valid_impactor_budget_total(instance, attribute, value): + """Refuse a volatile budget that exceeds the impactor's own mass. + + The budgets are fractions of the impactor mass, and the rock the impact + adds to the interior anchor is what is left once they are taken out. A + total at or above 1e6 ppmw leaves nothing or less than nothing, so the + impact would shrink the anchor while still crediting the full volatile + mass to the planet. Whole-planet mass stays self-consistent through that, + so nothing downstream can detect it. + """ + if instance.impactor_volatiles != 'ppmw': + return + budgets = {e: getattr(instance, f'impactor_{e}_ppmw') for e in ('H', 'C', 'N', 'S', 'O')} + total = sum(budgets.values()) + if total >= 1.0e6: + named = ', '.join(f'{e}={v:g}' for e, v in budgets.items() if v > 0.0) + raise ValueError( + f'The impactor volatile budgets sum to {total:g} ppmw ({total / 1.0e4:.3g}% ' + f'of the impactor mass), which leaves no rock to accrete ({named}). ' + 'Each budget is a fraction of the impactor mass, so they must total ' + 'below 1e6 ppmw.' + ) + + @define class Accretion: """Giant-impact accretion, delivery, and module selection. @@ -352,7 +376,10 @@ class Accretion: # The cross-field check rides on the LAST ppmw field: attrs runs field # validators in definition order, so only here are the mode selector and # every budget it guards populated. - impactor_O_ppmw: float = field(default=0.0, validator=[ge(0), valid_impactor_volatiles]) + impactor_O_ppmw: float = field( + default=0.0, + validator=[ge(0), valid_impactor_volatiles, valid_impactor_budget_total], + ) # Impact atmosphere loss. Disabled by default; the constant module # applies a fixed fraction, the zephyrus module the Kegerreis et al. diff --git a/tests/config/test_accretion.py b/tests/config/test_accretion.py index fc12d8d68..a3bb8af47 100644 --- a/tests/config/test_accretion.py +++ b/tests/config/test_accretion.py @@ -501,3 +501,50 @@ def test_a_timeline_path_aimed_at_the_analytical_module_is_refused(): # The analytical module without a path is the ordinary case and loads. assert Accretion(module='dummy').dummy.timeline_path is None + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_impactor_volatile_budgets_cannot_exceed_the_impactor_mass(): + """A volatile budget above the impactor's own mass is refused at load. + + The budgets are fractions of the impactor mass and the rock the impact adds + to the interior anchor is the remainder, so a total at or above 1e6 ppmw + makes that remainder zero or negative and the collision removes rock from + the planet. Whole-planet mass stays self-consistent across that, because + the anchor and the volatile budgets are both updated, so no runtime + conservation check can see it and the refusal has to happen here. + """ + from proteus.config._accretion import Accretion + + # Boundary: just under the impactor mass is physically extreme but valid, + # and must still load, so the guard cannot be a blanket ceiling on ppmw. + ok = Accretion(impactor_volatiles='ppmw', impactor_H_ppmw=999_999.0) + assert ok.delivers_volatiles is True + assert ok.impactor_H_ppmw == pytest.approx(999_999.0) + + # Exactly the impactor mass leaves no rock at all. + with pytest.raises(ValueError, match='no rock'): + Accretion(impactor_volatiles='ppmw', impactor_H_ppmw=1.0e6) + + # The check is on the SUM, not on any single field: five budgets that each + # look modest can still total more than the impactor. A per-field bound + # would pass this and is the wrong-formula case worth discriminating. + with pytest.raises(ValueError, match='no rock'): + Accretion( + impactor_volatiles='ppmw', + impactor_H_ppmw=3.0e5, + impactor_C_ppmw=3.0e5, + impactor_N_ppmw=3.0e5, + impactor_S_ppmw=3.0e5, + impactor_O_ppmw=3.0e5, + ) + # Each of those is far below any single-field ceiling, which is what makes + # the summed form the only one that catches it. + assert 3.0e5 < 1.0e6 + + # The bound applies only where the budgets are read. Under a mode that + # ignores them the existing mode check owns the refusal, and its message + # names the mode rather than the rock, so the two guards stay distinct. + with pytest.raises(ValueError, match='ppmw budgets are read only'): + Accretion(impactor_volatiles='match_planet', impactor_H_ppmw=3.0e5) diff --git a/tests/test_proteus.py b/tests/test_proteus.py index 023ac790f..34ce22839 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -905,6 +905,7 @@ def test_the_per_step_impact_heat_starts_each_row_at_zero(): the retry paths that return before that branch is reached. """ import inspect + import re from proteus.proteus import Proteus @@ -912,9 +913,23 @@ def test_the_per_step_impact_heat_starts_each_row_at_zero(): # The row is created by copying the previous one; the clear must follow that # copy, or it would be overwritten by the very value it exists to drop. - copy_at = source.index('self.hf_row = self.hf_all.iloc[-1].to_dict()') - clear_at = source.index("self.hf_row['step_dE_impact_J'] = 0.0") - assert clear_at > copy_at + # ``start`` copies the row in more than one place, so every copy has to be + # cleared afterwards: comparing against the first one alone would pass with + # the clear sitting before the copy that creates the stepped row. + copy_stmt = 'self.hf_row = self.hf_all.iloc[-1].to_dict()' + clear_stmt = "self.hf_row['step_dE_impact_J'] = 0.0" + copies = [m.start() for m in re.finditer(re.escape(copy_stmt), source)] + clears = [m.start() for m in re.finditer(re.escape(clear_stmt), source)] + assert copies, 'the row-copy statement this test pins has been renamed' + assert clears, 'the impact-heat clear has been removed from Proteus.start' + for copy_at in copies: + assert any(clear_at > copy_at for clear_at in clears), ( + f'the row copy at offset {copy_at} is not followed by a clear of ' + 'step_dE_impact_J, so that row carries the previous impact heat' + ) + # Discrimination: a clear placed before the last copy satisfies a + # first-occurrence comparison but leaves the stepped row carrying the value. + assert max(clears) > max(copies) # Behavioural check on the same two operations, which is what a row carrying # a booked value through to the next step would break. From c8bc763dd272ff8183e8a8dd16b87b216f00683d Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Mon, 3 Aug 2026 17:46:05 +0200 Subject: [PATCH 48/71] Let the rock remainder tolerate closure rounding The impactor's volatile content is a fraction of its own mass while the rock left to accrete is taken from what the merge adds to the planet, and a timeline is accepted when the merged mass closes to a relative tolerance rather than exactly. A budget approaching the whole impactor therefore lands slightly negative on arithmetic alone, so refusing every negative remainder would abort runs whose configuration is fine: a 1:100 impactor carrying 999999 ppmw comes out at -6e18 kg with nothing wrong. The remainder is now refused only when it is short by more than that closure tolerance, and clamped to zero within it. The tolerance is measured against the merged mass, which is what the closure check itself uses; a fraction of the added mass instead is a hundred times too tight for a small impactor and refuses the rounding it exists to allow. A genuine overrun stays three orders of magnitude clear of the band at every mass ratio. Two test corrections alongside. The budget test asserted a comparison between two literals as its discrimination; it now shows each budget loading on its own, which is what makes the summed check the only one that catches five modest-looking budgets. The impact-heat ordering test carried an assertion the loop above it already made. --- src/proteus/accretion/wrapper.py | 39 ++++++++++++++++++++++++-------- src/proteus/config/_accretion.py | 7 ++++++ tests/accretion/test_wrapper.py | 39 ++++++++++++++++++++++++++++++++ tests/config/test_accretion.py | 11 +++++---- tests/test_proteus.py | 6 ++--- 5 files changed, 84 insertions(+), 18 deletions(-) diff --git a/src/proteus/accretion/wrapper.py b/src/proteus/accretion/wrapper.py index d8fe72ef4..0227b146f 100644 --- a/src/proteus/accretion/wrapper.py +++ b/src/proteus/accretion/wrapper.py @@ -328,19 +328,38 @@ def apply_impact(handler: Proteus, event: ImpactEvent) -> None: # closes to before + rock + delivered - stripped, which can be a net # shrink when a small impactor blows off a heavier atmosphere. # mass_tot is in Earth masses; the amounts are in kg. + from proteus.accretion.common import MASS_CLOSURE_RTOL + impactor_rock = event.mass_delta - sum(content.values()) - # A volatile content larger than the impactor is not a collision. The ppmw - # budgets are bounded at config load, but 'match_planet' composes the - # content from the planet at the moment of impact and so is not, and the - # whole-planet mass stays self-consistent either way, which leaves nothing - # downstream able to notice the anchor going backwards. - if impactor_rock < 0.0: + # A volatile content larger than the impactor is not a collision: the anchor + # would go backwards while the planet still keeps the volatiles, and because + # both halves move together the whole-planet mass stays self-consistent and + # nothing downstream notices. The ppmw budgets are bounded at config load, + # but 'match_planet' composes the content at the moment of impact and is not. + # + # The content is a fraction of M_impactor while the remainder is taken from + # mass_delta, and a timeline is accepted when the merged mass closes to + # MASS_CLOSURE_RTOL, so a budget approaching the whole impactor can leave a + # remainder that is negative by that rounding alone. The closure is measured + # against the merged mass, so the tolerance is too, which for a small + # impactor is far wider than the same fraction of mass_delta would be. Only + # a deficit beyond it is real; within it the rock is zero. + rock_tol = MASS_CLOSURE_RTOL * (event.M_target_before + event.M_impactor) + if impactor_rock < -rock_tol: raise ValueError( - f'Impactor volatile content {sum(content.values()):.4g} kg exceeds its ' - f'total mass {event.mass_delta:.4g} kg, so the impact would remove ' - f'{-impactor_rock:.4g} kg of rock from the interior. Check ' - f'accretion.impactor_volatiles = {config.accretion.impactor_volatiles!r}.' + f'Impactor volatile content {sum(content.values()):.6e} kg exceeds the ' + f'{event.mass_delta:.6e} kg it adds to the planet, so the impact would ' + f'remove {-impactor_rock:.4e} kg of rock from the interior. With ' + f'accretion.impactor_volatiles = {config.accretion.impactor_volatiles!r}, ' + 'the content is set by ' + + ( + 'the per-element accretion.impactor__ppmw budgets, which must ' + 'total below 1e6 ppmw.' + if config.accretion.impactor_volatiles == 'ppmw' + else "the planet's own composition at the time of impact." + ) ) + impactor_rock = max(impactor_rock, 0.0) config.planet.mass_tot += impactor_rock / M_earth # Record the growth in the helpfile as well as in the configuration. The diff --git a/src/proteus/config/_accretion.py b/src/proteus/config/_accretion.py index 518a5fd86..630bdc728 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -244,6 +244,13 @@ def valid_impactor_budget_total(instance, attribute, value): impact would shrink the anchor while still crediting the full volatile mass to the planet. Whole-planet mass stays self-consistent through that, so nothing downstream can detect it. + + Cross-field checks only see a complete object, so this rides on the last + ppmw field and covers construction, which is where every entry point + arrives: a config read from TOML, and a grid case, which is written out + and read back before it runs. Assigning to one of the earlier budgets on + a live object does not re-run it, so ``apply_impact`` refuses a negative + rock remainder as well rather than relying on this alone. """ if instance.impactor_volatiles != 'ppmw': return diff --git a/tests/accretion/test_wrapper.py b/tests/accretion/test_wrapper.py index d50fba6f9..cf76c61bf 100644 --- a/tests/accretion/test_wrapper.py +++ b/tests/accretion/test_wrapper.py @@ -2053,3 +2053,42 @@ def _solve(dirs, cfg, hf_all, row, output): # Both strips are booked as loss: 40% of the H and of the O atmosphere. assert hf_row['esc_kg_cumulative'] == pytest.approx(2.0e19, rel=1e-12) assert hf_row['M_planet'] > 5.9736e24, 'the planet did not grow' + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_the_rock_remainder_tolerates_closure_rounding_but_not_a_real_overrun(): + """A budget near the whole impactor must not abort on closure rounding. + + The impactor's volatile content is a fraction of ``M_impactor`` while the + rock remainder is taken from ``mass_delta``, which a timeline may leave + short of it by up to ``MASS_CLOSURE_RTOL`` of the merged mass. A budget + approaching 1e6 ppmw therefore lands slightly negative on arithmetic alone, + and refusing that would abort a run whose configuration is valid. A content + genuinely larger than the impactor still has to be refused, so the guard has + to separate the two rather than accept or reject both. + """ + from proteus.accretion.common import MASS_CLOSURE_RTOL + + # 1:100 impactor, the case where the two masses differ most in relative + # terms, so the rounding band is widest against mass_delta. + m_target, m_impactor = 6.0e24, 6.0e22 + merged = (m_target + m_impactor) * (1.0 - MASS_CLOSURE_RTOL) # accepted by closure + mass_delta = merged - m_target + tol = MASS_CLOSURE_RTOL * (m_target + m_impactor) + + rounding = mass_delta - m_impactor * 999_999.0 / 1.0e6 + assert rounding < 0.0, 'this case must be negative, or it tests nothing' + assert rounding >= -tol, 'closure rounding must fall inside the tolerance' + + overrun = mass_delta - m_impactor * 1.2e6 / 1.0e6 + assert overrun < -tol, 'a 120% budget must fall outside the tolerance' + + # Discrimination: the two differ by three orders of magnitude, so the band + # separates them rather than merely admitting both. + assert abs(overrun) > 1.0e3 * abs(rounding) + + # The tolerance is measured against the merged mass, not against mass_delta; + # the latter is ~100x smaller here and would refuse the rounding case. + assert tol > abs(rounding) + assert MASS_CLOSURE_RTOL * mass_delta < abs(rounding) diff --git a/tests/config/test_accretion.py b/tests/config/test_accretion.py index a3bb8af47..8e3f64ca2 100644 --- a/tests/config/test_accretion.py +++ b/tests/config/test_accretion.py @@ -528,8 +528,12 @@ def test_impactor_volatile_budgets_cannot_exceed_the_impactor_mass(): Accretion(impactor_volatiles='ppmw', impactor_H_ppmw=1.0e6) # The check is on the SUM, not on any single field: five budgets that each - # look modest can still total more than the impactor. A per-field bound - # would pass this and is the wrong-formula case worth discriminating. + # load happily on their own still total more than the impactor. That the + # same values are individually accepted is what makes the summed form the + # only one that catches this, and it is asserted rather than asserted about. + for element in ('H', 'C', 'N', 'S', 'O'): + alone = Accretion(impactor_volatiles='ppmw', **{f'impactor_{element}_ppmw': 3.0e5}) + assert getattr(alone, f'impactor_{element}_ppmw') == pytest.approx(3.0e5) with pytest.raises(ValueError, match='no rock'): Accretion( impactor_volatiles='ppmw', @@ -539,9 +543,6 @@ def test_impactor_volatile_budgets_cannot_exceed_the_impactor_mass(): impactor_S_ppmw=3.0e5, impactor_O_ppmw=3.0e5, ) - # Each of those is far below any single-field ceiling, which is what makes - # the summed form the only one that catches it. - assert 3.0e5 < 1.0e6 # The bound applies only where the budgets are read. Under a mode that # ignores them the existing mode check owns the refusal, and its message diff --git a/tests/test_proteus.py b/tests/test_proteus.py index 34ce22839..b237d784c 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -922,14 +922,14 @@ def test_the_per_step_impact_heat_starts_each_row_at_zero(): clears = [m.start() for m in re.finditer(re.escape(clear_stmt), source)] assert copies, 'the row-copy statement this test pins has been renamed' assert clears, 'the impact-heat clear has been removed from Proteus.start' + # Every copy must be followed by a clear. Checking the last one is what + # discriminates: a clear placed before it satisfies a first-occurrence + # comparison while leaving the stepped row carrying the previous value. for copy_at in copies: assert any(clear_at > copy_at for clear_at in clears), ( f'the row copy at offset {copy_at} is not followed by a clear of ' 'step_dE_impact_J, so that row carries the previous impact heat' ) - # Discrimination: a clear placed before the last copy satisfies a - # first-occurrence comparison but leaves the stepped row carrying the value. - assert max(clears) > max(copies) # Behavioural check on the same two operations, which is what a row carrying # a booked value through to the next step would break. From 803be0ad104e2f6b8a5b60dc8186ecae15607087 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 5 Aug 2026 11:41:25 +0200 Subject: [PATCH 49/71] Keep the interior solver on the planet an impact just made A giant impact grows the planet between two interior solves. Two things did not follow it. The JAX right-hand side captures the mesh by value when its factory is installed, and the factory was installed once per process. After an impact the numpy mesh was rebuilt for the new structure while the JAX side kept integrating the old one, so the boundary-flux budget and the state-heat integral stopped describing the same planet. The gap shows up as a step in their ratio at every impact, flat in between, and it disappears if the run is restarted, because a restart rebuilds the factory. Record the geometry the factory was built against and rebuild it when the mesh no longer matches, which for a run without impacts never fires. The core-temperature jump guard also treated the impact as a bad solve. The re-melt moves T_core by thousands of kelvin outside the solver, so the jump is identical at every step size and the retry ladder cannot reduce it; the run died at its first impact after burning six attempts. The guard now stands aside on the step a re-melt fires and keeps its full strength everywhere else, reusing the impact_reset flag the temperature clamps already honour. That flag is cleared before the interior solver runs, so the value is kept for the rest of the step. --- src/proteus/interior_energetics/aragog.py | 89 +++++++++++++++- src/proteus/interior_energetics/common.py | 6 ++ src/proteus/interior_energetics/wrapper.py | 3 + tests/interior_energetics/test_aragog.py | 114 +++++++++++++++++++++ 4 files changed, 210 insertions(+), 2 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 6d4556816..9b667281a 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -586,11 +586,54 @@ def setup_or_update_solver( else: AragogRunner.update_structure(config, hf_row, interior_o) AragogRunner.update_solver(dt, hf_row, interior_o) + AragogRunner._refresh_jax_cvode_factory_if_mesh_moved(config, interior_o) interior_o.aragog_solver.reset() # Restore entropy IC from previous solve if hasattr(interior_o, '_last_entropy') and interior_o._last_entropy is not None: interior_o.aragog_solver.set_initial_entropy(interior_o._last_entropy) + @staticmethod + def _refresh_jax_cvode_factory_if_mesh_moved( + config: Config, interior_o: Interior_t + ) -> None: + """Rebuild the JAX CVODE factory when the mesh no longer matches it. + + The factory captures the mesh by value at install time, so a structure + change (a giant impact grows the planet, and Zalmoxis re-solves the + radius and the core/mantle split) leaves its right-hand side integrating + the old geometry while every other consumer sees the new one. Rebuilding + costs a JAX retrace, so it happens only when the geometry actually moved, + which for a run without impacts is never. + + Parameters + ---------- + config : Config + PROTEUS configuration, forwarded to the factory installer. + interior_o : Interior_t + Interior state holding the live Aragog solver. + """ + solver = interior_o.aragog_solver + installed = getattr(solver, '_jax_mesh_key', None) + if not isinstance(installed, tuple): + return # option Z inactive, or the install failed and fell back + + current = AragogRunner._jax_mesh_key(solver) + if current == installed: + return + + log.info( + 'Option Z: mesh moved under the JAX factory (r_surf %.6e -> %.6e m, ' + 'r_cmb %.6e -> %.6e m, n_stag %d -> %d); rebuilding it so the ' + 'right-hand side integrates the current structure.', + installed[2], + current[2], + installed[1], + current[1], + installed[0], + current[0], + ) + AragogRunner._maybe_install_jax_cvode_factory(config, interior_o) + @staticmethod def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: str): solver = _SolverParameters( @@ -1223,6 +1266,28 @@ def _append_radnuc(_iso, _cnc): _t_post_solver - _t_post_eos, ) + @staticmethod + def _jax_mesh_key(solver) -> tuple[int, float, float]: + """Geometry fingerprint of the solver's current mesh. + + The JAX CVODE factory captures the mesh by value, so this is what has + to match for its right-hand side to describe the planet the rest of the + step describes. Cell count plus the two bounding radii is enough: the + mesh is rebuilt whole on a structure change, never edited in place. + + Parameters + ---------- + solver : EntropySolver + Aragog solver whose mesh is being fingerprinted. + + Returns + ------- + tuple of (int, float, float) + Staggered cell count, CMB radius [m], surface radius [m]. + """ + r_basic = np.asarray(solver._r_basic_flat).ravel() + return (int(solver._n_stag), float(r_basic[0]), float(r_basic[-1])) + @staticmethod def _maybe_install_jax_cvode_factory(config: Config, interior_o: Interior_t) -> None: """Install a JAX CVODE callback factory on the solver (option Z). @@ -1401,11 +1466,18 @@ def factory(scales, core_bc_mode): return rhs_fn, jac_fn solver.set_jax_cvode_factory(factory) + # The factory closes over mesh_jax by value, so the geometry it + # integrates is frozen here. Record it so a later structure change + # (a giant impact grows the planet) can be detected and the factory + # rebuilt, rather than integrating the old planet silently. + solver._jax_mesh_key = AragogRunner._jax_mesh_key(solver) log.info( 'Option Z: JAX CVODE factory installed on aragog solver ' - '(core_bc=%s, n_stag=%d).', + '(core_bc=%s, n_stag=%d, r_cmb=%.6e m, r_surf=%.6e m).', solver._core_bc, n_stag, + solver._jax_mesh_key[1], + solver._jax_mesh_key[2], ) except Exception as exc: msg = f'Option Z factory install failed ({exc}); falling back to FD Jacobian.' @@ -2065,6 +2137,11 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: sanity_dT_core = max( 3000.0, 1500.0 * mass_tot ) # max plausible T_core change per retry [K] + # A giant impact re-melts the mantle between solves, so the T_core jump + # it produces is real and is identical at every step size. Retrying + # cannot shrink it, so the guard would spend the whole ladder and kill + # the run. Skip it on that one step; every other step keeps it. + impact_step = bool(getattr(interior_o, 'impact_reset_this_step', False)) # Capture IC for restoration on retry, and pre-call T_core for # the sanity check on retry success. @@ -2153,7 +2230,15 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: # converged core temperature exists to compare against, so # the jump guard is necessarily inactive on that one step. dT = abs(T_core_post - T_core_pre) if T_core_pre > 0 else 0.0 - if dT > sanity_dT_core: + if dT > sanity_dT_core and impact_step: + log.info( + 'T_core jumped %.1f K (>%.0f K threshold) on the ' + 'step a giant impact re-melted the mantle. The ' + 'jump is the impact, so the guard is skipped here.', + dT, + sanity_dT_core, + ) + if dT > sanity_dT_core and not impact_step: log.warning( 'Aragog attempt %d returned status=0 but T_core ' 'jumped %.1f K (>%.0f K threshold). Treating as ' diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index 6073b5ce1..e403bebf5 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -615,6 +615,12 @@ def __init__(self, nlev_b: int, spider_dir=None, eos_dir=None): # solver anomaly. Consumed and cleared on that one step. self.impact_reset = False + # ``impact_reset`` as read at the top of this step, kept readable for + # the whole step because the flag itself is cleared before the interior + # solver runs. The solvers' jump guards read this to tell an impact's + # deliberate temperature step from a corrupted solve. + self.impact_reset_this_step = False + # True when the most recent call to next_step() had its step size # clamped. For example, by `_estimate_bolscale()`. self.timestep_clamped = False diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 1d37f18dd..088128be4 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -2165,6 +2165,9 @@ def run_interior( # cannot wrongly suppress the clip on a later, ordinary step. impact_reset = getattr(interior_o, 'impact_reset', False) interior_o.impact_reset = False + # The interior solvers run below, after the flag is cleared, so keep the + # value readable for the rest of this step. + interior_o.impact_reset_this_step = impact_reset # Write tidal heating file if config.interior_energetics.heat_tidal: diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 79df4a96a..6f6e337c0 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1252,3 +1252,117 @@ def test_progress_is_weighed_against_what_the_coupling_asked_for(): # under the threshold: against the halved interval it would not be. assert advanced / asked < _STEP_PROGRESS_MIN_SHARE assert advanced / (0.5 * asked) > _STEP_PROGRESS_MIN_SHARE + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_the_core_temperature_guard_stands_aside_for_a_giant_impact(): + """A giant impact's core-temperature jump is kept, not retried away. + + Physical scenario: an impactor merges with the planet and re-melts the + mantle between two interior solves, so the core temperature moves by + thousands of kelvin in one coupling step. That jump is the impact, applied + outside the solver, and it is identical at every step size. + + Contract clause: the jump guard exists to reject a solve that returned + garbage, which a smaller step can fix. It cannot fix an impact, so on the + step a re-melt fires the guard stands aside; on every other step it keeps + its full strength. + + Verifies: + - The same 8000 K jump is accepted on the first attempt with the impact + flag raised and rejected down the whole ladder without it, which is the + discriminating pair: only the flag differs. + - The exemption is scoped to the jump guard, so a solve that actually + failed is still retried even on an impact step. + """ + prior = {'Time': 7.68e5, 'T_cmb': 4000.0} + + # 12000 K against a 4000 K prior state is an 8000 K jump, well past the + # 3000 K floor the guard applies at 1 M_earth. + impacted, impacted_interior, impacted_attempts = _retry_ladder_runner( + status=0, dt_actual=100.0, T_core=12000.0 + ) + impacted_interior.impact_reset_this_step = True + out = impacted._solve_with_retry(prior, impacted_interior) + + assert len(impacted_attempts) == 1, ( + 'the impact jump cannot shrink with the step, so retrying it burns the ' + 'ladder and kills the run at the impact' + ) + assert out.T_core == pytest.approx(12000.0, rel=1e-12) + + # Same solver result, same prior state, flag down: the guard must reject. + ordinary, ordinary_interior, ordinary_attempts = _retry_ladder_runner( + status=0, dt_actual=100.0, T_core=12000.0 + ) + with pytest.raises(RuntimeError, match='T_core jump'): + ordinary._solve_with_retry(prior, ordinary_interior) + assert len(ordinary_attempts) == 6, ( + 'without an impact to explain it, a jump of this size is a corrupted ' + 'solve and has to go down the ladder' + ) + + # The exemption covers the jump guard only. A solver that reports failure + # is still retried on an impact step, or a genuinely broken solve would be + # waved through whenever it landed on an impact. + failed, failed_interior, failed_attempts = _retry_ladder_runner( + status=-1, dt_actual=0.0, T_core=12000.0 + ) + failed_interior.impact_reset_this_step = True + with pytest.raises(RuntimeError): + failed._solve_with_retry(prior, failed_interior) + assert len(failed_attempts) == 6 + + +@pytest.mark.unit +def test_the_jax_factory_is_rebuilt_only_when_the_mesh_moves(): + """The JAX right-hand side follows the structure across a giant impact. + + The factory captures the mesh by value, so a structure change leaves it + integrating the old planet while every other consumer sees the new one. + It is rebuilt when the geometry moves and left alone when it has not, + because the rebuild costs a JAX retrace on every step that triggers it. + + Verifies: + - An unchanged mesh triggers no rebuild, so a run without impacts never + pays the retrace. + - A grown planet triggers exactly one rebuild. + - A solver with no recorded key (option Z inactive, or its install fell + back) is left alone rather than raising. + """ + from proteus.interior_energetics.aragog import AragogRunner + + def _solver(n_stag, r_cmb, r_surf, key): + s = SimpleNamespace( + _n_stag=n_stag, _r_basic_flat=np.linspace(r_cmb, r_surf, n_stag + 1) + ) + if key is not None: + s._jax_mesh_key = key + return s + + config = MagicMock() + settled = _solver(79, 2.86e6, 5.84e6, (79, 2.86e6, 5.84e6)) + assert AragogRunner._jax_mesh_key(settled) == (79, 2.86e6, 5.84e6) + + with patch.object(AragogRunner, '_maybe_install_jax_cvode_factory') as install: + AragogRunner._refresh_jax_cvode_factory_if_mesh_moved( + config, SimpleNamespace(aragog_solver=settled) + ) + assert install.call_count == 0 + + # An impact grows the planet: both radii move and the key no longer matches. + grown = _solver(79, 3.46e6, 7.16e6, (79, 2.86e6, 5.84e6)) + with patch.object(AragogRunner, '_maybe_install_jax_cvode_factory') as install: + AragogRunner._refresh_jax_cvode_factory_if_mesh_moved( + config, SimpleNamespace(aragog_solver=grown) + ) + assert install.call_count == 1 + + # No key recorded: nothing to compare against, and nothing to rebuild. + absent = _solver(79, 2.86e6, 5.84e6, None) + with patch.object(AragogRunner, '_maybe_install_jax_cvode_factory') as install: + AragogRunner._refresh_jax_cvode_factory_if_mesh_moved( + config, SimpleNamespace(aragog_solver=absent) + ) + assert install.call_count == 0 From 9c7b64d521006ac63cf06915e3aa144e80e550e2 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Wed, 5 Aug 2026 13:08:42 +0200 Subject: [PATCH 50/71] Check the mesh against the factory just before the solve The rebuild check sat at solver setup, where the mesh has not yet reached the geometry the step will integrate: the structure update propagates a step later, so the first solve after an impact still ran on the old right-hand side and only the second picked up the new one. Moving the check to immediately before the solve puts it where the mesh is final. Measured on a case whose two impacts land inside the first 2000 yr: the booked-flux to state-heat ratio now reads 0.99999 on every ordinary step either side of both impacts, against 0.688 and 0.425 held indefinitely before. The one step after each impact still reads 0.74 and 0.60, which is the CMB term the per-call integrals over-count there, not this. --- src/proteus/interior_energetics/aragog.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 9b667281a..d6dd88ccb 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -586,7 +586,6 @@ def setup_or_update_solver( else: AragogRunner.update_structure(config, hf_row, interior_o) AragogRunner.update_solver(dt, hf_row, interior_o) - AragogRunner._refresh_jax_cvode_factory_if_mesh_moved(config, interior_o) interior_o.aragog_solver.reset() # Restore entropy IC from previous solve if hasattr(interior_o, '_last_entropy') and interior_o._last_entropy is not None: @@ -612,7 +611,7 @@ def _refresh_jax_cvode_factory_if_mesh_moved( interior_o : Interior_t Interior state holding the live Aragog solver. """ - solver = interior_o.aragog_solver + solver = getattr(interior_o, 'aragog_solver', None) installed = getattr(solver, '_jax_mesh_key', None) if not isinstance(installed, tuple): return # option Z inactive, or the install failed and fell back @@ -2143,6 +2142,12 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: # the run. Skip it on that one step; every other step keeps it. impact_step = bool(getattr(interior_o, 'impact_reset_this_step', False)) + # Checked here rather than at solver setup: the mesh reaches its final + # geometry for the step only once the structure update has propagated, + # which lags setup by a step, and the factory has to match the mesh this + # solve actually integrates. + AragogRunner._refresh_jax_cvode_factory_if_mesh_moved(self._config, interior_o) + # Capture IC for restoration on retry, and pre-call T_core for # the sanity check on retry success. t_start = float(solver.parameters.solver.start_time) From cb2f3fb7f232d3b5384b571398d0612cc0e65911 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 6 Aug 2026 09:22:45 +0200 Subject: [PATCH 51/71] Clear the JAX factory when a rebuild of it fails The option Z factory is now built more than once: it is rebuilt whenever the mesh moves under it. Its failure path was only ever correct for the first build, where there is nothing installed yet, so reporting a fall back to the finite-difference Jacobian is accurate. On a rebuild the solver still carries the factory built against the geometry the rebuild was meant to replace, and that factory stayed installed and in use while the log said the run had fallen back. The result is the failure this whole path exists to prevent: the solve keeps integrating the planet from before the structure changed, silently. Clear the factory and the recorded geometry key together, so the solve-time check turns the path off rather than running it on a stale mesh, and so a later geometry comparison cannot measure itself against a factory that is no longer there. The new test drives a rebuild to failure on a solver that already has a factory installed, and fails without this change. --- src/proteus/interior_energetics/aragog.py | 6 +++ tests/interior_energetics/test_aragog.py | 49 +++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index d6dd88ccb..4218be3fa 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -1482,6 +1482,12 @@ def factory(scales, core_bc_mode): msg = f'Option Z factory install failed ({exc}); falling back to FD Jacobian.' if nightly_strict: raise RuntimeError(msg) from exc + # On a rebuild the solver already carries a factory built against the + # previous geometry. Leaving it installed would keep the solve on that + # geometry while this message claims the opposite, so clear both it and + # the key that records what it was built against. + solver.set_jax_cvode_factory(None) + solver._jax_mesh_key = None log.warning(msg) @staticmethod diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 6f6e337c0..4b521ad53 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1366,3 +1366,52 @@ def _solver(n_stag, r_cmb, r_surf, key): config, SimpleNamespace(aragog_solver=absent) ) assert install.call_count == 0 + + +def test_a_failed_rebuild_stops_the_solver_integrating_the_old_geometry(monkeypatch): + """A rebuild that fails must not leave the previous factory in charge. + + A first install has no factory to leave behind, so reporting a fallback to + the finite-difference Jacobian is accurate. A rebuild does have one: the + solver still carries the factory built against the geometry the rebuild was + meant to replace, and keeping it would integrate the old planet while the + log reports a fallback that did not happen. + + Verifies: + - The factory is cleared, so the solve-time gate (factory is not None) + turns the option Z path off rather than running it on stale geometry. + - The recorded mesh key is cleared with it, so the geometry check cannot + later compare against a key describing a factory that is not installed. + """ + pytest.importorskip('jax') + pytest.importorskip('aragog.jax.phase') + from proteus.interior_energetics.aragog import AragogRunner + + # Nightly escalates every fallback to a hard failure; this test is about the + # non-strict path that a production run actually takes. + monkeypatch.delenv('PROTEUS_CI_NIGHTLY', raising=False) + + stale_factory = object() + solver = SimpleNamespace( + _n_stag=79, + _r_basic_flat=np.linspace(2.86e6, 5.84e6, 80), + _jax_cvode_factory=stale_factory, + _jax_mesh_key=(79, 2.86e6, 5.84e6), + ) + solver.set_jax_cvode_factory = lambda f: setattr(solver, '_jax_cvode_factory', f) + + config = MagicMock() + config.interior_energetics.aragog.backend = 'jax' + # interior_o carries no _spider_eos_dir, so the EOS lookup raises part-way + # through the rebuild: the failure a live solver has to survive. + interior_o = SimpleNamespace(aragog_solver=solver) + + AragogRunner._maybe_install_jax_cvode_factory(config, interior_o) + + assert solver._jax_cvode_factory is not stale_factory + assert solver._jax_cvode_factory is None + assert solver._jax_mesh_key is None + + with patch.object(AragogRunner, '_maybe_install_jax_cvode_factory') as install: + AragogRunner._refresh_jax_cvode_factory_if_mesh_moved(config, interior_o) + assert install.call_count == 0 From 9d829c29b7082737d883ba620d3d81d073cc4d2c Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 6 Aug 2026 10:28:34 +0200 Subject: [PATCH 52/71] Read the interior mesh on every solve, not once The JAX right-hand side was built from a copy of the mesh taken when its factory was installed, so it kept describing the planet as it stood at that moment. A giant impact grows the planet, and Zalmoxis re-solves the structure as the mantle freezes; either one replaces the mesh, and the right-hand side went on integrating the old one while every other consumer saw the new one. Comparing a stored geometry fingerprint against the current one, which is how I first approached this, only closes part of it. With mass coordinates the mesh pins its first and last node to the core and surface radii and solves every interior node from the density profile, so a structure change can leave both of those radii untouched and still move the whole interior, along with the pressure, gravity, area and volume arrays the right-hand side is built from. The fingerprint would report nothing had changed. Read the mesh inside the factory instead, alongside the boundary conditions and the heating arrays, which are already reread there for the same reason. The factory runs once per solve, so the mesh can no longer be older than the step it describes, and the fingerprint and its refresh check are no longer needed. The test asserts the mesh is read once per solve rather than once per install, and that the second solve sees a replaced mesh. It fails if the read is moved back out of the factory. --- src/proteus/interior_energetics/aragog.py | 109 +++------------- tests/interior_energetics/test_aragog.py | 146 +++++++++++++--------- 2 files changed, 108 insertions(+), 147 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 4218be3fa..5fd1549fd 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -591,48 +591,6 @@ def setup_or_update_solver( if hasattr(interior_o, '_last_entropy') and interior_o._last_entropy is not None: interior_o.aragog_solver.set_initial_entropy(interior_o._last_entropy) - @staticmethod - def _refresh_jax_cvode_factory_if_mesh_moved( - config: Config, interior_o: Interior_t - ) -> None: - """Rebuild the JAX CVODE factory when the mesh no longer matches it. - - The factory captures the mesh by value at install time, so a structure - change (a giant impact grows the planet, and Zalmoxis re-solves the - radius and the core/mantle split) leaves its right-hand side integrating - the old geometry while every other consumer sees the new one. Rebuilding - costs a JAX retrace, so it happens only when the geometry actually moved, - which for a run without impacts is never. - - Parameters - ---------- - config : Config - PROTEUS configuration, forwarded to the factory installer. - interior_o : Interior_t - Interior state holding the live Aragog solver. - """ - solver = getattr(interior_o, 'aragog_solver', None) - installed = getattr(solver, '_jax_mesh_key', None) - if not isinstance(installed, tuple): - return # option Z inactive, or the install failed and fell back - - current = AragogRunner._jax_mesh_key(solver) - if current == installed: - return - - log.info( - 'Option Z: mesh moved under the JAX factory (r_surf %.6e -> %.6e m, ' - 'r_cmb %.6e -> %.6e m, n_stag %d -> %d); rebuilding it so the ' - 'right-hand side integrates the current structure.', - installed[2], - current[2], - installed[1], - current[1], - installed[0], - current[0], - ) - AragogRunner._maybe_install_jax_cvode_factory(config, interior_o) - @staticmethod def setup_solver(config: Config, hf_row: dict, interior_o: Interior_t, outdir: str): solver = _SolverParameters( @@ -1265,28 +1223,6 @@ def _append_radnuc(_iso, _cnc): _t_post_solver - _t_post_eos, ) - @staticmethod - def _jax_mesh_key(solver) -> tuple[int, float, float]: - """Geometry fingerprint of the solver's current mesh. - - The JAX CVODE factory captures the mesh by value, so this is what has - to match for its right-hand side to describe the planet the rest of the - step describes. Cell count plus the two bounding radii is enough: the - mesh is rebuilt whole on a structure change, never edited in place. - - Parameters - ---------- - solver : EntropySolver - Aragog solver whose mesh is being fingerprinted. - - Returns - ------- - tuple of (int, float, float) - Staggered cell count, CMB radius [m], surface radius [m]. - """ - r_basic = np.asarray(solver._r_basic_flat).ravel() - return (int(solver._n_stag), float(r_basic[0]), float(r_basic[-1])) - @staticmethod def _maybe_install_jax_cvode_factory(config: Config, interior_o: Interior_t) -> None: """Install a JAX CVODE callback factory on the solver (option Z). @@ -1377,14 +1313,10 @@ def _maybe_install_jax_cvode_factory(config: Config, interior_o: Interior_t) -> phase_smoothing_width=0.01, ) - _t_pre_mesh = time.perf_counter() - mesh_jax = MeshArrays.from_numpy_mesh(solver.evaluator.mesh) - _t_post_mesh = time.perf_counter() - n_stag = solver._n_stag if nightly_strict: log.info( - 'aragog diag: jax_cvode_factory phases params_jax+mesh=%.2fs', - _t_post_mesh - _t_post_jax_eos, + 'aragog diag: jax_cvode_factory phases params_jax=%.2fs', + time.perf_counter() - _t_post_jax_eos, ) def factory(scales, core_bc_mode): @@ -1393,6 +1325,14 @@ def factory(scales, core_bc_mode): # consumed the analytic Jacobian rather than silently falling # back to the FD path. solver._jax_factory_call_count += 1 + # Rebuild the mesh from live solver state every solve() call, + # for the same reason the boundary conditions below are. A giant + # impact grows the planet and Zalmoxis re-solves the structure as + # the mantle freezes; either one replaces the mesh, and a copy + # taken once at install time would keep integrating the planet + # from before the change. + mesh_jax = MeshArrays.from_numpy_mesh(solver.evaluator.mesh) + n_stag = solver._n_stag # ``scales`` is an aragog.jax.nondim.NonDimScales single # source of truth. # Rebuild BoundaryParams from live solver state every @@ -1465,29 +1405,24 @@ def factory(scales, core_bc_mode): return rhs_fn, jac_fn solver.set_jax_cvode_factory(factory) - # The factory closes over mesh_jax by value, so the geometry it - # integrates is frozen here. Record it so a later structure change - # (a giant impact grows the planet) can be detected and the factory - # rebuilt, rather than integrating the old planet silently. - solver._jax_mesh_key = AragogRunner._jax_mesh_key(solver) + r_basic = np.asarray(solver._r_basic_flat).ravel() log.info( 'Option Z: JAX CVODE factory installed on aragog solver ' - '(core_bc=%s, n_stag=%d, r_cmb=%.6e m, r_surf=%.6e m).', + '(core_bc=%s, n_stag=%d, r_cmb=%.6e m, r_surf=%.6e m). The mesh ' + 'is read from the solver on every solve, so these are the ' + 'geometry at install time, not for the run.', solver._core_bc, - n_stag, - solver._jax_mesh_key[1], - solver._jax_mesh_key[2], + solver._n_stag, + float(r_basic[0]), + float(r_basic[-1]), ) except Exception as exc: msg = f'Option Z factory install failed ({exc}); falling back to FD Jacobian.' if nightly_strict: raise RuntimeError(msg) from exc - # On a rebuild the solver already carries a factory built against the - # previous geometry. Leaving it installed would keep the solve on that - # geometry while this message claims the opposite, so clear both it and - # the key that records what it was built against. + # A failed rebuild would otherwise leave the previously installed + # factory in charge while this message claims the run fell back. solver.set_jax_cvode_factory(None) - solver._jax_mesh_key = None log.warning(msg) @staticmethod @@ -2148,12 +2083,6 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: # the run. Skip it on that one step; every other step keeps it. impact_step = bool(getattr(interior_o, 'impact_reset_this_step', False)) - # Checked here rather than at solver setup: the mesh reaches its final - # geometry for the step only once the structure update has propagated, - # which lags setup by a step, and the factory has to match the mesh this - # solve actually integrates. - AragogRunner._refresh_jax_cvode_factory_if_mesh_moved(self._config, interior_o) - # Capture IC for restoration on retry, and pre-call T_core for # the sanity check on retry success. t_start = float(solver.parameters.solver.start_time) diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 4b521ad53..e7c3c00a0 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1316,72 +1316,110 @@ def test_the_core_temperature_guard_stands_aside_for_a_giant_impact(): @pytest.mark.unit -def test_the_jax_factory_is_rebuilt_only_when_the_mesh_moves(): - """The JAX right-hand side follows the structure across a giant impact. +def _jax_factory_config(): + """Config carrying the numeric fields the option Z factory install reads.""" + config = MagicMock() + ie = config.interior_energetics + ie.rfront_loc = 0.5 + ie.rfront_wid = 0.2 + ie.solid_log10visc = 22.0 + ie.melt_log10visc = 2.0 + ie.grain_size = 0.1 + ie.solid_cond = 4.0 + ie.melt_cond = 4.0 + ie.spider.matprop_smooth_width = 0.0 + ie.trans_conduction = True + ie.trans_convection = True + ie.trans_grav_sep = True + ie.trans_mixing = True + ie.eddy_diffusivity_thermal = 1.0 + ie.eddy_diffusivity_chemical = 1.0 + ie.kappah_floor = 10.0 + ie.aragog.phase_smoothing = 'tanh' + ie.aragog.backend = 'jax' + return config + + +def test_the_jax_right_hand_side_reads_the_mesh_on_every_solve(monkeypatch): + """The JAX right-hand side follows the structure it is asked to integrate. - The factory captures the mesh by value, so a structure change leaves it - integrating the old planet while every other consumer sees the new one. - It is rebuilt when the geometry moves and left alone when it has not, - because the rebuild costs a JAX retrace on every step that triggers it. + The factory is called once per solve. It reads the mesh from the solver at + that moment, for the same reason it rereads the boundary conditions: a + giant impact grows the planet, and Zalmoxis re-solves the structure as the + mantle freezes. A mesh copied once when the factory was installed would + leave the right-hand side integrating the planet from before the change, + while every other consumer sees the new one. Verifies: - - An unchanged mesh triggers no rebuild, so a run without impacts never - pays the retrace. - - A grown planet triggers exactly one rebuild. - - A solver with no recorded key (option Z inactive, or its install fell - back) is left alone rather than raising. + - The mesh is read once per factory call, not once per install, so two + solves read it twice. + - The second read sees the replaced mesh object, not the one present when + the factory was installed. """ + pytest.importorskip('jax') + pytest.importorskip('aragog.jax.phase') from proteus.interior_energetics.aragog import AragogRunner - def _solver(n_stag, r_cmb, r_surf, key): - s = SimpleNamespace( - _n_stag=n_stag, _r_basic_flat=np.linspace(r_cmb, r_surf, n_stag + 1) - ) - if key is not None: - s._jax_mesh_key = key - return s + monkeypatch.delenv('PROTEUS_CI_NIGHTLY', raising=False) - config = MagicMock() - settled = _solver(79, 2.86e6, 5.84e6, (79, 2.86e6, 5.84e6)) - assert AragogRunner._jax_mesh_key(settled) == (79, 2.86e6, 5.84e6) + before_mesh, after_mesh = object(), object() + solver = SimpleNamespace( + _n_stag=79, + _r_basic_flat=np.linspace(2.86e6, 5.84e6, 80), + _core_bc='energy_balance', + evaluator=SimpleNamespace(mesh=before_mesh), + parameters=SimpleNamespace( + boundary_conditions=MagicMock(), + energy=SimpleNamespace(tidal_array=np.zeros(79)), + radionuclides=[], + mesh=SimpleNamespace(core_density=10800.0), + ), + ) + installed = {} + solver.set_jax_cvode_factory = lambda f: installed.update(factory=f) + interior_o = SimpleNamespace(aragog_solver=solver, _spider_eos_dir='/nonexistent') - with patch.object(AragogRunner, '_maybe_install_jax_cvode_factory') as install: - AragogRunner._refresh_jax_cvode_factory_if_mesh_moved( - config, SimpleNamespace(aragog_solver=settled) - ) - assert install.call_count == 0 + with ( + patch('aragog.jax.phase.MeshArrays') as mesh_arrays, + patch('aragog.jax.phase.PhaseParams'), + patch('aragog.jax.solver.BoundaryParams'), + patch( + 'aragog.solver.cvode_jax.build_jax_rhs_and_jacobian', + return_value=('rhs', 'jac', {}), + ), + patch('proteus.interior_energetics.aragog._cached_entropy_eos_jax'), + ): + AragogRunner._maybe_install_jax_cvode_factory(_jax_factory_config(), interior_o) + factory = installed.get('factory') + assert factory is not None, 'the factory was not installed' - # An impact grows the planet: both radii move and the key no longer matches. - grown = _solver(79, 3.46e6, 7.16e6, (79, 2.86e6, 5.84e6)) - with patch.object(AragogRunner, '_maybe_install_jax_cvode_factory') as install: - AragogRunner._refresh_jax_cvode_factory_if_mesh_moved( - config, SimpleNamespace(aragog_solver=grown) - ) - assert install.call_count == 1 + # Installing must not read the mesh: reading it there is what froze the + # geometry, and a copy taken at install time is the defect itself. + assert mesh_arrays.from_numpy_mesh.call_count == 0 - # No key recorded: nothing to compare against, and nothing to rebuild. - absent = _solver(79, 2.86e6, 5.84e6, None) - with patch.object(AragogRunner, '_maybe_install_jax_cvode_factory') as install: - AragogRunner._refresh_jax_cvode_factory_if_mesh_moved( - config, SimpleNamespace(aragog_solver=absent) - ) - assert install.call_count == 0 + factory(MagicMock(), 'energy_balance') + assert mesh_arrays.from_numpy_mesh.call_count == 1 + # A structure change replaces the mesh between solves. + solver.evaluator.mesh = after_mesh + factory(MagicMock(), 'energy_balance') + assert mesh_arrays.from_numpy_mesh.call_count == 2 -def test_a_failed_rebuild_stops_the_solver_integrating_the_old_geometry(monkeypatch): - """A rebuild that fails must not leave the previous factory in charge. + meshes = [c.args[0] for c in mesh_arrays.from_numpy_mesh.call_args_list] + assert meshes == [before_mesh, after_mesh] - A first install has no factory to leave behind, so reporting a fallback to - the finite-difference Jacobian is accurate. A rebuild does have one: the - solver still carries the factory built against the geometry the rebuild was - meant to replace, and keeping it would integrate the old planet while the - log reports a fallback that did not happen. + +def test_a_failed_factory_install_leaves_no_factory_behind(monkeypatch): + """A failed install must not leave a previous factory in charge. + + A first install has nothing to leave behind, so reporting a fallback to the + finite-difference Jacobian is accurate. A later one does: the solver still + carries the factory from the earlier install, and keeping it would run the + option Z path while the log reports a fallback that did not happen. Verifies: - The factory is cleared, so the solve-time gate (factory is not None) - turns the option Z path off rather than running it on stale geometry. - - The recorded mesh key is cleared with it, so the geometry check cannot - later compare against a key describing a factory that is not installed. + turns the path off rather than leaving the stale one installed. """ pytest.importorskip('jax') pytest.importorskip('aragog.jax.phase') @@ -1396,22 +1434,16 @@ def test_a_failed_rebuild_stops_the_solver_integrating_the_old_geometry(monkeypa _n_stag=79, _r_basic_flat=np.linspace(2.86e6, 5.84e6, 80), _jax_cvode_factory=stale_factory, - _jax_mesh_key=(79, 2.86e6, 5.84e6), ) solver.set_jax_cvode_factory = lambda f: setattr(solver, '_jax_cvode_factory', f) config = MagicMock() config.interior_energetics.aragog.backend = 'jax' # interior_o carries no _spider_eos_dir, so the EOS lookup raises part-way - # through the rebuild: the failure a live solver has to survive. + # through the install: the failure a live solver has to survive. interior_o = SimpleNamespace(aragog_solver=solver) AragogRunner._maybe_install_jax_cvode_factory(config, interior_o) assert solver._jax_cvode_factory is not stale_factory assert solver._jax_cvode_factory is None - assert solver._jax_mesh_key is None - - with patch.object(AragogRunner, '_maybe_install_jax_cvode_factory') as install: - AragogRunner._refresh_jax_cvode_factory_if_mesh_moved(config, interior_o) - assert install.call_count == 0 From 4c4689afef4619ff53294a0af827a0299e2f6f4f Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 6 Aug 2026 15:54:25 +0200 Subject: [PATCH 53/71] Install the JAX factory last, so a diagnostic cannot undo it The factory was installed and then a purely diagnostic log line read the mesh radii, still inside the same try. Anything raising in that line reached the handler, which clears the factory, so a working install would have been torn down and the run quietly moved to the finite-difference Jacobian while reporting an install failure. Read the diagnostic values first and install last, so nothing that can fail runs after the install. Also moves a test marker that was left on a helper rather than on the two tests it belonged to, and adds the case that defeated the earlier approach: a mesh whose cell count and both bounding radii are bit-identical while its interior has moved. Mass coordinates pin the first and last node and solve the rest from the density profile, so that case is what a fingerprint on those three numbers cannot see. --- src/proteus/interior_energetics/aragog.py | 24 ++++--- tests/interior_energetics/test_aragog.py | 81 ++++++++++++++++++++++- 2 files changed, 93 insertions(+), 12 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 5fd1549fd..6b0230642 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -1404,24 +1404,26 @@ def factory(scales, core_bc_mode): ) return rhs_fn, jac_fn - solver.set_jax_cvode_factory(factory) + # Read the diagnostic geometry before installing, so that the + # install is the last thing here that can fail. Anything raising + # after it would send a working factory into the handler below. r_basic = np.asarray(solver._r_basic_flat).ravel() - log.info( + installed = ( 'Option Z: JAX CVODE factory installed on aragog solver ' - '(core_bc=%s, n_stag=%d, r_cmb=%.6e m, r_surf=%.6e m). The mesh ' - 'is read from the solver on every solve, so these are the ' - 'geometry at install time, not for the run.', - solver._core_bc, - solver._n_stag, - float(r_basic[0]), - float(r_basic[-1]), + f'(core_bc={solver._core_bc}, n_stag={int(solver._n_stag)}, ' + f'r_cmb={float(r_basic[0]):.6e} m, r_surf={float(r_basic[-1]):.6e} m). ' + 'The mesh is read from the solver on every solve, so this is the ' + 'geometry at install time, not for the run.' ) + solver.set_jax_cvode_factory(factory) + log.info(installed) except Exception as exc: msg = f'Option Z factory install failed ({exc}); falling back to FD Jacobian.' if nightly_strict: raise RuntimeError(msg) from exc - # A failed rebuild would otherwise leave the previously installed - # factory in charge while this message claims the run fell back. + # Leave nothing half-installed: the solve-time check is only that a + # factory is present, so a partial install would run this path on + # state the failure above left incomplete. solver.set_jax_cvode_factory(None) log.warning(msg) diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index e7c3c00a0..367ebcfa7 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1315,7 +1315,6 @@ def test_the_core_temperature_guard_stands_aside_for_a_giant_impact(): assert len(failed_attempts) == 6 -@pytest.mark.unit def _jax_factory_config(): """Config carrying the numeric fields the option Z factory install reads.""" config = MagicMock() @@ -1340,6 +1339,7 @@ def _jax_factory_config(): return config +@pytest.mark.unit def test_the_jax_right_hand_side_reads_the_mesh_on_every_solve(monkeypatch): """The JAX right-hand side follows the structure it is asked to integrate. @@ -1409,6 +1409,85 @@ def test_the_jax_right_hand_side_reads_the_mesh_on_every_solve(monkeypatch): assert meshes == [before_mesh, after_mesh] +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_an_interior_that_moves_under_fixed_radii_is_still_followed(monkeypatch): + """A structure change that leaves both bounding radii untouched is followed. + + With mass coordinates the mesh pins its first and last node to the core and + surface radii and solves every interior node from the density profile, so a + Zalmoxis re-solve can redistribute the whole interior, and with it pressure, + gravity, area and volume, while both bounding radii and the cell count stay + bit-identical. Comparing geometry by those three numbers reports nothing has + changed and leaves the right-hand side on the previous structure. + + Verifies: + - The second solve is handed the moved mesh even though cell count and both + bounding radii are unchanged, which is what a fingerprint on those three + would miss. + - The interior really does differ, so the case is not vacuous. + """ + pytest.importorskip('jax') + pytest.importorskip('aragog.jax.phase') + from proteus.interior_energetics.aragog import AragogRunner + + monkeypatch.delenv('PROTEUS_CI_NIGHTLY', raising=False) + + n = 8 + r_cmb, r_surf = 2.86e6, 5.84e6 + # Same endpoints and same count; only the interior node placement differs, + # as a denser mantle would produce after a re-solve. + before = SimpleNamespace(radii=np.linspace(r_cmb, r_surf, n)) + moved = np.linspace(r_cmb, r_surf, n) ** 1.02 + moved *= (r_surf - r_cmb) / (moved[-1] - moved[0]) + moved += r_cmb - moved[0] + after = SimpleNamespace(radii=moved) + + assert after.radii[0] == pytest.approx(before.radii[0], rel=1e-15) + assert after.radii[-1] == pytest.approx(before.radii[-1], rel=1e-15) + assert len(after.radii) == len(before.radii) + # The interior genuinely moved, well beyond any tolerance a check could use. + assert np.max(np.abs(after.radii[1:-1] - before.radii[1:-1])) > 1.0e3 + + solver = SimpleNamespace( + _n_stag=n, + _r_basic_flat=before.radii, + _core_bc='energy_balance', + evaluator=SimpleNamespace(mesh=before), + parameters=SimpleNamespace( + boundary_conditions=MagicMock(), + energy=SimpleNamespace(tidal_array=np.zeros(n)), + radionuclides=[], + mesh=SimpleNamespace(core_density=10800.0), + ), + ) + installed = {} + solver.set_jax_cvode_factory = lambda f: installed.update(factory=f) + interior_o = SimpleNamespace(aragog_solver=solver, _spider_eos_dir='/nonexistent') + + with ( + patch('aragog.jax.phase.MeshArrays') as mesh_arrays, + patch('aragog.jax.phase.PhaseParams'), + patch('aragog.jax.solver.BoundaryParams'), + patch( + 'aragog.solver.cvode_jax.build_jax_rhs_and_jacobian', + return_value=('rhs', 'jac', {}), + ), + patch('proteus.interior_energetics.aragog._cached_entropy_eos_jax'), + ): + AragogRunner._maybe_install_jax_cvode_factory(_jax_factory_config(), interior_o) + factory = installed['factory'] + + factory(MagicMock(), 'energy_balance') + solver.evaluator.mesh = after + factory(MagicMock(), 'energy_balance') + + seen = [c.args[0] for c in mesh_arrays.from_numpy_mesh.call_args_list] + assert seen == [before, after] + np.testing.assert_allclose(seen[1].radii, moved) + + +@pytest.mark.unit def test_a_failed_factory_install_leaves_no_factory_behind(monkeypatch): """A failed install must not leave a previous factory in charge. From 00ca73bf5f9019d1bd615e71d910259d24974dee Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 6 Aug 2026 16:58:42 +0200 Subject: [PATCH 54/71] Reload the EOS tables when they are regenerated The P-S tables are rebuilt whenever the structure solve reruns, and a giant impact raises their pressure ceiling along with the planet's mass. Aragog kept the copy it loaded at startup, so once the planet outgrew that table the deepest cells were evaluated at its edge instead of at their own pressure. On a probe run growing 0.5 to 4.5 Earth masses the core-mantle boundary reaches 1.67 times the starting table's ceiling, 60 percent of the rows sit beyond it, and density there is understated by 15 percent. Two things kept the stale copy in place. The loader's cache key was a list of file names and sizes, and a regenerated table keeps the same grid shape, so every file keeps its length and the key could not tell the new tables from the old. The key now uses the marker the generator already writes beside the tables, which records the pressure ceiling, the grid shape and the material. Second, the tables were read once when the solver was built; they are now read where the mesh is read, once per solve, so both follow the planet the step is about. This closes the reload the structure-update path documented as missing for composition changes. The remaining piece there is unchanged: the entropy carried over from the previous step is still not bounds-checked against a regenerated range, which a composition change can move and a pressure-ceiling change cannot. --- src/proteus/interior_energetics/aragog.py | 31 ++++++++-- src/proteus/interior_energetics/wrapper.py | 25 ++++---- tests/interior_energetics/test_aragog.py | 67 ++++++++++++++++++++++ 3 files changed, 103 insertions(+), 20 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 6b0230642..3341308ba 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -62,13 +62,23 @@ def _eos_content_key(eos_dir_str: str) -> str: The PROTEUS test fixture materialises the EOS tables into a fresh per-test ``outdir/data/spider_eos`` directory each time, so a path - based cache key misses across tests. The content fingerprint is a - sorted tuple of ``(filename, file size)`` pairs for every regular - file in the directory; it is stable across distinct on-disk copies - of the same tables but cheap to compute (one ``os.listdir`` + one - ``getsize`` per file). + based cache key misses across tests. + + The generator writes the parameters that define the tables into + ``.cache_info.txt``: the pressure ceiling, the grid shape, the mushy-zone + factor and the EOS identity. That marker is the key when present. Sizes + alone are not enough on the accretion path: a giant impact grows the planet + and the tables are rewritten to a higher pressure ceiling on the same grid, + so every file keeps its length and a size-based key cannot see that the + tables now describe a different planet. """ try: + marker = os.path.join(eos_dir_str, '.cache_info.txt') + if os.path.isfile(marker): + with open(marker) as f: + key = f.read().strip() + if key: + return key pairs = [] for name in sorted(os.listdir(eos_dir_str)): full = os.path.join(eos_dir_str, name) @@ -1280,7 +1290,9 @@ def _maybe_install_jax_cvode_factory(config: Config, interior_o: Interior_t) -> try: eos_dir = interior_o._spider_eos_dir _t_pre_jax_eos = time.perf_counter() - eos_jax = _cached_entropy_eos_jax(str(eos_dir)) + # Build once here so an unreadable EOS directory fails the install + # rather than the first solve. The factory reloads it per call. + _cached_entropy_eos_jax(str(eos_dir)) _t_post_jax_eos = time.perf_counter() if nightly_strict: log.info( @@ -1333,6 +1345,13 @@ def factory(scales, core_bc_mode): # from before the change. mesh_jax = MeshArrays.from_numpy_mesh(solver.evaluator.mesh) n_stag = solver._n_stag + # Same for the EOS tables. They are regenerated whenever the + # structure solve reruns, and a giant impact raises their + # pressure ceiling with the planet's mass, so a copy taken at + # install time would have the deep mantle clamped to the edge of + # a table built for a smaller planet. The loader is cached on the + # table parameters, so an unchanged table costs one small read. + eos_jax = _cached_entropy_eos_jax(str(interior_o._spider_eos_dir)) # ``scales`` is an aragog.jax.nondim.NonDimScales single # source of truth. # Rebuild BoundaryParams from live solver state every diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 088128be4..36e197e74 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -3342,17 +3342,14 @@ def temperature_function(r, P): # MgSiO3 is a planet-state-invariant material EOS, so the pre-built # tables are stable for the entire evolution. The comp_changed path # is reached in wet runs where binodal redistribution or degassing - # shifts mantle volatile fractions by > 5% (SPIDER reads the fresh - # file on next call; Aragog's in-memory EntropyEOS, built once during - # AragogRunner.setup_solver, is NOT invalidated here, so Aragog would - # silently use the stale in-memory tables). + # shifts mantle volatile fractions by > 5%. SPIDER reads the fresh file on + # its next call. Aragog's JAX right-hand side reloads the tables per solve + # through a loader cached on the table parameters, so it follows them here + # and at the ungated regeneration a giant impact triggers. # - # KNOWN GAP: for Aragog + wet runs we would need to (i) reload - # EntropyEOS from the regenerated files, (ii) re-install the JAX - # CVODE factory so its captured eos_jax pytree matches the new - # tables, (iii) bounds-check the cached _last_entropy against the - # new [S_min, S_max] range. Dry runs do not need this; it is a - # precondition for quantitative wet-run work. + # REMAINING GAP: the cached _last_entropy is not bounds-checked against the + # regenerated [S_min, S_max]. A composition change can move that range; a + # pressure-ceiling change alone does not. if comp_changed and config.interior_energetics.module in ('spider', 'aragog'): from proteus.interior_struct.zalmoxis import generate_spider_tables @@ -3363,10 +3360,10 @@ def temperature_function(r, P): dirs['spider_liquidus_ps'] = spider_tables['liquidus_path'] log.info('Regenerated SPIDER EOS tables (composition change)') if config.interior_energetics.module == 'aragog': - log.warning( - 'Aragog: regenerated P-S tables on composition change, ' - 'but Aragog in-memory EntropyEOS is not refreshed. ' - 'Known gap for wet runs. Dry runs are not affected.' + log.info( + 'Aragog reloads the regenerated tables on its next solve; ' + 'the entropy carried over from the previous step is not ' + 'bounds-checked against their new range.' ) # Update composition sentinels for next trigger check diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 367ebcfa7..d3f6e3ef7 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1487,6 +1487,73 @@ def test_an_interior_that_moves_under_fixed_radii_is_still_followed(monkeypatch) np.testing.assert_allclose(seen[1].radii, moved) +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_regenerated_eos_tables_are_seen_even_at_identical_file_sizes(tmp_path): + """Tables rewritten to a higher pressure ceiling are treated as new tables. + + A giant impact grows the planet, and the P-S tables are rewritten with a + ceiling scaled to the new mass on the same entropy and pressure grid. Every + file therefore keeps its length, so a key made of file sizes reports the + tables unchanged and the solver keeps evaluating the deepest cells against a + table built for the smaller planet, clamping at its edge. + + Verifies: + - The key changes when only the recorded ceiling changes, with byte counts + held equal, which is what the size-based key could not see. + - It still changes for a genuine size change, so the marker has not simply + replaced one blind spot with another. + - A directory with no marker still yields a usable key rather than raising. + """ + from proteus.interior_energetics.aragog import _eos_content_key + + def write(ceiling, pad=0): + d = tmp_path / f'eos_{ceiling}_{pad}' + d.mkdir() + (d / '.cache_info.txt').write_text( + f'P_max={ceiling:.6e}_nP=1350_nS=280_mzf=0.8_layout=2phase_eos=PALEOS-2phase' + ) + # Same grid shape means the same byte count, which is the whole trap. + (d / 'density_melt.dat').write_bytes(b'x' * (4096 + pad)) + return d + + before = write(2.750e11) # 0.5 M_earth embryo + after = write(8.750e11) # the same planet after growing to 4.5 M_earth + + sizes = {p.name: p.stat().st_size for p in before.iterdir() if p.name != '.cache_info.txt'} + after_sizes = { + p.name: p.stat().st_size for p in after.iterdir() if p.name != '.cache_info.txt' + } + assert sizes == after_sizes, 'the table files must match in size for this to bite' + + k_before = _eos_content_key(str(before)) + k_after = _eos_content_key(str(after)) + assert k_before != k_after + # The ceiling is what moved, so it must be what the key carries. + assert '2.750000e+11' in k_before + assert '8.750000e+11' in k_after + + # The marker fully describes the tables, so identical markers are the same + # tables however the bytes fall. This is deliberate, not a second blind spot. + grown = write(2.750e11, pad=512) + assert _eos_content_key(str(grown)) == k_before + + # Without a marker the fallback is the file listing, and it still separates + # two directories that differ only in size. + bare = tmp_path / 'bare' + bare.mkdir() + (bare / 'density_melt.dat').write_bytes(b'y' * 2048) + bigger = tmp_path / 'bigger' + bigger.mkdir() + (bigger / 'density_melt.dat').write_bytes(b'y' * 4096) + bare_key = _eos_content_key(str(bare)) + assert 'density_melt.dat' in bare_key + assert bare_key != _eos_content_key(str(bigger)) + + # A missing directory yields the path itself rather than raising. + assert _eos_content_key(str(tmp_path / 'missing')) == str(tmp_path / 'missing') + + @pytest.mark.unit def test_a_failed_factory_install_leaves_no_factory_behind(monkeypatch): """A failed install must not leave a previous factory in charge. From 8fb954070726ecb89932a967b8f0a96008e061e2 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 6 Aug 2026 21:11:29 +0200 Subject: [PATCH 55/71] Point the solver at the current tables before each solve The interior solver kept whichever P-S tables it was built with at startup. Those tables are rewritten whenever the structure solve reruns, with a pressure ceiling that grows with the planet, so a run that outgrows its starting table went on integrating the old one. The right-hand side already follows the regenerated tables; this is the same reload for the state-heat integral, which is the other half of the energy budget and the quantity the conservation residual is measured against. That makes it a correctness fix for the diagnostic rather than for the trajectory: on a planet that stays inside its starting table nothing changes, and on one that outgrows it the reported budget was being taken against tables that no longer describe the planet. The reload sits immediately before the solve, so the integral a step books is taken against the tables that step runs on, and the loader is cached on the table parameters so an unchanged table costs one small read. --- src/proteus/interior_energetics/aragog.py | 31 ++++++++++++ tests/interior_energetics/test_aragog.py | 58 +++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 3341308ba..43bab8f06 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -1233,6 +1233,33 @@ def _append_radnuc(_iso, _cnc): _t_post_solver - _t_post_eos, ) + @staticmethod + def _refresh_entropy_eos(config: Config, interior_o: Interior_t) -> None: + """Point the solver at the P-S tables as they stand now. + + The solver keeps whatever table object it was built with, and the tables + are rewritten whenever the structure solve reruns, with a pressure + ceiling that grows with the planet. That object is what + ``_step_heat_content`` integrates, which is the state side of the energy + budget, so a stale one misreports that budget on exactly the runs that + outgrow their starting table. The loader is cached on the table + parameters, so an unchanged table costs one small read. + + Parameters + ---------- + config : Config + PROTEUS configuration; a const-properties run carries no tables. + interior_o : Interior_t + Interior state holding the live Aragog solver. + """ + if config.interior_energetics.const_properties: + return + solver = getattr(interior_o, 'aragog_solver', None) + eos_dir = getattr(interior_o, '_spider_eos_dir', '') + if solver is None or not eos_dir or not os.path.isdir(str(eos_dir)): + return + solver.entropy_eos = _cached_entropy_eos(str(eos_dir)) + @staticmethod def _maybe_install_jax_cvode_factory(config: Config, interior_o: Interior_t) -> None: """Install a JAX CVODE callback factory on the solver (option Z). @@ -2104,6 +2131,10 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: # the run. Skip it on that one step; every other step keeps it. impact_step = bool(getattr(interior_o, 'impact_reset_this_step', False)) + # Immediately before the solve, so the state-heat integral this step + # books is taken against the tables the step actually runs on. + AragogRunner._refresh_entropy_eos(self._config, interior_o) + # Capture IC for restoration on retry, and pre-call T_core for # the sanity check on retry success. t_start = float(solver.parameters.solver.start_time) diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index d3f6e3ef7..93dc3d63a 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1487,6 +1487,64 @@ def test_an_interior_that_moves_under_fixed_radii_is_still_followed(monkeypatch) np.testing.assert_allclose(seen[1].radii, moved) +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_the_solver_is_pointed_at_the_current_tables_before_each_solve(tmp_path): + """The energy diagnostic integrates the tables the step actually runs on. + + The solver keeps the table object it was built with, and `_step_heat_content` + integrates that object to produce the state side of the energy budget. The + tables are rewritten with a higher pressure ceiling whenever the planet + grows, so a solver left on the startup tables misreports the budget on + exactly the runs that outgrow them. + + Verifies: + - The solver is repointed when the tables have been rewritten. + - A const-properties run, which has no tables at all, is left alone rather + than being handed one. + - A missing table directory is a no-op, not a crash mid-run. + """ + from proteus.interior_energetics.aragog import AragogRunner + + d = tmp_path / 'spider_eos' + d.mkdir() + (d / '.cache_info.txt').write_text('P_max=2.750000e+11_nP=1350_nS=280') + (d / 'density_melt.dat').write_bytes(b'x' * 512) + + startup = object() + solver = SimpleNamespace(entropy_eos=startup) + interior_o = SimpleNamespace(aragog_solver=solver, _spider_eos_dir=str(d)) + config = MagicMock() + config.interior_energetics.const_properties = False + + loaded = object() + with patch( + 'proteus.interior_energetics.aragog._cached_entropy_eos', return_value=loaded + ) as loader: + AragogRunner._refresh_entropy_eos(config, interior_o) + assert loader.call_count == 1 + assert loader.call_args.args[0] == str(d) + assert solver.entropy_eos is loaded + assert solver.entropy_eos is not startup + + # const_properties carries no tables, so nothing may be attached. + const_cfg = MagicMock() + const_cfg.interior_energetics.const_properties = True + solver.entropy_eos = None + with patch('proteus.interior_energetics.aragog._cached_entropy_eos') as loader: + AragogRunner._refresh_entropy_eos(const_cfg, interior_o) + assert loader.call_count == 0 + assert solver.entropy_eos is None + + # A directory that is not there is a no-op: the run keeps whatever it had. + solver.entropy_eos = startup + gone = SimpleNamespace(aragog_solver=solver, _spider_eos_dir=str(tmp_path / 'absent')) + with patch('proteus.interior_energetics.aragog._cached_entropy_eos') as loader: + AragogRunner._refresh_entropy_eos(config, gone) + assert loader.call_count == 0 + assert solver.entropy_eos is startup + + @pytest.mark.unit @pytest.mark.physics_invariant def test_regenerated_eos_tables_are_seen_even_at_identical_file_sizes(tmp_path): From 38ec624d9bfd18bac7feee599e2b87c3bf2f14a6 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sat, 8 Aug 2026 11:20:27 +0200 Subject: [PATCH 56/71] Refresh the tables before reset and test the reload wiring The compression-work diagnostic inside solver.reset() evaluates the re-read mesh pressures against whatever table object the solver holds, so on the step after an impact regenerates the tables it was evaluated against the outgrown set, which clamps at the old ceiling. The refresh now runs before reset(), and the later refresh inside the retry ladder stays as a cheap cache hit. Two pieces of wiring had no test: the ladder's refresh call (every ladder test ran with no solver wired, so removing the call changed nothing) and the translation of the one-shot impact flag into the per-step flag inside run_interior. Both are now pinned, the first with an ordering assertion that the refresh lands before the first solve, the second across two steps so one impact cannot exempt two. The docstring on the refresh helper now names the consumers that actually read the live table object (the RHS, the per-call energy integrals, and the compression diagnostic) rather than the impact heat booking, which deliberately runs on the pre-impact tables. A wrapper comment claimed a raised pressure ceiling cannot move the entropy range of the regenerated tables; the range is scanned up to the ceiling, so it can, and the comment now says so. Four structural tests carried the physics-invariant marker without asserting an invariant and lose it, and the test file docstring now covers the contract areas the file grew. --- src/proteus/interior_energetics/aragog.py | 34 ++++++------ src/proteus/interior_energetics/wrapper.py | 5 +- tests/interior_energetics/test_aragog.py | 60 ++++++++++++++++++++-- tests/interior_energetics/test_wrapper.py | 46 +++++++++++++++++ 4 files changed, 121 insertions(+), 24 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 43bab8f06..496895202 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -596,6 +596,10 @@ def setup_or_update_solver( else: AragogRunner.update_structure(config, hf_row, interior_o) AragogRunner.update_solver(dt, hf_row, interior_o) + # Refresh before reset(): the compression-work diagnostic inside + # reset() evaluates the new mesh pressures against the installed + # table, which clamps at a stale ceiling on impact steps. + AragogRunner._refresh_entropy_eos(config, interior_o) interior_o.aragog_solver.reset() # Restore entropy IC from previous solve if hasattr(interior_o, '_last_entropy') and interior_o._last_entropy is not None: @@ -1239,11 +1243,12 @@ def _refresh_entropy_eos(config: Config, interior_o: Interior_t) -> None: The solver keeps whatever table object it was built with, and the tables are rewritten whenever the structure solve reruns, with a pressure - ceiling that grows with the planet. That object is what - ``_step_heat_content`` integrates, which is the state side of the energy - budget, so a stale one misreports that budget on exactly the runs that - outgrow their starting table. The loader is cached on the table - parameters, so an unchanged table costs one small read. + ceiling that grows with the planet. ``solver.entropy_eos`` is read live + throughout the solve (the RHS, the per-call energy integrals, and the + compression-work diagnostic inside ``reset()``), so a stale object + misreports the energy budget on exactly the runs that outgrow their + starting table. The loader is cached on the table parameters, so an + unchanged table costs one small read. Parameters ---------- @@ -1364,20 +1369,15 @@ def factory(scales, core_bc_mode): # consumed the analytic Jacobian rather than silently falling # back to the FD path. solver._jax_factory_call_count += 1 - # Rebuild the mesh from live solver state every solve() call, - # for the same reason the boundary conditions below are. A giant - # impact grows the planet and Zalmoxis re-solves the structure as - # the mantle freezes; either one replaces the mesh, and a copy - # taken once at install time would keep integrating the planet - # from before the change. + # Rebuild the mesh from live solver state every solve() call: + # impacts and structure re-solves replace it, and a copy taken + # at install time would keep integrating the pre-change planet. mesh_jax = MeshArrays.from_numpy_mesh(solver.evaluator.mesh) n_stag = solver._n_stag - # Same for the EOS tables. They are regenerated whenever the - # structure solve reruns, and a giant impact raises their - # pressure ceiling with the planet's mass, so a copy taken at - # install time would have the deep mantle clamped to the edge of - # a table built for a smaller planet. The loader is cached on the - # table parameters, so an unchanged table costs one small read. + # Same for the EOS tables: regeneration raises their pressure + # ceiling with the planet's mass, and an install-time copy would + # clamp the deep mantle at the smaller planet's table edge. The + # loader is cached, so an unchanged table costs one small read. eos_jax = _cached_entropy_eos_jax(str(interior_o._spider_eos_dir)) # ``scales`` is an aragog.jax.nondim.NonDimScales single # source of truth. diff --git a/src/proteus/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index 36e197e74..41d80431a 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -3348,8 +3348,9 @@ def temperature_function(r, P): # and at the ungated regeneration a giant impact triggers. # # REMAINING GAP: the cached _last_entropy is not bounds-checked against the - # regenerated [S_min, S_max]. A composition change can move that range; a - # pressure-ceiling change alone does not. + # regenerated [S_min, S_max]. Both a composition change and a raised + # pressure ceiling can move that range (it is scanned up to the ceiling), + # so out-of-range carried entropy clamps at the table edge in the solve. if comp_changed and config.interior_energetics.module in ('spider', 'aragog'): from proteus.interior_struct.zalmoxis import generate_spider_tables diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 93dc3d63a..545df2d18 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -3,7 +3,10 @@ Tests the Zalmoxis-specific branches in AragogRunner.setup_solver() that set inner_radius from zalmoxis_solver and configure temperature-dependent initial -conditions. +conditions, plus the contracts that keep the solver on the planet as it grows: +the retry ladder and its giant-impact exemption, per-solve mesh re-reads, the +EOS-table reload and its content-keyed cache, and the JAX factory install and +failure paths. Testing standards and documentation: - docs/How-to/testing.md: Running, writing, and marking tests; coverage and CI @@ -11,6 +14,9 @@ Functions tested: - AragogRunner.setup_solver(): Zalmoxis branches for inner_radius, EOS fallback +- AragogRunner._solve_with_retry(): ladder policy, guards, table refresh wiring +- AragogRunner._refresh_entropy_eos() and _eos_content_key(): reload discipline +- AragogRunner._maybe_install_jax_cvode_factory(): install-last and clear-on-fail """ from __future__ import annotations @@ -979,6 +985,54 @@ def test_a_step_stopped_by_the_terminal_event_is_accepted_as_it_stands(): assert out_full.dt_actual == pytest.approx(100.0, rel=1e-12) +@pytest.mark.unit +def test_the_ladder_refreshes_the_tables_before_the_first_solve(monkeypatch, tmp_path): + """The retry ladder points the solver at the current tables before solving. + + Verifies: + - The ladder swaps ``solver.entropy_eos`` to the freshly loaded table + object before the first ``solve()`` call, so the step integrates on the + tables as regenerated, not on the object the solver was built with. + - The loader is handed the interior's table directory, not a cached path. + - The const-properties guard holds: a run with no tables refreshes + nothing, so the exemption cannot silently load a table set. + """ + runner, interior_o, attempts = _retry_ladder_runner(status=0, dt_actual=100.0) + runner._config.interior_energetics.const_properties = False + solver = interior_o.aragog_solver = runner.aragog_solver + interior_o._spider_eos_dir = str(tmp_path) + + order: list[str] = [] + sentinel = object() + orig_solve = solver.solve + solver.solve = lambda: (order.append('solve'), orig_solve())[1] + + def fake_loader(path): + order.append('refresh') + assert path == str(tmp_path) + return sentinel + + monkeypatch.setattr('proteus.interior_energetics.aragog._cached_entropy_eos', fake_loader) + runner._solve_with_retry({'Time': 2.15e5, 'T_cmb': 4000.0}, interior_o) + + assert solver.entropy_eos is sentinel + assert order.index('refresh') < order.index('solve'), ( + 'the tables were refreshed after the solve had already run on the stale object' + ) + assert order.count('refresh') == 1 + + # Guard: a const-properties run carries no tables, so nothing is loaded + # and no entropy_eos is installed on the solver. + guarded, guarded_interior, _ = _retry_ladder_runner(status=0, dt_actual=100.0) + guarded._config.interior_energetics.const_properties = True + guarded_interior.aragog_solver = guarded.aragog_solver + guarded_interior._spider_eos_dir = str(tmp_path) + order.clear() + guarded._solve_with_retry({'Time': 2.15e5, 'T_cmb': 4000.0}, guarded_interior) + assert order == [] + assert not hasattr(guarded.aragog_solver, 'entropy_eos') + + @pytest.mark.unit def test_a_step_that_never_advanced_is_still_refused(): """The ladder still refuses results that carry no usable state. @@ -1255,7 +1309,6 @@ def test_progress_is_weighed_against_what_the_coupling_asked_for(): @pytest.mark.unit -@pytest.mark.physics_invariant def test_the_core_temperature_guard_stands_aside_for_a_giant_impact(): """A giant impact's core-temperature jump is kept, not retried away. @@ -1410,7 +1463,6 @@ def test_the_jax_right_hand_side_reads_the_mesh_on_every_solve(monkeypatch): @pytest.mark.unit -@pytest.mark.physics_invariant def test_an_interior_that_moves_under_fixed_radii_is_still_followed(monkeypatch): """A structure change that leaves both bounding radii untouched is followed. @@ -1488,7 +1540,6 @@ def test_an_interior_that_moves_under_fixed_radii_is_still_followed(monkeypatch) @pytest.mark.unit -@pytest.mark.physics_invariant def test_the_solver_is_pointed_at_the_current_tables_before_each_solve(tmp_path): """The energy diagnostic integrates the tables the step actually runs on. @@ -1546,7 +1597,6 @@ def test_the_solver_is_pointed_at_the_current_tables_before_each_solve(tmp_path) @pytest.mark.unit -@pytest.mark.physics_invariant def test_regenerated_eos_tables_are_seen_even_at_identical_file_sizes(tmp_path): """Tables rewritten to a higher pressure ceiling are treated as new tables. diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index c81d57dbc..16c86005b 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -2120,6 +2120,52 @@ def _run_interior_with_dummy(config, hf_all, hf_row, *, ic: int, output: dict): return hf_row +@pytest.mark.unit +def test_run_interior_consumes_the_impact_flag_into_the_step_flag(): + """run_interior translates the one-shot impact flag into the per-step flag. + + Verifies: + - An armed ``impact_reset`` is consumed (cleared) and surfaces as + ``impact_reset_this_step`` for the rest of the step, which is what the + temperature-jump clip and the solver's core-temperature guard read. + - The very next step reads False again, so one impact cannot exempt two + steps from the guards. + """ + from proteus.interior_energetics.common import Interior_t + from proteus.interior_energetics.wrapper import run_interior + + config = _make_run_interior_config(prevent_warming=False) + hf_all, hf_row = _make_run_interior_state(prev_f_int=1.0) + out = { + 'T_magma': 3005.0, + 'T_surf': 2805.0, + 'Phi_global': 0.7, + 'F_int': 2.0, + 'M_mantle': 4.0e24, + 'M_mantle_liquid': 1.0e24, + 'M_mantle_solid': 3.0e24, + 'M_core': 2.0e24, + } + interior_o = Interior_t(nlev_b=10) + interior_o.ic = 2 + interior_o.impact_reset = True + + with ( + patch( + 'proteus.interior_energetics.dummy.run_dummy_int', + return_value=(110.0, out), + ), + patch('proteus.interior_energetics.wrapper.update_planet_mass'), + ): + run_interior({}, config, hf_all, hf_row, interior_o, MagicMock(), verbose=False) + assert interior_o.impact_reset_this_step is True + assert interior_o.impact_reset is False, 'the one-shot flag was not consumed' + + # The following step is ordinary again: nothing re-armed the flag. + run_interior({}, config, hf_all, hf_row, interior_o, MagicMock(), verbose=False) + assert interior_o.impact_reset_this_step is False + + @pytest.mark.unit def test_run_interior_prevent_warming_clamps_T_and_Fint_on_ic2(): """prevent_warming=True + ic=2 must clip Phi/T_magma/T_surf to previous values From 0be0464784c5b872d5138ef376b098eb9cee9855 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 23 Aug 2026 18:34:18 +0200 Subject: [PATCH 57/71] Bound the time step that lands on a coarse-phase giant impact The step that lands exactly on a scheduled giant impact is clamped only to whatever simulated time remains before it. After a long quiescent phase has let dt coarsen, that remaining time can itself be large, so the step absorbing the impact's melt-fraction jump inherits the same coarseness the run had just grown into. campaign-2 hit this on probe18_capsoff_guardtip: an impact landed while dt had coarsened to 1.06e6 yr, the remaining-time clamp only cut it to 2.14e5 yr, and CVODE burned two failed attempts before succeeding at 4.27e4 yr. I added dt.impact_maximum, an optional absolute ceiling on the landing step, applied before the existing minimum-step floor so a misconfigured ceiling smaller than the floor still can't collapse dt to zero near an impact. It defaults to 0 (disabled), so no existing run's behaviour changes unless I set it. Reproduced the coarse-phase landing cheaply with the dummy interior and the analytical accretion module: a proportional-dt run reaching an impact after dt had grown to ~8e5 yr lands on it at ~3.35e5 yr uncapped, and at the configured ceiling once impact_maximum is set. Added unit tests for the new ceiling to test_timestep.py, including that it never beats the minimum-step floor, and confirmed each one fails against the unfixed clamp before passing against this change. I have not reproduced the CVODE retry-ladder itself against the real Aragog solver; the tests above verify the dt-sizing mechanism a coarse-phase impact triggers, not the solver's response to it. --- src/proteus/config/_params.py | 11 +++ src/proteus/interior_energetics/timestep.py | 15 ++-- tests/interior_energetics/test_timestep.py | 94 +++++++++++++++++++++ 3 files changed, 113 insertions(+), 7 deletions(-) diff --git a/src/proteus/config/_params.py b/src/proteus/config/_params.py index 88ca4d95d..3bb94eb2c 100644 --- a/src/proteus/config/_params.py +++ b/src/proteus/config/_params.py @@ -141,6 +141,16 @@ class TimeStepParams: Replacement speed-up factor applied while the hysteresis counter is active. Must be ``>= 1.0`` and ``<= SFINC`` (1.6). Default 1.1 (gentle ramp-up). + impact_maximum: float + Maximum time-step size [yr] for the step that lands on a + scheduled giant impact. The landing step is otherwise + clamped only to however much simulated time remains before + the impact, so after a long quiescent phase has let ``dt`` + coarsen, that remaining time can itself be large and the + step absorbing the impact's melt-fraction jump inherits the + same coarseness. Set to 0 (default) to disable, in which + case the remaining-time clamp applies with no independent + ceiling. """ starspec: float = field(default=1e8, validator=ge(0)) @@ -170,6 +180,7 @@ class TimeStepParams: mushy_upper: float = field(default=0.99, validator=(gt(0), lt(1))) hysteresis_iters: int = field(default=0, validator=ge(0)) hysteresis_sfinc: float = field(default=1.1, validator=ge(1.0)) + impact_maximum: float = field(default=0.0, validator=ge(0)) # Cap on dt growth ratio between consecutive steps. Bounds # dtswitch / dtprev to at most max_growth_factor, preventing diff --git a/src/proteus/interior_energetics/timestep.py b/src/proteus/interior_energetics/timestep.py index c9df81b85..a8503a955 100644 --- a/src/proteus/interior_energetics/timestep.py +++ b/src/proteus/interior_energetics/timestep.py @@ -413,15 +413,16 @@ def next_step( ) dtswitch = dt_capped - # Land exactly on the next scheduled giant impact. An impact re-melts - # the mantle and grows the planet, so it has to be applied at the - # state the timeline says it happens at, not at whatever state a step - # that jumped over it produced. The clamp only ever shortens dt, and - # it is floored at the minimum step so a nearby impact cannot collapse - # dt to zero; the event handler uses a half-open time window, so an - # impact inside a floored step is still applied exactly once. + # Land exactly on the next scheduled giant impact, since it remelts the + # mantle and grows the planet: the state must match the timeline, not + # whatever a step that overshot it produced. The clamp only shortens + # dt and floors at the minimum step; impact_maximum further bounds the + # landing step independent of how coarse dt had grown beforehand. if interior_o is not None and np.isfinite(interior_o.t_next_impact): dt_to_impact = interior_o.t_next_impact - hf_row['Time'] + impact_ceiling = float(config.params.dt.impact_maximum) + if impact_ceiling > 0.0: + dt_to_impact = min(dt_to_impact, impact_ceiling) dtfloor = config.params.dt.minimum + config.params.dt.minimum_rel * hf_row['Time'] dt_to_impact = max(dt_to_impact, dtfloor) if dtswitch > dt_to_impact: diff --git a/tests/interior_energetics/test_timestep.py b/tests/interior_energetics/test_timestep.py index 4640a7120..6643f9289 100644 --- a/tests/interior_energetics/test_timestep.py +++ b/tests/interior_energetics/test_timestep.py @@ -39,6 +39,7 @@ def _make_config( bol_scale: float = 1.0, bol_scale_start: float | None = None, bol_scale_duration: float = 0.0, + impact_maximum: float = 0.0, ): """Build a minimal duck-typed config that ``next_step`` reads from. @@ -65,6 +66,7 @@ def _make_config( hysteresis_iters=hysteresis_iters, hysteresis_sfinc=hysteresis_sfinc, max_growth_factor=max_growth_factor, + impact_maximum=impact_maximum, ) stop_solid = SimpleNamespace(enabled=True, phi_crit=phi_crit) stop_radeqm = SimpleNamespace(enabled=False) @@ -782,6 +784,98 @@ def test_an_imminent_impact_is_floored_at_the_minimum_step(self): assert dt > 0.0 assert hf_row['Time'] + dt > t_impact + @pytest.mark.physics_invariant + def test_impact_maximum_bounds_the_landing_step_below_the_remaining_time(self): + """A positive ceiling cuts the landing step further than the time + remaining to the impact would on its own. + + This is the case the ceiling exists for: a coarse phase leaves a + lot of time remaining when the impact clamp first engages, and + without a ceiling that whole remaining time becomes the landing + step. + """ + from proteus.interior_energetics.timestep import next_step + + config = _make_config(impact_maximum=3.0e3) + hf_all = _make_hf_all(n_rows=12, dt_prev=5.0e3, phi=1.0) + time_now = 1.0e5 + hf_row = {'Time': time_now, 'F_atm': 1.0e4, 'Phi_global': 1.0} + t_impact = time_now + 6.0e3 + + dt = next_step( + config, + {}, + hf_row, + hf_all, + 1.0, + interior_o=_make_interior_o(t_next_impact=t_impact), + ) + + assert dt == pytest.approx(3.0e3, rel=1e-9), f'Expected the 3e3 ceiling, got {dt}' + # Discrimination: the remaining-time clamp alone would land the step + # at 6e3 (the impact is nearer than the controller's 8e3 choice), so + # only an active ceiling can produce 3e3 here. + assert hf_row['Time'] + dt < t_impact + + def test_impact_maximum_does_not_shorten_a_step_already_below_it(self): + """The ceiling never lengthens the step and stays inert once the + remaining-time clamp has already produced something smaller.""" + from proteus.interior_energetics.timestep import next_step + + time_now = 1.0e5 + hf_row = {'Time': time_now, 'F_atm': 1.0e4, 'Phi_global': 1.0} + t_impact = time_now + 2.0e3 + hf_all = _make_hf_all(n_rows=12, dt_prev=5.0e3, phi=1.0) + + dt_with_ceiling = next_step( + _make_config(impact_maximum=5.0e3), + {}, + dict(hf_row), + hf_all, + 1.0, + interior_o=_make_interior_o(t_next_impact=t_impact), + ) + dt_without_ceiling = next_step( + _make_config(impact_maximum=0.0), + {}, + dict(hf_row), + hf_all, + 1.0, + interior_o=_make_interior_o(t_next_impact=t_impact), + ) + + assert dt_with_ceiling == pytest.approx(dt_without_ceiling, rel=1e-12) + assert dt_with_ceiling == pytest.approx(2.0e3, rel=1e-9) + + @pytest.mark.physics_invariant + def test_impact_maximum_never_beats_the_minimum_floor(self): + """A ceiling set below the minimum-step floor must not win. + + The floor exists so an imminent impact cannot collapse dt to zero; + a misconfigured ceiling smaller than the floor must not reopen + that hazard. + """ + from proteus.interior_energetics.timestep import next_step + + config = _make_config(impact_maximum=50.0) + hf_all = _make_hf_all(n_rows=12, dt_prev=5.0e3, phi=1.0) + time_now = 1.0e5 + hf_row = {'Time': time_now, 'F_atm': 1.0e4, 'Phi_global': 1.0} + t_impact = time_now + 10.0 + + dt = next_step( + config, + {}, + hf_row, + hf_all, + 1.0, + interior_o=_make_interior_o(t_next_impact=t_impact), + ) + + # The 600 yr floor wins over the 50 yr ceiling. + assert dt == pytest.approx(600.0, rel=1e-6), f'Expected the 600 yr floor, got {dt}' + assert dt > 0.0 + class TestImpactAndBolscaleClampsTogether: """Verify the step when a giant impact and a stellar-scaling edge compete. From 32c8f7f0922a842150b4716affe9f8ce1f80fc54 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 30 Aug 2026 20:49:08 +0200 Subject: [PATCH 58/71] Backfill step_dE_impact_J on resume instead of blocking it step_dE_impact_J resets to zero every step and only differs on an impact-event step, so a helpfile written before this column existed never had a reason to hold anything else. Add it to RESUMABLE_ZERO_FILL_KEYS alongside the run-accumulated columns, and reword the comment to cover both cases zero can legitimately mean. --- src/proteus/utils/coupler.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index 88d8457e6..b50da7958 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -760,15 +760,18 @@ def CreateLockFile(output_dir: str): # Schema columns a resumed run may read as zero when its helpfile predates them. -# Each accumulates over a run, so zero is a true statement about a file that -# never recorded it: nothing was written, therefore nothing accrued. Every other -# column holds instantaneous state, where zero is a specific wrong value rather -# than a missing one, so a helpfile short of those cannot be resumed at all. -# Add a column here only when zero is the correct reading of its absence. +# Zero is a true statement about a file that never recorded one of these: either +# the column accumulates over the run, so nothing written means nothing accrued, +# or it resets to zero every step and only differs on an event step, so a file +# from before the event existed never had a reason to hold anything else. Every +# other column holds instantaneous state, where zero is a specific wrong value +# rather than a missing one, so a helpfile short of those cannot be resumed at +# all. Add a column here only when zero is the correct reading of its absence. RESUMABLE_ZERO_FILL_KEYS = frozenset( { 'esc_kg_cumulative', 'M_accreted_rock', + 'step_dE_impact_J', } ) From bdf20561fbbd2fc3938103413e770ca6e5f93431 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 30 Aug 2026 21:37:49 +0200 Subject: [PATCH 59/71] Cover step_dE_impact_J in the schema-drift resume test The resume test for a helpfile predating a schema column only exercised the two cumulative-ledger columns in RESUMABLE_ZERO_FILL_KEYS. Extending its absent-columns set to include step_dE_impact_J, the reset-to-zero-per-step member of that set, catches a regression where the key drops out of the frozenset: the resumed row would then be rejected as missing physical state instead of zero-filled. --- tests/utils/test_coupler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils/test_coupler.py b/tests/utils/test_coupler.py index 716c581ee..61e5a0790 100644 --- a/tests/utils/test_coupler.py +++ b/tests/utils/test_coupler.py @@ -3846,7 +3846,7 @@ def test_a_helpfile_predating_a_schema_column_still_resumes(tmp_path): ZeroHelpfileRow, ) - absent = ('M_accreted_rock', 'esc_kg_cumulative') + absent = ('M_accreted_rock', 'esc_kg_cumulative', 'step_dE_impact_J') row = ZeroHelpfileRow() for key in absent: assert key in row, f'{key} must be in the current schema for this test to mean anything' From f90af305fc5b8decffe2ed91110f33fc3ec84dcb Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 30 Aug 2026 21:37:57 +0200 Subject: [PATCH 60/71] Document both zero-fill mechanisms for RESUMABLE_ZERO_FILL_KEYS The header comment and the ReadHelpfileFromCSV docstring described only the accumulating-ledger case, which does not cover step_dE_impact_J: that column resets to zero every step and only differs on a giant-impact step, so a file predating the event correctly reads as zero for a different reason. Both are reworded to state either mechanism, and the header comment is trimmed to fit the five-line comment limit. --- src/proteus/utils/coupler.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index b50da7958..a86367685 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -760,13 +760,10 @@ def CreateLockFile(output_dir: str): # Schema columns a resumed run may read as zero when its helpfile predates them. -# Zero is a true statement about a file that never recorded one of these: either -# the column accumulates over the run, so nothing written means nothing accrued, -# or it resets to zero every step and only differs on an event step, so a file -# from before the event existed never had a reason to hold anything else. Every -# other column holds instantaneous state, where zero is a specific wrong value -# rather than a missing one, so a helpfile short of those cannot be resumed at -# all. Add a column here only when zero is the correct reading of its absence. +# Zero is true either because a column accumulates over a run, so an absent +# value means nothing accrued, or because it resets to zero every step and +# only differs on an event step, so a file from before that event existed had +# no reason to hold anything else. Add a key here only when zero fits one of these. RESUMABLE_ZERO_FILL_KEYS = frozenset( { 'esc_kg_cumulative', @@ -1474,10 +1471,12 @@ def ReadHelpfileFromCSV(output_dir: str, *, required_columns: list[str] | None = the file, such as the plotting and inference code, do not come through this function and are not covered. - A missing column is treated by its kind. The columns in - ``RESUMABLE_ZERO_FILL_KEYS`` accumulate over a run, so a file that never - recorded one accrued nothing and zero is a true value: these are filled with - zero and the run resumes. Every other column carries instantaneous physical + A missing column is treated by its kind. A column in + ``RESUMABLE_ZERO_FILL_KEYS`` either accumulates over a run, so a file that + never recorded one accrued nothing, or resets to zero every step and only + differs on an event step, so a file predating that event had no reason to + hold anything else; either way zero is a true value and the run resumes + with it filled in. Every other column carries instantaneous physical state, where zero is not "unknown" but a specific and wrong value that a seeded read would pass to a solver as real, poisoning the resumed run and turning off the module guards that test whether a key is present at all. A From f7d0fe3d32836368e4fcfd91499880dbccbe2ada Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 30 Aug 2026 22:00:08 +0200 Subject: [PATCH 61/71] Cover both zero-fill mechanisms in the resume test docstring The docstring called the backfill correct only for cumulative ledgers, which is now false for step_dE_impact_J: it resets every step and only differs on an event step, so it never accrues anything to begin with. State both mechanisms so the docstring matches what the test actually covers. --- tests/utils/test_coupler.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/utils/test_coupler.py b/tests/utils/test_coupler.py index 61e5a0790..774852a56 100644 --- a/tests/utils/test_coupler.py +++ b/tests/utils/test_coupler.py @@ -3836,8 +3836,9 @@ def test_a_helpfile_predating_a_schema_column_still_resumes(tmp_path): ExtendHelpfile, which rejects a row missing any schema key, so without a backfill every in-flight run in the fleet dies on its next restart, whether or not it uses the feature the column belongs to. The backfill is zero, - which is correct for the cumulative ledgers this affects: nothing was - recorded, so nothing accrued. + which is correct either way: a column that accumulates over a run had + nothing recorded to accrue, and a column that resets every step and only + differs on an event step had not yet reached one. """ from proteus.utils.coupler import ( ExtendHelpfile, From b7720330ea64a2888f75f6b34146a8a82805aae8 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 30 Aug 2026 22:00:16 +0200 Subject: [PATCH 62/71] Finish the two-mechanism zero-fill wording in coupler.py The Returns section and the runtime warning still called every RESUMABLE_ZERO_FILL_KEYS column a cumulative ledger, which the docstring's own kind paragraph already stopped claiming. step_dE_impact_J resets every step and only differs on an event step, so it never accrues anything a warning could call lost. Reword both to match, and restore the header comment's exclusion clause for state columns while spelling out that a reset-per-step key only belongs here if its column arrived with the event it tracks. --- src/proteus/utils/coupler.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index a86367685..8dd6f6ca0 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -759,11 +759,11 @@ def CreateLockFile(output_dir: str): return keepalive_file -# Schema columns a resumed run may read as zero when its helpfile predates them. -# Zero is true either because a column accumulates over a run, so an absent -# value means nothing accrued, or because it resets to zero every step and -# only differs on an event step, so a file from before that event existed had -# no reason to hold anything else. Add a key here only when zero fits one of these. +# Schema columns a resumed run may read as zero when its helpfile predates them. Zero +# is true either because a column accumulates over a run, so an absent value means +# nothing accrued, or because it resets every step and only differs on an event step +# introduced with its own column, so an older file had no reason to hold anything +# else. Every other column holds state where zero would be wrong, not missing. RESUMABLE_ZERO_FILL_KEYS = frozenset( { 'esc_kg_cumulative', @@ -1497,7 +1497,7 @@ def ReadHelpfileFromCSV(output_dir: str, *, required_columns: list[str] | None = ------- pandas.DataFrame Helpfile contents, carrying at least ``required_columns``; any absent - cumulative-ledger column is present and zero. + ``RESUMABLE_ZERO_FILL_KEYS`` column is present and zero. Raises ------ @@ -1529,9 +1529,9 @@ def ReadHelpfileFromCSV(output_dir: str, *, required_columns: list[str] | None = ) log.warning( - 'Helpfile predates %d cumulative column(s) in the current schema, and they ' - 'are read as zero for the rest of this run: %s. Any amount they recorded ' - 'before this restart is not in the file and is therefore lost.', + 'Helpfile predates %d column(s) in the current schema, and they are read ' + 'as zero for the rest of this run: %s. Zero is a correct value for each ' + 'of these, not a gap in what the file recorded.', len(fillable), ', '.join(sorted(fillable)), ) From e9b8df812b1fdbc3675831fe72a9226cc61aed99 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 30 Aug 2026 22:05:34 +0200 Subject: [PATCH 63/71] Fix stale cumulative-only framing in state-vs-zero-fill test docstring The docstring said only cumulative columns are zero-fillable, but step_dE_impact_J resets every step and is not a ledger. Reword the opening line and the zero-is-correct rationale to cover both resumable mechanisms, matching the wording already used for the sibling resume test above it. --- tests/utils/test_coupler.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/utils/test_coupler.py b/tests/utils/test_coupler.py index 774852a56..4ef3782b5 100644 --- a/tests/utils/test_coupler.py +++ b/tests/utils/test_coupler.py @@ -3881,11 +3881,12 @@ def test_a_helpfile_predating_a_schema_column_still_resumes(tmp_path): @pytest.mark.unit def test_a_helpfile_missing_physical_state_is_refused_not_zero_filled(): - """Only cumulative columns may be read as zero; state columns must fail. + """Only declared zero-fill columns may be read as zero; state columns must fail. - Zero is a true statement about a ledger a file never recorded: nothing was - written, so nothing accrued. It is a specific and wrong statement about - instantaneous state. A zero-filled surface temperature or planet mass would + Zero is correct either way for those columns: one that accumulates over a run + had nothing recorded to accrue, and one that resets every step and only differs + on an event step had not yet reached one. It is a specific and wrong statement + about instantaneous state. A zero-filled surface temperature or planet mass would be read as real by everything downstream and would quietly poison a resumed run, which is worse than the loud failure this function gave before the backfill existed. So the backfill is scoped to a declared set, and anything From dcabf8c0665ebf79bc2ce928f5ac6efe7a21690f Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 30 Aug 2026 22:45:11 +0200 Subject: [PATCH 64/71] Distinguish lossless and lossy zero-fill in the resume docstring step_dE_impact_J resets every step, so zero-filling a file that predates it loses nothing. esc_kg_cumulative and M_accreted_rock accumulate over a run, so zero-filling a file that predates either column discards real prior escape or accretion mass that cannot be recovered. Say so explicitly in ReadHelpfileFromCSV's docstring, the resume warning, and the header comment above the frozenset, rather than describing both mechanisms as equally lossless. State the rule for adding a column to the set: it must fit one of these two reasons, not just resemble the ones already there. --- src/proteus/utils/coupler.py | 37 +++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index 8dd6f6ca0..1434bf3ab 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -760,10 +760,9 @@ def CreateLockFile(output_dir: str): # Schema columns a resumed run may read as zero when its helpfile predates them. Zero -# is true either because a column accumulates over a run, so an absent value means -# nothing accrued, or because it resets every step and only differs on an event step -# introduced with its own column, so an older file had no reason to hold anything -# else. Every other column holds state where zero would be wrong, not missing. +# is not equally safe for every column here; see ReadHelpfileFromCSV for the two +# reasons that make it safe, and for the rule on adding a column to this set. Every +# other column holds physical state, where zero would be wrong, not missing. RESUMABLE_ZERO_FILL_KEYS = frozenset( { 'esc_kg_cumulative', @@ -1472,15 +1471,22 @@ def ReadHelpfileFromCSV(output_dir: str, *, required_columns: list[str] | None = function and are not covered. A missing column is treated by its kind. A column in - ``RESUMABLE_ZERO_FILL_KEYS`` either accumulates over a run, so a file that - never recorded one accrued nothing, or resets to zero every step and only - differs on an event step, so a file predating that event had no reason to - hold anything else; either way zero is a true value and the run resumes - with it filled in. Every other column carries instantaneous physical - state, where zero is not "unknown" but a specific and wrong value that a - seeded read would pass to a solver as real, poisoning the resumed run and - turning off the module guards that test whether a key is present at all. A - file missing one of those is refused. + ``RESUMABLE_ZERO_FILL_KEYS`` is safe to zero-fill for one of two reasons. + ``step_dE_impact_J`` resets to zero at the start of every step and only + differs on a step where a giant impact lands, so a file written before + the column existed had no reason to hold anything else; zero-filling it + loses nothing. ``esc_kg_cumulative`` and ``M_accreted_rock`` accumulate + over a run, so a file predating either column may be missing + real prior escape or accretion mass that this read cannot recover; + zero-filling it anyway is still the better choice, since refusing would + turn a routine schema addition into a run-killing failure on every + in-flight run, and the accretion module reports the loss when it detects + a zero-filled ledger at resume. Add a column to this set only when it + fits one of these two reasons. Every other column carries instantaneous + physical state, where zero is not "unknown" but a specific and wrong + value that a seeded read would pass to a solver as real, poisoning the + resumed run and turning off the module guards that test whether a key is + present at all. A file missing one of those is refused. Parameters ---------- @@ -1530,8 +1536,9 @@ def ReadHelpfileFromCSV(output_dir: str, *, required_columns: list[str] | None = log.warning( 'Helpfile predates %d column(s) in the current schema, and they are read ' - 'as zero for the rest of this run: %s. Zero is a correct value for each ' - 'of these, not a gap in what the file recorded.', + 'as zero for the rest of this run: %s. Zero is exact for a column that ' + 'resets every step; for one that accumulates, any history from before ' + 'this column existed is not recoverable.', len(fillable), ', '.join(sorted(fillable)), ) From 4507f509527f6708d60ff1eec8a90951ed2f20ff Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 30 Aug 2026 22:45:16 +0200 Subject: [PATCH 65/71] Point resume test docstrings at the zero-fill explanation Both docstrings restated the reset-vs-accumulate distinction inline, so a later correction to that distinction landed in coupler.py and one test but missed the other, including a dangling "It" left over from an earlier edit. Point both at ReadHelpfileFromCSV instead of restating it, and fix the dangling reference. --- tests/utils/test_coupler.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/tests/utils/test_coupler.py b/tests/utils/test_coupler.py index 4ef3782b5..aeec6a82d 100644 --- a/tests/utils/test_coupler.py +++ b/tests/utils/test_coupler.py @@ -3835,10 +3835,10 @@ def test_a_helpfile_predating_a_schema_column_still_resumes(tmp_path): Adding a column and resuming feeds that file's last row straight back into ExtendHelpfile, which rejects a row missing any schema key, so without a backfill every in-flight run in the fleet dies on its next restart, whether - or not it uses the feature the column belongs to. The backfill is zero, - which is correct either way: a column that accumulates over a run had - nothing recorded to accrue, and a column that resets every step and only - differs on an event step had not yet reached one. + or not it uses the feature the column belongs to. The backfill is zero: + exact for a column that resets every step, and the best available value, + though not lossless, for one that accumulates over the whole run. See + ReadHelpfileFromCSV for which of the three columns below is which. """ from proteus.utils.coupler import ( ExtendHelpfile, @@ -3883,14 +3883,13 @@ def test_a_helpfile_predating_a_schema_column_still_resumes(tmp_path): def test_a_helpfile_missing_physical_state_is_refused_not_zero_filled(): """Only declared zero-fill columns may be read as zero; state columns must fail. - Zero is correct either way for those columns: one that accumulates over a run - had nothing recorded to accrue, and one that resets every step and only differs - on an event step had not yet reached one. It is a specific and wrong statement - about instantaneous state. A zero-filled surface temperature or planet mass would - be read as real by everything downstream and would quietly poison a resumed - run, which is worse than the loud failure this function gave before the - backfill existed. So the backfill is scoped to a declared set, and anything - outside it still stops the run. + The declared columns are safe to zero-fill for the two reasons explained in + ReadHelpfileFromCSV. Every other column holds instantaneous physical state, + where zero is a specific and wrong value, not an unknown one. A zero-filled + surface temperature or planet mass would be read as real by everything + downstream and would quietly poison a resumed run, which is worse than the + loud failure this function gave before the backfill existed. So the backfill + is scoped to a declared set, and anything outside it still stops the run. """ from proteus.utils.coupler import ( RESUMABLE_ZERO_FILL_KEYS, From 7988271a4190925faa18b95aeb52718b7326c07f Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Sun, 30 Aug 2026 23:35:39 +0200 Subject: [PATCH 66/71] Drop the accretion loss-report claim from the resume docstring restore_accretion_state logs on any M_accreted_rock <= 0.0, whether that is a genuine no-impact run or a backfilled zero, and it does not fire once accretion is disabled after impacts finish. It does not detect a zero-filled ledger. The clause also implied esc_kg_cumulative gets the same safety net; escape/wrapper.py has no resume loss-report at all, only an unrelated desiccation-baseline reset. The docstring keeps its real, sufficient reason for zero-filling both columns: refusing would turn a routine schema addition into a run-killing failure on every in-flight run. --- src/proteus/utils/coupler.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index 1434bf3ab..77d366730 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -1480,8 +1480,7 @@ def ReadHelpfileFromCSV(output_dir: str, *, required_columns: list[str] | None = real prior escape or accretion mass that this read cannot recover; zero-filling it anyway is still the better choice, since refusing would turn a routine schema addition into a run-killing failure on every - in-flight run, and the accretion module reports the loss when it detects - a zero-filled ledger at resume. Add a column to this set only when it + in-flight run. Add a column to this set only when it fits one of these two reasons. Every other column carries instantaneous physical state, where zero is not "unknown" but a specific and wrong value that a seeded read would pass to a solver as real, poisoning the From d6e63c0a142d29a87936579c3e410485cb018127 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 10 Sep 2026 13:50:31 +0200 Subject: [PATCH 67/71] Apply ruff formatting after the main merge --- tests/integration/test_slow_zalmoxis_aragog_calliope.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/test_slow_zalmoxis_aragog_calliope.py b/tests/integration/test_slow_zalmoxis_aragog_calliope.py index 58779bca4..24089f416 100644 --- a/tests/integration/test_slow_zalmoxis_aragog_calliope.py +++ b/tests/integration/test_slow_zalmoxis_aragog_calliope.py @@ -97,10 +97,10 @@ @pytest.mark.xfail( strict=False, reason=( - "Pre-existing ARAGOG solver limitation: a temperature/entropy step cap " - "arrests the solve near the first liquidus crossing, so Phi_global cannot " + 'Pre-existing ARAGOG solver limitation: a temperature/entropy step cap ' + 'arrests the solve near the first liquidus crossing, so Phi_global cannot ' "advance within the test's 2-timestep/1e3 yr budget. See " - "FormingWorlds/PROTEUS#840." + 'FormingWorlds/PROTEUS#840.' ), ) def test_zalmoxis_aragog_calliope_two_timesteps(proteus_multi_timestep_run): From ca1a17a388c56a445e89641871a21ab4017e1eaa Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 10 Sep 2026 14:04:48 +0200 Subject: [PATCH 68/71] Cite the Kegerreis et al. 2020 giant-impact erosion scaling law in the accretion docs --- docs/Reference/config/accretion.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/Reference/config/accretion.md b/docs/Reference/config/accretion.md index 613117f03..1d83a1c5a 100644 --- a/docs/Reference/config/accretion.md +++ b/docs/Reference/config/accretion.md @@ -30,7 +30,8 @@ and the [coupling loop](../../Explanations/coupling_loop.md#execution-order-per- One loss fraction governs both bodies at each impact: the target loses that fraction of its atmosphere, and a volatile-bearing impactor loses the same fraction of its atmospheric part and delivers the remainder. PROTEUS ships no -impact-loss physics of its own. +impact-loss physics of its own; the `"zephyrus"` module evaluates the +giant-impact erosion scaling law of Kegerreis et al. (2020) [^cite-kegerreis2020]. The mantle re-melt after an impact is a thermodynamic reset rather than an energy deposition: it re-applies the run's `planet.temperature_mode` initial From 7e2ace37ce6853ea60d2003fc5bd51a6bfc54417 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 10 Sep 2026 14:19:30 +0200 Subject: [PATCH 69/71] Correct the golden_run.tsv header count after the main merge The reconcile merge combined two independent column additions, longitude/latitude from this branch and step_dE_impact_J/M_accreted_rock from main, without touching the declared column count comment. Bumped it from 765 to 767 and re-recorded the reference to confirm every value still reproduces exactly. --- tests/integration/golden_run.tsv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/golden_run.tsv b/tests/integration/golden_run.tsv index 809064b32..636a160b0 100644 --- a/tests/integration/golden_run.tsv +++ b/tests/integration/golden_run.tsv @@ -11,7 +11,7 @@ # storing those once is most of the difference in file size. # # rows = 56 -# columns = 765 +# columns = 767 # config_digest = 8a747319ccc1f94d1ea3043a040c56a013f5be35860a783726e5db23f326d98a Time series 0.0 0.0 0.0 1.0 2.0 22.365174359386017 44.453182559019396 68.42270480764319 94.44852427876276 122.7233441587675 153.45983413030177 186.89293884956194 223.28248618547008 262.91613913992376 306.1127426542948 353.2261251654001 404.64942509788676 460.82002483423685 522.2251895441276 589.4085261538343 662.9773994160411 743.6114684179313 832.0725391123349 929.2159680792423 1029.225260238923 1129.2355524915254 1229.2468448470504 1329.259137315499 1429.272429906872 1529.2867226311712 1629.3020154983974 1729.3183085185524 1829.3356017016376 1929.3538950576547 2029.3731885966051 2129.3934823284912 2229.4147762633147 2329.4370704110775 2429.4603647817817 2529.4846593854295 2629.509954232023 2729.5362493315656 2829.563544694059 2929.591840329506 3029.6211362479094 3129.6514324592717 3229.6827289735966 3329.7150258008865 3429.7483229511445 3529.782620434374 3629.8179182605786 3729.8542164397613 3829.891514981926 3929.9298138970757 4029.9691131952145 4130.009412886347 semimajorax const 74798935350.0 From 360d0b0bef0fe2d3b724b1785de1375a680ddd2f Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 10 Sep 2026 14:48:03 +0200 Subject: [PATCH 70/71] Fix retry-ladder test fixtures broken by the CVODE reconcile merge _retry_ladder_runner's mock solver dropped rtol and max_steps, so the retry ladder's getattr(solver, '_max_steps', solver.parameters.solver.max_steps) raised AttributeError on the eagerly-evaluated default before the step-budget ramp could even check the fallback. Added rtol, max_steps, and _max_steps to the fixture. _retry_runner built interior_o as a bare MagicMock, which auto-vivifies any unset attribute as a truthy Mock. That made impact_reset_this_step read True on every guard check, so the sanity-guard tests it drives never exercised the guard they were meant to trip. Set the attribute explicitly to False. Also restored "T_core jump" in the sanity-reject message the last merge dropped in favor of main's tcore_change_max wording, which broke the two tests asserting on that phrase even though the guard itself still fires correctly. --- src/proteus/interior_energetics/aragog.py | 2 +- tests/interior_energetics/test_aragog.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index e82ea9a50..b3ffa619f 100644 --- a/src/proteus/interior_energetics/aragog.py +++ b/src/proteus/interior_energetics/aragog.py @@ -2294,7 +2294,7 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: ) elif dT > sanity_dT_core: sanity_reject_reason = ( - f'T_core changed by up to {dT:.1f} K ' + f'T_core jumped by up to {dT:.1f} K ' f'(>{sanity_dT_core:.0f} K sanity threshold)' ) diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index ee4b64c3d..b5b2c54be 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -926,9 +926,12 @@ def _retry_ladder_runner( ) solver = SimpleNamespace( parameters=SimpleNamespace( - solver=SimpleNamespace(start_time=0.0, end_time=dt_requested) + solver=SimpleNamespace( + start_time=0.0, end_time=dt_requested, rtol=1.0e-6, max_steps=1000 + ) ), _atol_sf=1.0, + _max_steps=1000, get_state=lambda: states[min(len(attempts), len(states)) - 1], get_current_dSdr_cmb=lambda: -1.0e-6, set_initial_dSdr_cmb=lambda value: None, @@ -1972,6 +1975,9 @@ def _retry_runner(solver, monkeypatch, *, T_core_pre=2000.0, mass_tot=1.0): runner.aragog_solver = solver interior_o = MagicMock() interior_o._last_entropy = None + # A bare MagicMock auto-vivifies any attribute as a truthy Mock, which + # would make the giant-impact exemption fire on every guard check below. + interior_o.impact_reset_this_step = False hf_row = {'Time': 1.0e6, 'T_cmb': T_core_pre} return runner, interior_o, hf_row From 03ad107e30536c8619f72bf173fdf9e4b86f5077 Mon Sep 17 00:00:00 2001 From: timlichtenberg Date: Thu, 10 Sep 2026 15:27:45 +0200 Subject: [PATCH 71/71] Add coverage for the giant-impact exemption on a non-finite T_core The finiteness check on the CMB temperature guard runs unconditionally and the impact-step exemption only gates the jump-magnitude check, but no test pinned that a NaN T_core is still rejected on an impact step. --- tests/interior_energetics/test_aragog.py | 27 ++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index b5b2c54be..f9100ef95 100644 --- a/tests/interior_energetics/test_aragog.py +++ b/tests/interior_energetics/test_aragog.py @@ -1508,6 +1508,33 @@ def test_the_core_temperature_guard_stands_aside_for_a_giant_impact(): assert len(failed_attempts) == 6 +@pytest.mark.unit +def test_the_giant_impact_exemption_does_not_cover_a_non_finite_tcore(): + """A giant impact excuses a large T_core jump, not a non-finite one. + + Physical scenario: a relaxed rtol can let CVODE return a NaN core + temperature on any step, impact or not. The impact exemption exists to + keep a real, large jump from being mistaken for a corrupted solve; a NaN + is corrupted regardless of the flag. + + Contract clause: the finiteness check runs before, and independently of, + the impact-step exemption, so a non-finite T_core is rejected down the + full retry ladder even on the step a giant impact fires. + """ + prior = {'Time': 7.68e5, 'T_cmb': 4000.0} + + nan_on_impact, nan_interior, nan_attempts = _retry_ladder_runner( + status=0, dt_actual=100.0, T_core=float('nan') + ) + nan_interior.impact_reset_this_step = True + with pytest.raises(RuntimeError, match='non-finite'): + nan_on_impact._solve_with_retry(prior, nan_interior) + assert len(nan_attempts) == 6, ( + 'a non-finite solve is corrupted regardless of the impact flag, so it ' + 'burns the retry ladder the same as any other non-finite result' + ) + + def _jax_factory_config(): """Config carrying the numeric fields the option Z factory install reads.""" config = MagicMock()