diff --git a/.github/workflows/ci-nightly.yml b/.github/workflows/ci-nightly.yml index 685a058fc..bf390b8e0 100644 --- a/.github/workflows/ci-nightly.yml +++ b/.github/workflows/ci-nightly.yml @@ -217,12 +217,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/.gitignore b/.gitignore index 2aedede6b..82b7fd01e 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,9 @@ Love.jl/ BOREAS boreas Boreas +morrigan +Morrigan +MORRIGAN # misc ###### @@ -335,3 +338,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/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/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 baa9cb609..1eb4dcf97 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 7b711dcdc..2dc0a8625 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. @@ -195,6 +196,18 @@ Some notable consequences of step 3: Config section: `[orbit]`. Reference: [Star and orbit configuration](../Reference/config/star_orbit.md). +## Accretion: Morrigan + +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. + +**[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. + +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 **[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. @@ -241,9 +254,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. --- @@ -296,3 +312,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 717f90428..cd6919d4a 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/optionalmodules_installation.md b/docs/How-to/optionalmodules_installation.md index 3df35c994..900d7a76c 100644 --- a/docs/How-to/optionalmodules_installation.md +++ b/docs/How-to/optionalmodules_installation.md @@ -142,3 +142,31 @@ 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. + +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/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..1d83a1c5a --- /dev/null +++ b/docs/Reference/config/accretion.md @@ -0,0 +1,172 @@ +# 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 to use. Choices: `none`, `"dummy"`, `"timeline"`, `"morrigan"`. | +| `time_offset` | float | `0.0` | 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 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 | `"dry"` | 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; the atmospheric part loses the same collision fraction that strips the target's atmosphere and the remainder of the content is delivered. Choices: `"dry"`, `"match_planet"`, `"ppmw"`. | +| `impactor_H_ppmw` | float | `0.0` | Hydrogen carried by each impactor \[ppmw of impactor mass\]. Must be >= 0. | +| `impactor_C_ppmw` | float | `0.0` | Carbon carried by each impactor \[ppmw of impactor mass\]. Must be >= 0. | +| `impactor_N_ppmw` | float | `0.0` | Nitrogen carried by each impactor \[ppmw of impactor mass\]. Must be >= 0. | +| `impactor_S_ppmw` | float | `0.0` | Sulfur carried by each impactor \[ppmw of impactor mass\]. Must be >= 0. | +| `impactor_O_ppmw` | float | `0.0` | Oxygen carried by each impactor \[ppmw of impactor mass\]. Must be >= 0. | +| `atmloss_module` | str or none | `none` | 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 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. Choices: `none`, `"constant"`, `"zephyrus"`. | +| `atmloss_frac` | float | `0.0` | 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. Must be >= 0 and <= 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 `"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 +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 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 +budgets that are tracked, and the two accountings are not reconciled. + +### 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 an impact history reproducible; sweeping it samples the outcome distribution. Must be >= 0. | +| `num_planets` | int | `10` | Number of embryos the system starts with. Must be >= 2. | +| `masses` | list | `[]` | Initial embryo masses \[M_earth\], one per embryo. An empty list starts every embryo at ``mass_equal``. | +| `mass_equal` | float | `0.5` | Initial mass of every embryo \[M_earth\], used when ``masses`` is empty. Must be > 0. | +| `eccentricity_init` | float | `0.01` | Initial eccentricity shared by all embryos. Must be >= 0. | +| `inner_edge` | float | `0.1` | Semi-major axis of the innermost embryo \[AU\]. Must be > 0. | +| `spacing` | float | `10.0` | 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. Must be > 0 and <= 50.0. | +| `density` | float | `5500.0` | Uniform bulk density used to convert embryo mass to radius \[kg m-3\]. Must be > 0. | +| `impact_angle` | float | `45.0` | Impact angle \[deg\]. The impact parameter is its sine. Must be >= 0. | +| `evolution_time` | float | `1.0` | Duration of the dynamical evolution \[Gyr\]. Must be > 0. | +| `inner_cutoff` | float | `0.005` | Perihelion inside which an embryo counts as lost to the star \[AU\]. Must be > 0. | +| `selector` | str | `"match_config"` | 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``. Choices: `"match_config"`, `"mass"`, `"semimajoraxis"`, `"id"`. | +| `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 | +|---|---|---|---| +| `timeline_path` | str or none | `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``. | +| `mass_accreted` | float | `0.1` | 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. Must be > 0. | +| `num_impacts` | int | `3` | Number of impacts in the timeline. Must be >= 1. | +| `timescale` | float | `1000000.0` | 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. Must be > 0. | +| `time_last` | float | `5000000.0` | Time of the final impact \[yr\]. Impacts are spaced evenly from ``time_last / num_impacts`` up to this time. Must be > 0. | +| `eccentricity` | float | `0.05` | Encounter eccentricity \[1\], setting both the approach velocity that adds to the mutual escape velocity and the impactor's orbit. Must be >= 0 and < 1. | +| `impact_parameter` | float | `0.5` | Impact parameter of every collision \[1\], the sine of the impact angle. Zero is head-on, one is grazing. Must be >= 0 and <= 1. | + + +`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" +``` + +## Constraints + + + +Cross-field constraints enforced when the config file loads: + +- Refuse a timeline path aimed at the module that generates its own. +- The timeline accretion module requires a path to an impact timeline file. +- Refuse a volatile budget that exceeds the impactor's own mass. +- Refuse ppmw budgets that the selected content mode would ignore. +- The Morrigan module requires as many embryo masses as planets, all positive, and a selector value when the selector is a semi-major axis or planet id. + + + [^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/config_schema.json b/docs/Reference/config/config_schema.json index 696ff76b1..dc236be10 100644 --- a/docs/Reference/config/config_schema.json +++ b/docs/Reference/config/config_schema.json @@ -531,6 +531,27 @@ "group": null, "group_qualifier": null }, + { + "path": "params.dt.impact_maximum", + "toml_section": "params.dt", + "class": "TimeStepParams", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "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.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, { "path": "params.dt.max_growth_factor", "toml_section": "params.dt", @@ -8310,10 +8331,13 @@ "accepts_none": true, "default": "none", "choices": [ - null + null, + "dummy", + "timeline", + "morrigan" ], "bounds": null, - "description": "Accretion module to use. Currently only None is supported.", + "description": "Accretion module to use. Choices: None, \"dummy\", \"timeline\", \"morrigan\".", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -8321,18 +8345,57 @@ "group_qualifier": null }, { - "path": "observe.module", - "toml_section": "observe", - "class": "Observe", - "type": "str or none", - "accepts_none": true, - "default": "none", - "choices": [ - null, - "petitRADTRANS" + "path": "accretion.morrigan.seed", + "toml_section": "accretion.morrigan", + "class": "Morrigan", + "type": "int", + "accepts_none": false, + "default": "1", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "Random seed for the Monte Carlo. Fixing it makes an impact history reproducible; sweeping it samples the outcome distribution.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.morrigan.num_planets", + "toml_section": "accretion.morrigan", + "class": "Morrigan", + "type": "int", + "accepts_none": false, + "default": "10", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 2 + } ], + "description": "Number of embryos the system starts with.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.morrigan.masses", + "toml_section": "accretion.morrigan", + "class": "Morrigan", + "type": "list", + "accepts_none": false, + "default": "[]", + "choices": null, "bounds": null, - "description": "Module to use for calculating synthetic spectra.", + "description": "Initial embryo masses [M_earth], one per embryo. An empty list starts every embryo at ``mass_equal``.", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -8340,12 +8403,75 @@ "group_qualifier": null }, { - "path": "observe.clip_vmr", - "toml_section": "observe", - "class": "Observe", + "path": "accretion.morrigan.mass_equal", + "toml_section": "accretion.morrigan", + "class": "Morrigan", "type": "float", "accepts_none": false, - "default": "1e-08", + "default": "0.5", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Initial mass of every embryo [M_earth], used when ``masses`` is empty.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.morrigan.eccentricity_init", + "toml_section": "accretion.morrigan", + "class": "Morrigan", + "type": "float", + "accepts_none": false, + "default": "0.01", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "Initial eccentricity shared by all embryos.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.morrigan.inner_edge", + "toml_section": "accretion.morrigan", + "class": "Morrigan", + "type": "float", + "accepts_none": false, + "default": "0.1", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Semi-major axis of the innermost embryo [AU].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.morrigan.spacing", + "toml_section": "accretion.morrigan", + "class": "Morrigan", + "type": "float", + "accepts_none": false, + "default": "10.0", "choices": null, "bounds": [ { @@ -8353,11 +8479,11 @@ "value": 0 }, { - "op": "<", - "value": 1 + "op": "<=", + "value": 50.0 } ], - "description": "Minimum VMR to include a species in radiative transfer.", + "description": "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.", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -8365,12 +8491,12 @@ "group_qualifier": null }, { - "path": "observe.reference_pressure", - "toml_section": "observe", - "class": "Observe", + "path": "accretion.morrigan.density", + "toml_section": "accretion.morrigan", + "class": "Morrigan", "type": "float", "accepts_none": false, - "default": "10", + "default": "5500.0", "choices": null, "bounds": [ { @@ -8378,7 +8504,7 @@ "value": 0 } ], - "description": "Reference pressure for synthetic spectrum generation [bar].", + "description": "Uniform bulk density used to convert embryo mass to radius [kg m-3].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -8386,20 +8512,20 @@ "group_qualifier": null }, { - "path": "observe.source", - "toml_section": "observe", - "class": "Observe", - "type": "str", + "path": "accretion.morrigan.impact_angle", + "toml_section": "accretion.morrigan", + "class": "Morrigan", + "type": "float", "accepts_none": false, - "default": "\"all\"", - "choices": [ - "all", - "outgas", - "profile", - "offchem" + "default": "45.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } ], - "bounds": null, - "description": "Composition source selection: 'all', 'outgas', 'profile', or 'offchem'.", + "description": "Impact angle [deg]. The impact parameter is its sine.", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -8407,19 +8533,20 @@ "group_qualifier": null }, { - "path": "observe.spectrum_type", - "toml_section": "observe", - "class": "Observe", - "type": "str", + "path": "accretion.morrigan.evolution_time", + "toml_section": "accretion.morrigan", + "class": "Morrigan", + "type": "float", "accepts_none": false, - "default": "\"both\"", - "choices": [ - "both", - "transit", - "eclipse" + "default": "1.0", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } ], - "bounds": null, - "description": "Synthetic spectrum products to compute: 'both', 'transit', or 'eclipse'.", + "description": "Duration of the dynamical evolution [Gyr].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -8427,15 +8554,20 @@ "group_qualifier": null }, { - "path": "observe.remove_one_gas", - "toml_section": "observe", - "class": "Observe", - "type": "bool", + "path": "accretion.morrigan.inner_cutoff", + "toml_section": "accretion.morrigan", + "class": "Morrigan", + "type": "float", "accepts_none": false, - "default": "true", + "default": "0.005", "choices": null, - "bounds": null, - "description": "If True, generate additional leave-one-out spectra with each gas removed.", + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Perihelion inside which an embryo counts as lost to the star [AU].", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -8443,18 +8575,20 @@ "group_qualifier": null }, { - "path": "observe.petitRADTRANS.line_opacity_mode", - "toml_section": "observe.petitRADTRANS", - "class": "PetitRADTRANS", + "path": "accretion.morrigan.selector", + "toml_section": "accretion.morrigan", + "class": "Morrigan", "type": "str", "accepts_none": false, - "default": "\"c-k\"", + "default": "\"match_config\"", "choices": [ - "c-k", - "lbl" + "match_config", + "mass", + "semimajoraxis", + "id" ], "bounds": null, - "description": "Opacity treatment: 'c-k' (correlated-k) or 'lbl' (line-by-line).", + "description": "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``.", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -8462,15 +8596,15 @@ "group_qualifier": null }, { - "path": "observe.petitRADTRANS.include_rayleigh", - "toml_section": "observe.petitRADTRANS", - "class": "PetitRADTRANS", - "type": "bool", - "accepts_none": false, - "default": "true", + "path": "accretion.morrigan.selector_value", + "toml_section": "accretion.morrigan", + "class": "Morrigan", + "type": "float or none", + "accepts_none": true, + "default": "none", "choices": null, "bounds": null, - "description": "Include Rayleigh scattering contributions.", + "description": "Target value for the 'semimajoraxis' and 'id' selectors. Ignored otherwise.", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -8478,15 +8612,15 @@ "group_qualifier": null }, { - "path": "observe.petitRADTRANS.include_cia", - "toml_section": "observe.petitRADTRANS", - "class": "PetitRADTRANS", - "type": "bool", - "accepts_none": false, - "default": "true", + "path": "accretion.dummy.timeline_path", + "toml_section": "accretion.dummy", + "class": "AccretionDummy", + "type": "str or none", + "accepts_none": true, + "default": "none", "choices": null, "bounds": null, - "description": "Include collision-induced absorption contributions.", + "description": "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``.", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -8494,15 +8628,20 @@ "group_qualifier": null }, { - "path": "observe.petitRADTRANS.silent", - "toml_section": "observe.petitRADTRANS", - "class": "PetitRADTRANS", - "type": "bool", + "path": "accretion.dummy.mass_accreted", + "toml_section": "accretion.dummy", + "class": "AccretionDummy", + "type": "float", "accepts_none": false, - "default": "true", + "default": "0.1", "choices": null, - "bounds": null, - "description": "Suppress petitRADTRANS stdout/stderr during Radtrans initialization.", + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "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.", "doc_source": "attributes", "group_order": 0, "group_position": 0, @@ -8510,37 +8649,598 @@ "group_qualifier": null }, { - "path": "config_version", - "toml_section": "", - "class": "Config", - "type": "str", + "path": "accretion.dummy.num_impacts", + "toml_section": "accretion.dummy", + "class": "AccretionDummy", + "type": "int", "accepts_none": false, - "default": "\"3.0\"", + "default": "3", "choices": null, - "bounds": null, - "description": "Version of the configuration file format.", + "bounds": [ + { + "op": ">=", + "value": 1 + } + ], + "description": "Number of impacts in the timeline.", "doc_source": "attributes", "group_order": 0, "group_position": 0, "group": null, "group_qualifier": null - } - ], - "constraints": [ - { - "validator": "valid_aerosols_enabled", - "module": "_atmos_clim.py", - "attached_to": [ - "atmos_clim.aerosols_enabled" - ], - "touches": [ - "agni.spectral_file", - "module" - ], - "doc": "Aerosol scattering needs band-resolved RT." }, { - "validator": "valid_agni", + "path": "accretion.dummy.timescale", + "toml_section": "accretion.dummy", + "class": "AccretionDummy", + "type": "float", + "accepts_none": false, + "default": "1000000.0", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "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.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.dummy.time_last", + "toml_section": "accretion.dummy", + "class": "AccretionDummy", + "type": "float", + "accepts_none": false, + "default": "5000000.0", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Time of the final impact [yr]. Impacts are spaced evenly from ``time_last / num_impacts`` up to this time.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.dummy.eccentricity", + "toml_section": "accretion.dummy", + "class": "AccretionDummy", + "type": "float", + "accepts_none": false, + "default": "0.05", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + }, + { + "op": "<", + "value": 1 + } + ], + "description": "Encounter eccentricity [1], setting both the approach velocity that adds to the mutual escape velocity and the impactor's orbit.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.dummy.impact_parameter", + "toml_section": "accretion.dummy", + "class": "AccretionDummy", + "type": "float", + "accepts_none": false, + "default": "0.5", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + }, + { + "op": "<=", + "value": 1 + } + ], + "description": "Impact parameter of every collision [1], the sine of the impact angle. Zero is head-on, one is grazing.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.timeline.timeline_path", + "toml_section": "accretion.timeline", + "class": "AccretionTimeline", + "type": "str or none", + "accepts_none": true, + "default": "none", + "choices": null, + "bounds": null, + "description": "Path to the impact timeline file. Environment variables and ``~`` are expanded.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.time_offset", + "toml_section": "accretion", + "class": "Accretion", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": null, + "description": "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 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.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.impactor_volatiles", + "toml_section": "accretion", + "class": "Accretion", + "type": "str", + "accepts_none": false, + "default": "\"dry\"", + "choices": [ + "dry", + "match_planet", + "ppmw" + ], + "bounds": null, + "description": "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; the atmospheric part loses the same collision fraction that strips the target's atmosphere and the remainder of the content is delivered.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.impactor_H_ppmw", + "toml_section": "accretion", + "class": "Accretion", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "Hydrogen carried by each impactor [ppmw of impactor mass].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.impactor_C_ppmw", + "toml_section": "accretion", + "class": "Accretion", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "Carbon carried by each impactor [ppmw of impactor mass].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.impactor_N_ppmw", + "toml_section": "accretion", + "class": "Accretion", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "Nitrogen carried by each impactor [ppmw of impactor mass].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.impactor_S_ppmw", + "toml_section": "accretion", + "class": "Accretion", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "Sulfur carried by each impactor [ppmw of impactor mass].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.impactor_O_ppmw", + "toml_section": "accretion", + "class": "Accretion", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + } + ], + "description": "Oxygen carried by each impactor [ppmw of impactor mass].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.atmloss_module", + "toml_section": "accretion", + "class": "Accretion", + "type": "str or none", + "accepts_none": true, + "default": "none", + "choices": [ + null, + "constant", + "zephyrus" + ], + "bounds": null, + "description": "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 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.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "accretion.atmloss_frac", + "toml_section": "accretion", + "class": "Accretion", + "type": "float", + "accepts_none": false, + "default": "0.0", + "choices": null, + "bounds": [ + { + "op": ">=", + "value": 0 + }, + { + "op": "<=", + "value": 1 + } + ], + "description": "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.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "observe.module", + "toml_section": "observe", + "class": "Observe", + "type": "str or none", + "accepts_none": true, + "default": "none", + "choices": [ + null, + "petitRADTRANS" + ], + "bounds": null, + "description": "Module to use for calculating synthetic spectra.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "observe.clip_vmr", + "toml_section": "observe", + "class": "Observe", + "type": "float", + "accepts_none": false, + "default": "1e-08", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + }, + { + "op": "<", + "value": 1 + } + ], + "description": "Minimum VMR to include a species in radiative transfer.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "observe.reference_pressure", + "toml_section": "observe", + "class": "Observe", + "type": "float", + "accepts_none": false, + "default": "10", + "choices": null, + "bounds": [ + { + "op": ">", + "value": 0 + } + ], + "description": "Reference pressure for synthetic spectrum generation [bar].", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "observe.source", + "toml_section": "observe", + "class": "Observe", + "type": "str", + "accepts_none": false, + "default": "\"all\"", + "choices": [ + "all", + "outgas", + "profile", + "offchem" + ], + "bounds": null, + "description": "Composition source selection: 'all', 'outgas', 'profile', or 'offchem'.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "observe.spectrum_type", + "toml_section": "observe", + "class": "Observe", + "type": "str", + "accepts_none": false, + "default": "\"both\"", + "choices": [ + "both", + "transit", + "eclipse" + ], + "bounds": null, + "description": "Synthetic spectrum products to compute: 'both', 'transit', or 'eclipse'.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "observe.remove_one_gas", + "toml_section": "observe", + "class": "Observe", + "type": "bool", + "accepts_none": false, + "default": "true", + "choices": null, + "bounds": null, + "description": "If True, generate additional leave-one-out spectra with each gas removed.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "observe.petitRADTRANS.line_opacity_mode", + "toml_section": "observe.petitRADTRANS", + "class": "PetitRADTRANS", + "type": "str", + "accepts_none": false, + "default": "\"c-k\"", + "choices": [ + "c-k", + "lbl" + ], + "bounds": null, + "description": "Opacity treatment: 'c-k' (correlated-k) or 'lbl' (line-by-line).", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "observe.petitRADTRANS.include_rayleigh", + "toml_section": "observe.petitRADTRANS", + "class": "PetitRADTRANS", + "type": "bool", + "accepts_none": false, + "default": "true", + "choices": null, + "bounds": null, + "description": "Include Rayleigh scattering contributions.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "observe.petitRADTRANS.include_cia", + "toml_section": "observe.petitRADTRANS", + "class": "PetitRADTRANS", + "type": "bool", + "accepts_none": false, + "default": "true", + "choices": null, + "bounds": null, + "description": "Include collision-induced absorption contributions.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "observe.petitRADTRANS.silent", + "toml_section": "observe.petitRADTRANS", + "class": "PetitRADTRANS", + "type": "bool", + "accepts_none": false, + "default": "true", + "choices": null, + "bounds": null, + "description": "Suppress petitRADTRANS stdout/stderr during Radtrans initialization.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + }, + { + "path": "config_version", + "toml_section": "", + "class": "Config", + "type": "str", + "accepts_none": false, + "default": "\"3.0\"", + "choices": null, + "bounds": null, + "description": "Version of the configuration file format.", + "doc_source": "attributes", + "group_order": 0, + "group_position": 0, + "group": null, + "group_qualifier": null + } + ], + "constraints": [ + { + "validator": "valid_accretiondummy", + "module": "_accretion.py", + "attached_to": [ + "accretion.dummy" + ], + "touches": [ + "dummy.timeline_path" + ], + "doc": "Refuse a timeline path aimed at the module that generates its own." + }, + { + "validator": "valid_accretiontimeline", + "module": "_accretion.py", + "attached_to": [ + "accretion.timeline" + ], + "touches": [ + "module", + "timeline.timeline_path" + ], + "doc": "The timeline accretion module requires a path to an impact timeline file." + }, + { + "validator": "valid_impactor_budget_total", + "module": "_accretion.py", + "attached_to": [ + "accretion.impactor_O_ppmw" + ], + "touches": [ + "impactor_volatiles" + ], + "doc": "Refuse a volatile budget that exceeds the impactor's own mass." + }, + { + "validator": "valid_impactor_volatiles", + "module": "_accretion.py", + "attached_to": [ + "accretion.impactor_O_ppmw" + ], + "touches": [ + "impactor_volatiles" + ], + "doc": "Refuse ppmw budgets that the selected content mode would ignore." + }, + { + "validator": "valid_morrigan", + "module": "_accretion.py", + "attached_to": [ + "accretion.morrigan" + ], + "touches": [ + "module", + "morrigan" + ], + "doc": "The Morrigan module requires as many embryo masses as planets, all positive, and a selector value when the selector is a semi-major axis or planet id." + }, + { + "validator": "valid_aerosols_enabled", + "module": "_atmos_clim.py", + "attached_to": [ + "atmos_clim.aerosols_enabled" + ], + "touches": [ + "agni.spectral_file", + "module" + ], + "doc": "Aerosol scattering needs band-resolved RT." + }, + { + "validator": "valid_agni", "module": "_atmos_clim.py", "attached_to": [ "atmos_clim.agni" @@ -8623,6 +9323,30 @@ ], "doc": "Boundary backend assumes a fixed surface state coupling." }, + { + "validator": "check_accretion_interior_compatibility", + "module": "_config.py", + "attached_to": [ + "config_version" + ], + "touches": [ + "accretion.module", + "interior_energetics.module" + ], + "doc": "Reject accretion runs on an interior that cannot apply an impact." + }, + { + "validator": "check_accretion_vapourise_compatibility", + "module": "_config.py", + "attached_to": [ + "config_version" + ], + "touches": [ + "accretion.module", + "outgas.vapourise" + ], + "doc": "Reject accretion runs that also vapourise rock into the atmosphere." + }, { "validator": "check_module_dependencies", "module": "_config.py", @@ -8630,6 +9354,7 @@ "config_version" ], "touches": [ + "accretion.module", "atmos_chem.module", "escape.module", "outgas.module" diff --git a/docs/Reference/config/observe.md b/docs/Reference/config/observe.md index 580e55301..629bd5ea4 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]` @@ -113,15 +110,6 @@ See [Output format](../../Reference/output.md) for the CSV column layout. | `silent` | bool | `true` | Suppress petitRADTRANS stdout/stderr during Radtrans initialization. | -## Late accretion `[accretion]` - - - -| Parameter | Type | Default | Description | -|---|---|---|---| -| `module` | str or none | `none` | Accretion module to use. Currently only None is supported. Choices: `none`. | - - ## Constraints diff --git a/docs/Reference/config/params.md b/docs/Reference/config/params.md index 7fae49a45..3f3cc5daf 100644 --- a/docs/Reference/config/params.md +++ b/docs/Reference/config/params.md @@ -76,6 +76,7 @@ solidification transition (melt fraction between `phi_crit` and | `mushy_upper` | float | `0.99` | Upper bound of the mushy regime \[dimensionless melt fraction\]. When ``Phi_global < mushy_upper`` AND ``Phi_global > stop.solid.phi_crit``, ``mushy_maximum`` takes over from ``maximum``. Default 0.99 so the cap kicks in as soon as the first cell crystallises. Must be > 0 and < 1. | | `hysteresis_iters` | int | `0` | Number of PROTEUS iterations after an adaptive "slow down" decision during which the speed-up factor is suppressed. Prevents the controller from ramping dt straight back into the same stiffness cliff it just escaped from. Default 3; set to 0 to disable. Must be >= 0. | | `hysteresis_sfinc` | float | `1.1` | 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). Must be >= 1.0. | +| `impact_maximum` | float | `0.0` | 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. Must be >= 0. | | `max_growth_factor` | float | `0.0` | Cap on the dt growth ratio between consecutive steps \[dimensionless\]. Bounds dtswitch / dtprev, preventing large jumps that can wedge the interior solver; 0 (default) disables the cap. Must be >= 0. | @@ -185,6 +186,8 @@ to be satisfied for two consecutive iterations before terminating. Cross-field constraints enforced when the config file loads: +- Reject accretion runs on an interior that cannot apply an impact. +- Reject accretion runs that also vapourise rock into the atmosphere. - Check that required external packages are importable for the selected modules. - The maximum must exceed the minimum on the same section. diff --git a/docs/Reference/module_map.json b/docs/Reference/module_map.json index 0ccc9b51f..ee3c65c6f 100644 --- a/docs/Reference/module_map.json +++ b/docs/Reference/module_map.json @@ -1,5 +1,33 @@ { "sites": [ + { + "area": "Accretion", + "config_path": "accretion.module", + "wrapper": "src/proteus/accretion/wrapper.py", + "options": [ + { + "option": "dummy", + "entry": "src/proteus/accretion/dummy.py:get_timeline", + "role": "Analytical exponential accretion law" + }, + { + "option": "morrigan", + "entry": "src/proteus/accretion/morrigan.py:get_timeline", + "role": "Monte Carlo giant-impact dynamics (Morrigan)" + }, + { + "option": "timeline", + "entry": "src/proteus/accretion/timeline.py:get_timeline", + "role": "Impacts replayed from a timeline file" + }, + { + "option": null, + "entry": null, + "role": "Accretion disabled; no impacts" + } + ], + "sub_dispatches": [] + }, { "area": "Atmosphere climate", "config_path": "atmos_clim.module", diff --git a/docs/Reference/module_map.md b/docs/Reference/module_map.md index 031927f29..e08d123bc 100644 --- a/docs/Reference/module_map.md +++ b/docs/Reference/module_map.md @@ -11,6 +11,15 @@ Module selection happens in each area's wrapper; invalid option values are rejected when the config file loads. Secondary switches within an area are listed below its table. +## Accretion (`accretion.module`) + +| Option | Entry point | Role | +|---|---|---| +| `"dummy"` | `accretion/dummy.py:get_timeline` | Analytical exponential accretion law | +| `"morrigan"` | `accretion/morrigan.py:get_timeline` | Monte Carlo giant-impact dynamics (Morrigan) | +| `"timeline"` | `accretion/timeline.py:get_timeline` | Impacts replayed from a timeline file | +| `none` | not applicable | Accretion disabled; no impacts | + ## Atmosphere climate (`atmos_clim.module`) | Option | Entry point | Role | diff --git a/docs/Reference/module_versions.md b/docs/Reference/module_versions.md index 6ec442ed5..f351aab56 100644 --- a/docs/Reference/module_versions.md +++ b/docs/Reference/module_versions.md @@ -21,7 +21,7 @@ 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-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.09.09-blue)](https://pypi.org/project/fwl-aragog/26.09.09/){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/) | @@ -48,6 +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.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/docs/Reference/output.md b/docs/Reference/output.md index 12c6ebe01..a36adbfc7 100644 --- a/docs/Reference/output.md +++ b/docs/Reference/output.md @@ -74,17 +74,17 @@ Each iteration carries the previous row forward and overwrites only the columns | Column | Unit | Description | Producer | Written when | Read by | |---|---|---|---|---|---| -| `Time` | `yr` | | `proteus.py` | always | atmos_chem, atmos_clim, interior_energetics, main loop, observe, orbit, outgas, plot, star, utils | +| `Time` | `yr` | | `proteus.py` | always | accretion, atmos_chem, atmos_clim, interior_energetics, main loop, observe, orbit, outgas, plot, star, utils | ### Orbital and spin parameters of planet | Column | Unit | Description | Producer | Written when | Read by | |---|---|---|---|---|---| -| `semimajorax` | `m` | semi-major axis | `orbit/orbit.py`
`orbit/wrapper.py` | always; orbit.evolve = true | escape, orbit, plot | +| `semimajorax` | `m` | semi-major axis | `accretion/wrapper.py`
`orbit/orbit.py`
`orbit/wrapper.py` | always; orbit.evolve = true | accretion, escape, orbit, plot | | `separation` | `m` | time-averaged separation | `orbit/wrapper.py` | always | atmos_chem, atmos_clim, main loop, observe, orbit, plot, star, utils | | `perihelion` | `m` | lowest point in orbit | `orbit/wrapper.py` | always | orbit | | `orbital_period` | `s` | orbital duration | `orbit/wrapper.py` | always | orbit | -| `eccentricity` | `1` | orbital eccentricity | `orbit/orbit.py`
`orbit/wrapper.py` | always; orbit.evolve = true | escape, orbit, plot | +| `eccentricity` | `1` | orbital eccentricity | `accretion/wrapper.py`
`orbit/orbit.py`
`orbit/wrapper.py` | always; orbit.evolve = true | accretion, escape, orbit, plot | | `Imk2` | `1` | Imaginary part of k2 Love Number | `orbit/wrapper.py` | always | orbit | | `axial_period` | `s` | day length of planet around its axis | `orbit/satellite.py`
`orbit/wrapper.py` | always; orbit.satellite = true | atmos_clim, orbit, plot, utils | | `longitude` | `deg` | column longitude relative to substellar point | `atmos_clim/agni.py`
`orbit/wrapper.py` | always; atmos_clim.module = "agni" | atmos_clim | @@ -105,7 +105,7 @@ Each iteration carries the previous row forward and overwrites only the columns |---|---|---|---|---|---| | `R_int` | `m` | interior radius | `interior_energetics/wrapper.py`
`interior_struct/dummy.py`
`interior_struct/zalmoxis.py`
`proteus.py` | always; interior_struct.module = "dummy"; interior_struct.module = "zalmoxis" | atmos_chem, atmos_clim, escape, interior_energetics, interior_struct, main loop, observe, orbit, outgas, plot | | `M_int` | `kg` | interior mass | `interior_energetics/wrapper.py`
`interior_struct/dummy.py`
`interior_struct/zalmoxis.py` | always; interior_struct.module = "dummy"; interior_struct.module = "zalmoxis" | atmos_clim, interior_energetics, interior_struct, orbit, outgas | -| `M_planet` | `kg` | total planet wet+dry mass | `interior_energetics/wrapper.py` | always | atmos_clim, escape, interior_energetics, orbit, outgas, plot, utils | +| `M_planet` | `kg` | total planet wet+dry mass | `interior_energetics/wrapper.py` | always | accretion, atmos_clim, escape, interior_energetics, orbit, outgas, plot, utils | | `M_vaps` | `kg` | vapourised rock mass, including the vapourised oxygen | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, utils | | `R_core` | `m` | core radius | `interior_struct/dummy.py`
`interior_struct/zalmoxis.py` | interior_struct.module = "dummy"; interior_struct.module = "zalmoxis" | interior_energetics | | `R_solvus` | `m` | solvus radius for global_miscibility mode | `interior_struct/zalmoxis.py` | interior_struct.module = "zalmoxis" | interior_energetics, interior_struct, main loop | @@ -191,6 +191,7 @@ Each iteration carries the previous row forward and overwrites only the columns | `step_solver_residual_J` | `J` | per-call entropy-ODE LHS-RHS | `interior_energetics/aragog.py` | interior_energetics.module = "aragog" | utils | | `step_dE_compression_J` | `J` | per-call structure-re-solve compression work (diagnostic) | `interior_energetics/aragog.py` | interior_energetics.module = "aragog" | | | `step_dE_state_heat_J` | `J` | per-call entropy-transported heat content change | `interior_energetics/aragog.py` | interior_energetics.module = "aragog" | utils | +| `step_dE_impact_J` | `J` | giant-impact re-melt heat injection (both residual sides) | `interior_energetics/aragog.py`
`interior_energetics/wrapper.py`
`proteus.py` | always; interior_energetics.module = "aragog" | interior_energetics, utils | | `E_state_heat_cons_J` | `J` | cumulative sum of step_dE_state_heat_J across rows | `utils/coupler.py` | always | | | `dE_predicted_cons_J` | `J` | cumulative sum of boundary fluxes + live-density step_dE_Q_*_J | `utils/coupler.py` | always | | | `E_residual_cons_J` | `J` | E_state_heat_cons_J - dE_predicted_cons_J | `utils/coupler.py` | always | | @@ -237,15 +238,16 @@ Each iteration carries the previous row forward and overwrites only the columns | `fO2_vapourise_shift_IW_derived` | `log10 bar` | rock-vapour IW offset | `outgas/lavatmos.py` | outgas.vapourise = true | outgas | | `O_res` | `kg` | O mass-balance residual | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | | | `O_vapourised_kg` | `kg` | oxygen released by rock vapourisation (LavAtmos) | `outgas/lavatmos.py` | outgas.vapourise = true | | +| `M_vol_initial` | `kg` | bulk volatile inventory baseline | `escape/wrapper.py` | always | escape, outgas | +| `esc_kg_cumulative` | `kg` | cumulative mass lost to space (escape + impact stripping) | `accretion/wrapper.py`
`escape/wrapper.py` | always | accretion, escape, outgas | +| `esc_clamp_frac` | `1` | requested per-step loss / escapable reservoir | `escape/wrapper.py`
`proteus.py` | always | escape | +| `esc_step_kg` | `kg` | loss applied on this step, after the cap | `escape/wrapper.py`
`proteus.py` | always | outgas | -### Desiccation escape balance +### Giant-impact accretion ledger | Column | Unit | Description | Producer | Written when | Read by | |---|---|---|---|---|---| -| `M_vol_initial` | `kg` | bulk volatile inventory baseline | `escape/wrapper.py` | always | escape, outgas | -| `esc_kg_cumulative` | `kg` | cumulative escaped mass | `escape/wrapper.py` | always | escape, outgas | -| `esc_clamp_frac` | `1` | requested per-step loss / escapable reservoir | `escape/wrapper.py`
`proteus.py` | always | escape | -| `esc_step_kg` | `kg` | loss applied on this step, after the cap | `escape/wrapper.py`
`proteus.py` | always | outgas | +| `M_accreted_rock` | `kg` | cumulative rock mass added by giant impacts | `accretion/wrapper.py` | always | accretion, main loop | ### Gases from outgassing @@ -376,10 +378,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `He_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `He_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `He_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `He_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `He_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `He_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `He_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `He_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `He_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `He_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `He_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `He_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -387,10 +389,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `Ne_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ne_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ne_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `Ne_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `Ne_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `Ne_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ne_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `Ne_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `Ne_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `Ne_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `Ne_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `Ne_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -398,10 +400,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `Ar_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ar_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ar_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `Ar_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `Ar_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `Ar_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ar_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `Ar_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `Ar_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `Ar_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `Ar_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `Ar_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -409,10 +411,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `Kr_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Kr_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Kr_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `Kr_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `Kr_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `Kr_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Kr_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `Kr_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `Kr_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `Kr_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `Kr_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `Kr_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -420,10 +422,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `Xe_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Xe_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Xe_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `Xe_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `Xe_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `Xe_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Xe_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `Xe_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `Xe_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `Xe_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `Xe_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `Xe_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -453,10 +455,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `Si_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Si_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Si_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `Si_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `Si_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `Si_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Si_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `Si_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `Si_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `Si_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `Si_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `Si_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -464,10 +466,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `Na_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Na_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Na_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `Na_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `Na_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `Na_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Na_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `Na_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `Na_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `Na_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `Na_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `Na_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -475,10 +477,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `K_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `K_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `K_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `K_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `K_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `K_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `K_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `K_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `K_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `K_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `K_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `K_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -486,10 +488,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `Ti_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ti_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ti_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `Ti_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `Ti_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `Ti_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ti_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `Ti_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `Ti_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `Ti_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `Ti_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `Ti_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -519,10 +521,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `Mg_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Mg_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Mg_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `Mg_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `Mg_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `Mg_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Mg_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `Mg_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `Mg_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `Mg_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `Mg_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `Mg_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -541,10 +543,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `Al_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Al_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Al_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `Al_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `Al_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `Al_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Al_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `Al_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `Al_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `Al_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `Al_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `Al_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -585,10 +587,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `Fe_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Fe_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Fe_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `Fe_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `Fe_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `Fe_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Fe_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `Fe_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `Fe_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `Fe_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `Fe_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `Fe_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -640,10 +642,10 @@ Each iteration carries the previous row forward and overwrites only the columns | `Ca_mol_solid` | `mol` | number in solid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ca_mol_liquid` | `mol` | number in liquid mantle | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ca_mol_total` | `mol` | number in whole planet | `outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | plot | -| `Ca_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `Ca_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `Ca_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `Ca_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `Ca_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | escape, interior_struct, outgas, plot | +| `Ca_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, escape, interior_struct, outgas, plot | | `Ca_vmr` | `1` | outgassed volume mixing ratio | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_clim, outgas | | `Ca_bar` | `bar` | partial surface pressure | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py`
`proteus.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | outgas, plot | | `Ca_vmr_xuv` | `1` | volume mixing ratio at XUV level | `atmos_clim/agni.py`
`atmos_clim/dummy.py`
`atmos_clim/janus.py` | atmos_clim.module = "agni"; atmos_clim.module = "dummy"; atmos_clim.module = "janus" | | @@ -663,26 +665,26 @@ Each iteration carries the previous row forward and overwrites only the columns | Column | Unit | Description | Producer | Written when | Read by | |---|---|---|---|---|---| -| `H_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `H_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `H_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `H_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/binodal.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; h2 binodal enabled; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `H_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | escape, interior_struct, outgas, plot | -| `O_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `H_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | accretion, escape, interior_struct, outgas, plot | +| `O_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `O_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `O_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `O_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | escape, interior_struct, outgas, plot | -| `C_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `O_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | accretion, escape, interior_struct, outgas, plot | +| `C_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `C_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `C_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `C_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | escape, interior_struct, outgas, plot | -| `N_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `C_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | accretion, escape, interior_struct, outgas, plot | +| `N_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `N_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `N_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `N_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | escape, interior_struct, outgas, plot | -| `S_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | atmos_chem, interior_struct, outgas, plot | +| `N_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | accretion, escape, interior_struct, outgas, plot | +| `S_kg_atm` | `kg` | mass outgassed to atmosphere | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/lavatmos.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy"; outgas.vapourise = true | accretion, atmos_chem, interior_struct, outgas, plot | | `S_kg_solid` | `kg` | mass in solid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | | `S_kg_liquid` | `kg` | mass in liquid mantle | `outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | outgas | -| `S_kg_total` | `kg` | mass in whole planet | `escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | escape, interior_struct, outgas, plot | +| `S_kg_total` | `kg` | mass in whole planet | `accretion/wrapper.py`
`escape/wrapper.py`
`outgas/atmodeller.py`
`outgas/calliope.py`
`outgas/dummy.py`
`outgas/wrapper.py` | always; outgas.module = "atmodeller"; outgas.module = "calliope"; outgas.module = "dummy" | accretion, escape, interior_struct, outgas, plot | ### Element mass ratios in atmosphere diff --git a/docs/Reference/output_schema.json b/docs/Reference/output_schema.json index 26bc834a3..db9650d9e 100644 --- a/docs/Reference/output_schema.json +++ b/docs/Reference/output_schema.json @@ -13,6 +13,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "atmos_clim", "interior_energetics", @@ -32,6 +33,10 @@ "group": "Orbital and spin parameters of planet", "origin": "literal", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/orbit/orbit.py", "condition": "orbit.evolve = true" @@ -42,6 +47,7 @@ } ], "consumers": [ + "accretion", "escape", "orbit", "plot" @@ -109,6 +115,10 @@ "group": "Orbital and spin parameters of planet", "origin": "literal", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/orbit/orbit.py", "condition": "orbit.evolve = true" @@ -119,6 +129,7 @@ } ], "consumers": [ + "accretion", "escape", "orbit", "plot" @@ -348,6 +359,7 @@ } ], "consumers": [ + "accretion", "atmos_clim", "escape", "interior_energetics", @@ -1751,6 +1763,31 @@ "utils" ] }, + { + "name": "step_dE_impact_J", + "unit": "J", + "description": "giant-impact re-melt heat injection (both residual sides)", + "group": "Energy-conservation columns", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/interior_energetics/aragog.py", + "condition": "interior_energetics.module = \"aragog\"" + }, + { + "file": "src/proteus/interior_energetics/wrapper.py", + "condition": "always" + }, + { + "file": "src/proteus/proteus.py", + "condition": "always" + } + ], + "consumers": [ + "interior_energetics", + "utils" + ] + }, { "name": "E_state_heat_cons_J", "unit": "J", @@ -2427,7 +2464,7 @@ "name": "M_vol_initial", "unit": "kg", "description": "bulk volatile inventory baseline", - "group": "Desiccation escape balance", + "group": "Atmospheric composition from outgassing", "origin": "literal", "producers": [ { @@ -2443,16 +2480,21 @@ { "name": "esc_kg_cumulative", "unit": "kg", - "description": "cumulative escaped mass", - "group": "Desiccation escape balance", + "description": "cumulative mass lost to space (escape + impact stripping)", + "group": "Atmospheric composition from outgassing", "origin": "literal", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" } ], "consumers": [ + "accretion", "escape", "outgas" ] @@ -2461,7 +2503,7 @@ "name": "esc_clamp_frac", "unit": "1", "description": "requested per-step loss / escapable reservoir", - "group": "Desiccation escape balance", + "group": "Atmospheric composition from outgassing", "origin": "literal", "producers": [ { @@ -2481,7 +2523,7 @@ "name": "esc_step_kg", "unit": "kg", "description": "loss applied on this step, after the cap", - "group": "Desiccation escape balance", + "group": "Atmospheric composition from outgassing", "origin": "literal", "producers": [ { @@ -2497,6 +2539,23 @@ "outgas" ] }, + { + "name": "M_accreted_rock", + "unit": "kg", + "description": "cumulative rock mass added by giant impacts", + "group": "Giant-impact accretion ledger", + "origin": "literal", + "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + } + ], + "consumers": [ + "accretion", + "main loop" + ] + }, { "name": "H2O_mol_atm", "unit": "mol", @@ -6209,6 +6268,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -6278,6 +6338,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -6304,6 +6368,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -6540,6 +6605,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -6609,6 +6675,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -6635,6 +6705,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -6871,6 +6942,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -6940,6 +7012,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -6966,6 +7042,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -7202,6 +7279,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -7271,6 +7349,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -7297,6 +7379,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -7533,6 +7616,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -7602,6 +7686,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -7628,6 +7716,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -8506,6 +8595,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -8575,6 +8665,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -8601,6 +8695,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -8837,6 +8932,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -8906,6 +9002,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -8932,6 +9032,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -9168,6 +9269,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -9237,6 +9339,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -9263,6 +9369,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -9499,6 +9606,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -9568,6 +9676,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -9594,6 +9706,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -10472,6 +10585,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -10541,6 +10655,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -10567,6 +10685,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -11124,6 +11243,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -11193,6 +11313,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -11219,6 +11343,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -12418,6 +12543,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -12487,6 +12613,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -12513,6 +12643,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -14033,6 +14164,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -14102,6 +14234,10 @@ "group": "Gases from outgassing", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -14128,6 +14264,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -14584,6 +14721,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -14657,6 +14795,10 @@ "group": "Quantities for each element", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -14679,6 +14821,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -14714,6 +14857,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -14783,6 +14927,10 @@ "group": "Quantities for each element", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -14805,6 +14953,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -14840,6 +14989,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -14909,6 +15059,10 @@ "group": "Quantities for each element", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -14931,6 +15085,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -14966,6 +15121,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -15035,6 +15191,10 @@ "group": "Quantities for each element", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -15057,6 +15217,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", @@ -15092,6 +15253,7 @@ } ], "consumers": [ + "accretion", "atmos_chem", "interior_struct", "outgas", @@ -15161,6 +15323,10 @@ "group": "Quantities for each element", "origin": "_kg_total", "producers": [ + { + "file": "src/proteus/accretion/wrapper.py", + "condition": "always" + }, { "file": "src/proteus/escape/wrapper.py", "condition": "always" @@ -15183,6 +15349,7 @@ } ], "consumers": [ + "accretion", "escape", "interior_struct", "outgas", diff --git a/docs/Validation/accretion/dummy.md b/docs/Validation/accretion/dummy.md new file mode 100644 index 000000000..201190de8 --- /dev/null +++ b/docs/Validation/accretion/dummy.md @@ -0,0 +1,42 @@ +# 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 +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 +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. 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/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 8888d2894..b8feb9fee 100644 --- a/docs/Validation/index.md +++ b/docs/Validation/index.md @@ -12,6 +12,8 @@ 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) | | Atmosphere climate | `atmos_clim/agni.py` | [Transparent black-body limit](atmos_clim/agni.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/docs/assets/proteus_architecture.svg b/docs/assets/proteus_architecture.svg index db472ac4e..0eefc5a97 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
\ 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..37caa9748 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 69cfb88a7..6de50080f 100644 --- a/docs/assets/proteus_modules_schematic.svg +++ b/docs/assets/proteus_modules_schematic.svg @@ -1,4 +1,7 @@ - - -
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 + + + + +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.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 6e6b10949..e1ed67ed2 100644 --- a/docs/assets/proteus_modules_schematic_darkmode.svg +++ b/docs/assets/proteus_modules_schematic_darkmode.svg @@ -1,4 +1,7 @@ - - -
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 + + + + +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.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/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/extra.css b/docs/stylesheets/extra.css index 633330e11..49cdb28fd 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -40,6 +40,43 @@ --pt-basalt: #0E131B; --pt-line-d: #1A2230; + /* 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 */ + --pt-dom-chem: #1B6FA8; /* VULCAN, ZEPHYRUS */ + --pt-dom-atmos: #4FA3D9; /* AGNI, JANUS */ + --pt-dom-stellar: #E0A32E; /* MORS; deepens on light, see the light block */ + --pt-dom-accretion: #A38F7A; /* Morrigan */ + + /* 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; + --pt-p4: #05070B; + --pt-p5: #0E2A45; + --pt-p6: #14406B; + --pt-p7: #1B6FA8; + --pt-p8: #4FA3D9; + --pt-p9: #A8D4E8; + + /* 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; + --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; } @@ -94,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 ---------- */ diff --git a/docs/stylesheets/layout.css b/docs/stylesheets/layout.css index 8a886bb88..27d453ea7 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 */ @@ -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; diff --git a/input/all_options.toml b/input/all_options.toml index 030c00295..d54decf10 100644 --- a/input/all_options.toml +++ b/input/all_options.toml @@ -49,6 +49,7 @@ config_version = "3.0" maximum = 1e7 # maximum time step [yr] initial = 3e1 # initial step size [yr] maximum_rel = 1.0 # time-fraction allowance added to the maximum step; 0 disables + impact_maximum = 0.0 # max dt [yr] for the step landing on a scheduled giant impact; 0 = disabled starspec = 1e8 # recalculate stellar spectrum interval [yr] starinst = 1e2 # recalculate instellation interval [yr] method = "adaptive" # proportional | adaptive | maximum @@ -653,9 +654,63 @@ config_version = "3.0" kappa_SO2 = 1.0 # grey SO2 opacity in IR [cm2 g-1] kappa_S2 = 1.0 # grey S2 opacity in IR [cm2 g-1] -# Late accretion +# Giant-impact accretion and delivery [accretion] - module = "none" # not yet implemented + 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 + # 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. 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 + 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]; 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] + 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 + + [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] + timeline_path = "none" # not a dummy parameter; setting a file here is rejected, use module = "timeline" + + [accretion.timeline] + timeline_path = "none" # impact timeline file to replay # Atmospheric chemistry (post-processing) [atmos_chem] diff --git a/input/cmb_regression_singleimpact.toml b/input/cmb_regression_singleimpact.toml new file mode 100644 index 000000000..32876c6bc --- /dev/null +++ b/input/cmb_regression_singleimpact.toml @@ -0,0 +1,553 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "cmb_regression_singleimpact" +logging = "INFO" +plot_fmt = "png" +write_mod = 1 +dt_write_rel = 0.0 +plot_mod = 0 +archive_mod = "none" +remove_sf = false + +[params.dt] +starspec = 100000000.0 +starinst = 100.0 +method = "adaptive" +propconst = 52.0 +atol = 0.04 +rtol = 0.11 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 100.0 +minimum_rel = 1e-05 +maximum = 10000000.0 +maximum_rel = 1.0 +initial = 100.0 +mushy_maximum = 4000.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 400 + +[params.stop.time] +enabled = true +maximum = 250000.0 +minimum = 210000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = true +freeze_volatiles = false + +[params.stop.radeqm] +enabled = true +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 3.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 1.0 +age_ini = 0.01 +bol_scale = 1.0 +bol_scale_duration = 0.0 +bol_scale_start = "none" + +[star.mors] +age_now = 4.567 +star_name = "sun" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "solar" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 1.0 +eccentricity = 0.1 +zenith_angle = 48.19 +s0_factor = 0.375 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 0.5 +temperature_mode = "liquidus_super" +tsurf_init = 4000.0 +tcmb_init = 6000.0 +tcenter_init = 6000.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "kg" +H_budget = 2.4e+20 +C_mode = "kg" +C_budget = 1.4e+20 +N_mode = "kg" +N_budget = 4e+18 +S_mode = "kg" +S_budget = 1e+20 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.325 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS-2phase:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-08 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "constant" +grain_size = 0.1 +flux_guess = 1000.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.55 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.01 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 200.0 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "atmodeller" +fO2_shift_IW = 1.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 700.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 +vapourise = false + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[outgas.lavatmos] +T_min = 1500.0 +melt_comp_name = "BSE_palm" +P_melt = 0.01 +xatol = 1e-05 +fO2_buffer_model = "oneill" + +[atmos_clim] +module = "agni" +spectral_group = "Dayspring" +spectral_bands = "48" +num_levels = 45 +p_top = 0.0001 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.1 +albedo_pl = 0.1 +rayleigh = false +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.5 +solution_rtol = 0.15 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = true +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = true +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 15.0 +dx_max_ini = 50.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 1000.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" +hill_clamp = true +hill_clamp_frac = 1.0 + +[escape.zephyrus] +Pxuv = 5e-05 +efficiency = 0.3 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "dummy" +time_offset = 0.0 +impactor_volatiles = "ppmw" +impactor_H_ppmw = 0.0 +impactor_C_ppmw = 0.0 +impactor_N_ppmw = 0.0 +impactor_S_ppmw = 0.0 +impactor_O_ppmw = 0.0 +atmloss_module = "constant" +atmloss_frac = 0.0 + +[accretion.morrigan] +seed = 1 +num_planets = 12 +masses = [] +mass_equal = 0.5 +eccentricity_init = 0.01 +inner_edge = 0.7 +spacing = 10.0 +density = 5500.0 +impact_angle = 45.0 +evolution_time = 0.3 +inner_cutoff = 0.005 +selector = "match_config" +selector_value = "none" + +[accretion.dummy] +timeline_path = "none" +mass_accreted = 0.003 +num_impacts = 1 +timescale = 200000.0 +time_last = 200000.0 +eccentricity = 0.05 +impact_parameter = 0.5 + +[accretion.timeline] +timeline_path = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/cmb_regression_singleimpact_prefix.toml b/input/cmb_regression_singleimpact_prefix.toml new file mode 100644 index 000000000..32876c6bc --- /dev/null +++ b/input/cmb_regression_singleimpact_prefix.toml @@ -0,0 +1,553 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "cmb_regression_singleimpact" +logging = "INFO" +plot_fmt = "png" +write_mod = 1 +dt_write_rel = 0.0 +plot_mod = 0 +archive_mod = "none" +remove_sf = false + +[params.dt] +starspec = 100000000.0 +starinst = 100.0 +method = "adaptive" +propconst = 52.0 +atol = 0.04 +rtol = 0.11 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 100.0 +minimum_rel = 1e-05 +maximum = 10000000.0 +maximum_rel = 1.0 +initial = 100.0 +mushy_maximum = 4000.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 400 + +[params.stop.time] +enabled = true +maximum = 250000.0 +minimum = 210000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = true +freeze_volatiles = false + +[params.stop.radeqm] +enabled = true +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 3.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 1.0 +age_ini = 0.01 +bol_scale = 1.0 +bol_scale_duration = 0.0 +bol_scale_start = "none" + +[star.mors] +age_now = 4.567 +star_name = "sun" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "solar" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 1.0 +eccentricity = 0.1 +zenith_angle = 48.19 +s0_factor = 0.375 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 0.5 +temperature_mode = "liquidus_super" +tsurf_init = 4000.0 +tcmb_init = 6000.0 +tcenter_init = 6000.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "kg" +H_budget = 2.4e+20 +C_mode = "kg" +C_budget = 1.4e+20 +N_mode = "kg" +N_budget = 4e+18 +S_mode = "kg" +S_budget = 1e+20 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.325 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS-2phase:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-08 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "constant" +grain_size = 0.1 +flux_guess = 1000.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.55 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.01 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 200.0 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "atmodeller" +fO2_shift_IW = 1.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 700.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 +vapourise = false + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[outgas.lavatmos] +T_min = 1500.0 +melt_comp_name = "BSE_palm" +P_melt = 0.01 +xatol = 1e-05 +fO2_buffer_model = "oneill" + +[atmos_clim] +module = "agni" +spectral_group = "Dayspring" +spectral_bands = "48" +num_levels = 45 +p_top = 0.0001 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.1 +albedo_pl = 0.1 +rayleigh = false +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.5 +solution_rtol = 0.15 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = true +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = true +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 15.0 +dx_max_ini = 50.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 1000.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" +hill_clamp = true +hill_clamp_frac = 1.0 + +[escape.zephyrus] +Pxuv = 5e-05 +efficiency = 0.3 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "dummy" +time_offset = 0.0 +impactor_volatiles = "ppmw" +impactor_H_ppmw = 0.0 +impactor_C_ppmw = 0.0 +impactor_N_ppmw = 0.0 +impactor_S_ppmw = 0.0 +impactor_O_ppmw = 0.0 +atmloss_module = "constant" +atmloss_frac = 0.0 + +[accretion.morrigan] +seed = 1 +num_planets = 12 +masses = [] +mass_equal = 0.5 +eccentricity_init = 0.01 +inner_edge = 0.7 +spacing = 10.0 +density = 5500.0 +impact_angle = 45.0 +evolution_time = 0.3 +inner_cutoff = 0.005 +selector = "match_config" +selector_value = "none" + +[accretion.dummy] +timeline_path = "none" +mass_accreted = 0.003 +num_impacts = 1 +timescale = 200000.0 +time_last = 200000.0 +eccentricity = 0.05 +impact_parameter = 0.5 + +[accretion.timeline] +timeline_path = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/oxcheck/.toml b/input/oxcheck/.toml new file mode 100644 index 000000000..e69de29bb diff --git a/input/oxcheck/ox-fO2-m1.toml b/input/oxcheck/ox-fO2-m1.toml new file mode 100644 index 000000000..b667582e2 --- /dev/null +++ b/input/oxcheck/ox-fO2-m1.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "oxcheck/ox-fO2-m1/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 500000.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 500000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = true + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 31547.867224009646 +C_mode = "C/H" +C_budget = 0.1 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.325 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-06 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = -1.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/oxcheck/ox-fO2-p1.toml b/input/oxcheck/ox-fO2-p1.toml new file mode 100644 index 000000000..d732a89ab --- /dev/null +++ b/input/oxcheck/ox-fO2-p1.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "oxcheck/ox-fO2-p1/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 500000.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 500000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = true + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 31547.867224009646 +C_mode = "C/H" +C_budget = 0.1 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.325 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-06 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = 1.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/oxcheck/ox-fO2-p3.toml b/input/oxcheck/ox-fO2-p3.toml new file mode 100644 index 000000000..a29b45518 --- /dev/null +++ b/input/oxcheck/ox-fO2-p3.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "oxcheck/ox-fO2-p3/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 500000.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 500000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = true + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 31547.867224009646 +C_mode = "C/H" +C_budget = 0.1 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.325 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-06 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = 3.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/oxcheck/ox-fO2-p5.toml b/input/oxcheck/ox-fO2-p5.toml new file mode 100644 index 000000000..f0a889958 --- /dev/null +++ b/input/oxcheck/ox-fO2-p5.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "oxcheck/ox-fO2-p5/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 500000.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 500000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = true + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 31547.867224009646 +C_mode = "C/H" +C_budget = 0.1 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.325 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-06 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = 5.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/rewave_local/q1-000001.toml b/input/rewave_local/q1-000001.toml new file mode 100644 index 000000000..169e25bcd --- /dev/null +++ b/input/rewave_local/q1-000001.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "capcheck/q1-000001-cap/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 0.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 9000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = false + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 12559.43215754791 +C_mode = "C/H" +C_budget = 0.001 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.215 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-10 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = -5.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/rewave_local/q1-000002.toml b/input/rewave_local/q1-000002.toml new file mode 100644 index 000000000..e98d67f89 --- /dev/null +++ b/input/rewave_local/q1-000002.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "capcheck/q1-000002-cap/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 0.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 9000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = false + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 49999.99999999999 +C_mode = "C/H" +C_budget = 0.001 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.215 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-10 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = -5.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/rewave_local/q1-000004.toml b/input/rewave_local/q1-000004.toml new file mode 100644 index 000000000..6e777af30 --- /dev/null +++ b/input/rewave_local/q1-000004.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "capcheck/q1-000004-cap/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 0.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 9000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = false + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 12559.43215754791 +C_mode = "C/H" +C_budget = 0.0031622776601683794 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.215 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-10 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = -5.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/rewave_local/q1-000005.toml b/input/rewave_local/q1-000005.toml new file mode 100644 index 000000000..0b6041d66 --- /dev/null +++ b/input/rewave_local/q1-000005.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "capcheck/q1-000005-cap/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 0.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 9000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = false + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 49999.99999999999 +C_mode = "C/H" +C_budget = 0.0031622776601683794 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.215 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-10 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = -5.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/rewave_local/q1-000007.toml b/input/rewave_local/q1-000007.toml new file mode 100644 index 000000000..6e3040cdd --- /dev/null +++ b/input/rewave_local/q1-000007.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "capcheck/q1-000007-cap/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 0.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 9000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = false + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 12559.43215754791 +C_mode = "C/H" +C_budget = 0.01 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.215 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-10 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = -5.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/rewave_local/q1-000008.toml b/input/rewave_local/q1-000008.toml new file mode 100644 index 000000000..0e2c8ff0b --- /dev/null +++ b/input/rewave_local/q1-000008.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "capcheck/q1-000008-cap/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 0.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 9000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = false + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 49999.99999999999 +C_mode = "C/H" +C_budget = 0.01 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.215 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-10 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = -5.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/rewave_local/q1-000010.toml b/input/rewave_local/q1-000010.toml new file mode 100644 index 000000000..f5f947ee6 --- /dev/null +++ b/input/rewave_local/q1-000010.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "capcheck/q1-000010-cap/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 0.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 9000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = false + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 12559.43215754791 +C_mode = "C/H" +C_budget = 0.03162277660168379 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.215 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-10 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = -5.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/rewave_local/q1-000011.toml b/input/rewave_local/q1-000011.toml new file mode 100644 index 000000000..6933e711b --- /dev/null +++ b/input/rewave_local/q1-000011.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "capcheck/q1-000011-cap/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 0.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 9000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = false + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 49999.99999999999 +C_mode = "C/H" +C_budget = 0.03162277660168379 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.215 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-10 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = -5.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/input/rewave_local/q1-000013.toml b/input/rewave_local/q1-000013.toml new file mode 100644 index 000000000..d22a80c42 --- /dev/null +++ b/input/rewave_local/q1-000013.toml @@ -0,0 +1,505 @@ +config_version = "3.0" + +[params] +resume = false +offline = true + +[params.out] +path = "capcheck/q1-000013-cap/" +logging = "INFO" +plot_fmt = "png" +write_mod = 10 +dt_write_rel = 0.0 +plot_mod = 50 +archive_mod = 0 +remove_sf = false + +[params.dt] +starspec = 10000000000.0 +starinst = 10.0 +method = "adaptive" +propconst = 52.0 +atol = 0.05 +rtol = 0.15 +scale_incr = 1.6 +scale_decr = 0.8 +window = 3 +minimum = 10000.0 +minimum_rel = 0.01 +maximum = 10000000.0 +maximum_rel = 0.0 +initial = 30.0 +mushy_maximum = 0.0 +mushy_upper = 0.99 +hysteresis_iters = 0 +hysteresis_sfinc = 1.1 +max_growth_factor = 0.0 + +[params.stop] +strict = false + +[params.stop.iters] +enabled = true +minimum = 5 +maximum = 9000 + +[params.stop.time] +enabled = true +maximum = 5465000000.0 +minimum = 1000.0 + +[params.stop.solid] +phi_crit = 0.01 +enabled = false +freeze_volatiles = false + +[params.stop.radeqm] +enabled = false +atol = 1.0 +rtol = 0.001 + +[params.stop.escape] +enabled = true +p_stop = 5.0 + +[params.stop.disint] +enabled = false +roche_enabled = true +offset_roche = 0.0 +spin_enabled = true +offset_spin = 0.0 + +[params.stop.clock] +enabled = true +maximum = 604800.0 + +[star] +module = "mors" +mass = 0.606 +age_ini = 0.1 +bol_scale = 1.0 + +[star.mors] +age_now = 5.465 +star_name = "hd85512" +star_path = "none" +rot_pcntle = 50.0 +rot_period = "none" +tracks = "spada" +spectrum_source = "muscles" +phoenix_FeH = 0.0 +phoenix_alpha = 0.0 +phoenix_radius = "none" +phoenix_log_g = "none" +phoenix_Teff = "none" + +[star.dummy] +Teff = 5772.0 +radius = "none" +calculate_radius = false + +[orbit] +module = "none" +semimajoraxis = 0.0563 +eccentricity = 0.0 +zenith_angle = 54.74 +s0_factor = 0.25 +evolve = false +axial_period = "none" +satellite = false +mass_sat = 7.347e+22 +semimajoraxis_sat = 300000000.0 +instellation_method = "distance" +instellationflux = 1.0 + +[orbit.dummy] +H_tide = 0.0 +Phi_tide = "<0.3" +Imk2 = 0.0 + +[orbit.lovepy] +visc_thresh = 1000000000.0 +ncalc = 1000 + +[planet] +mass_tot = 3.02 +temperature_mode = "liquidus_super" +tsurf_init = 3000.0 +tcmb_init = 4500.0 +tcenter_init = 4500.0 +f_accretion = 0.04 +f_differentiation = 0.5 +ini_entropy = 3900.0 +ini_dsdr = -4.698e-06 +delta_T_super = 500.0 +volatile_mode = "elements" +volatile_reservoir = "mantle" +fO2_source = "user_constant" +R_int_override = "none" +prevent_warming = false + +[planet.elements] +H_mode = "ppmw" +H_budget = 12559.43215754791 +C_mode = "C/H" +C_budget = 0.1 +N_mode = "N/H" +N_budget = 0.001 +S_mode = "S/H" +S_budget = 0.001 +O_mode = "ic_chemistry" +O_budget = 0.0 +use_metallicity = false +metallicity = 1000.0 +He_mode = "kg" +He_budget = 0.0 +Ne_mode = "kg" +Ne_budget = 0.0 +Ar_mode = "kg" +Ar_budget = 0.0 +Kr_mode = "kg" +Kr_budget = 0.0 +Xe_mode = "kg" +Xe_budget = 0.0 + +[planet.gas_prs] +H2O = 0.0 +CO2 = 0.0 +N2 = 0.0 +S2 = 0.0 +SO2 = 0.0 +H2S = 0.0 +NH3 = 0.0 +H2 = 0.0 +CH4 = 0.0 +CO = 0.0 + +[interior_struct] +core_frac = 0.215 +core_frac_mode = "mass" +module = "zalmoxis" +core_density = "self" +core_heatcap = "self" +melting_dir = "none" +eos_dir = "none" + +[interior_struct.zalmoxis] +core_eos = "PALEOS:iron" +mantle_eos = "PALEOS:MgSiO3" +ice_layer_eos = "none" +mushy_zone_factor = 0.8 +mantle_mass_fraction = 0.0 +num_levels = 150 +solver_tol_outer = 0.003 +solver_tol_inner = 0.0001 +solver_max_iter_outer = 100 +solver_max_iter_inner = 100 +update_interval = 0.0 +update_min_interval = 0.0 +update_dtmagma_frac = 0.05 +update_dphi_abs = 0.05 +update_dw_comp_abs = 0.05 +update_stale_ceiling = 25000.0 +mesh_max_shift = 0.05 +mesh_convergence_interval = 10.0 +equilibrate_init = true +equilibrate_max_iter = 15 +equilibrate_tol = 0.01 +dry_mantle = true +lookup_nP = 1350 +lookup_nS = 280 +global_miscibility = false +miscibility_max_iter = 10 +miscibility_tol = 0.01 +use_jax = true +use_anderson = false +outer_solver = "newton" +newton_max_iter = 30 +newton_tol = 0.0001 +newton_relative_tolerance = 1e-09 +newton_absolute_tolerance = 1e-10 + +[interior_energetics] +module = "aragog" +num_levels = 80 +rtol = 1e-10 +atol = 1e-10 +num_tolerance = -1.0 +trans_conduction = true +trans_convection = true +trans_grav_sep = true +trans_mixing = true +heat_radiogenic = true +heat_tidal = false +mixing_length = "nearest" +grain_size = 0.1 +flux_guess = -1.0 +tmagma_atol = 20.0 +tmagma_rtol = 0.02 +radio_tref = 4.567 +radio_Al = 0.0 +radio_Fe = 0.0 +radio_K = 310.0 +radio_U = 0.031 +radio_Th = 0.124 +rfront_loc = 0.5 +rfront_wid = 0.2 +kappah_floor = 10.0 +param_utbl = false +param_utbl_const = 1e-07 +surface_bc_mode = "flux" +adams_williamson_rhos = 4078.95095544 +adams_williamson_beta = 1.1115348931000002e-07 +adiabatic_bulk_modulus = 260000000000.0 +melt_log10visc = 2.0 +solid_log10visc = 22.0 +melt_cond = 4.0 +solid_cond = 4.0 +eddy_diffusivity_thermal = 1.0 +eddy_diffusivity_chemical = 1.0 +const_properties = false +const_rho = 4000.0 +const_Cp = 1000.0 +const_alpha = 1e-05 +const_cond = 4.0 +const_log10visc = 2.0 +const_T_ref = 3500.0 +const_S_ref = 3000.0 +latent_heat_of_fusion = 4000000.0 +phase_transition_width = 0.1 +core_tfac_avg = 1.147 +write_flux_diagnostics = false + +[interior_energetics.spider] +solver_type = "bdf" +tolerance_rel = -1.0 +matprop_smooth_width = 0.3 +tolerance_struct = 100.0 +log_output = true + +[interior_energetics.aragog] +mass_coordinates = true +backend = "jax" +atol_temperature_equivalent = 1e-08 +core_bc = "energy_balance" +phase_smoothing = "tanh" +solver_method = "cvode" +scalar_gravity_override = false +phi_step_cap = -1.0 +temperature_step_cap = -1.0 +entropy_step_cap = -1.0 +phase_boundary_entropy_margin = 0.001 +tolerance_struct = 100.0 + +[interior_energetics.dummy] +mantle_tliq = 2700.0 +mantle_tsol = 1700.0 +mantle_rho = 4550.0 +mantle_cp = 1792.0 +heat_internal = 0.0 + +[interior_energetics.boundary] +T_solidus = 1420.0 +T_liquidus = 2020.0 +critical_rayleigh_number = 1100.0 +nusselt_exponent = 0.33 +silicate_heat_capacity = 1200.0 +core_density = 10738.0 +atm_heat_capacity_const = true +atm_heat_capacity = 17000.0 +silicate_density = 4103.0 +thermal_conductivity = 4.2 +thermal_diffusivity = 1e-06 +thermal_expansivity = 2e-05 +viscosity_model = 2 +dynamic_viscosity = 3800000000.0 +activation_energy = 350000.0 +creep_parameter = 26.0 +viscosity_prefactor = 0.00024 +viscosity_activation_temp = 4600.0 +logging = false + +[outgas] +module = "calliope" +fO2_shift_IW = -5.0 +mass_thresh = 1e+16 +h2_binodal = false +T_floor = 620.0 +solver_rtol = 0.0001 +solver_atol = 1e-06 + +[outgas.calliope] +include_H2O = true +include_CO2 = true +include_N2 = true +include_S2 = true +include_SO2 = true +include_H2S = true +include_NH3 = true +include_H2 = true +include_CH4 = true +include_CO = true +include_He = false +include_Ne = false +include_Ar = false +include_Kr = false +include_Xe = false +solubility = true +nguess = 1000 +nsolve = 3000 +p_guess_max = 100000.0 + +[outgas.atmodeller] +solver_mode = "robust" +solver_max_steps = 1024 +solver_multistart = 10 +include_condensates = true +solubility_H2O = "H2O_peridotite_sossi23" +solubility_CO2 = "CO2_basalt_dixon95" +solubility_H2 = "H2_basalt_hirschmann12" +solubility_N2 = "N2_basalt_dasgupta22" +solubility_S2 = "S2_sulfide_basalt_boulliung23" +solubility_CO = "CO_basalt_yoshioka19" +solubility_CH4 = "CH4_basalt_ardia13" +eos_H2O = "none" +eos_CO2 = "none" +eos_H2 = "none" +eos_CH4 = "none" +eos_CO = "none" + +[atmos_clim] +module = "agni" +spectral_group = "Honeyside" +spectral_bands = "48" +num_levels = 45 +p_top = 1e-05 +p_obs = 0.02 +overlap_method = "ee" +surf_state = "skin" +surface_d = 0.01 +surface_k = 2.0 +aerosols_enabled = false +cloud_enabled = false +cloud_alpha = 0.0 +surf_greyalbedo = 0.2 +albedo_pl = 0.0 +rayleigh = true +tmp_minimum = 0.5 + +[atmos_clim.agni] +verbosity = 1 +surf_material = "greybody" +chemistry = "none" +solve_energy = true +solution_atol = 0.1 +solution_rtol = 0.08 +surf_roughness = 0.001 +surf_windspeed = 2.0 +phs_timescale = 1000000.0 +evap_efficiency = 0.01 +rainout = false +oceans = true +latent_heat = false +convection = true +conduction = false +sens_heat = true +real_gas = false +thermo_functions = true +psurf_thresh = 0.1 +dx_max = 35.0 +dx_max_ini = 300.0 +max_steps = 70 +perturb_all = true +mlt_criterion = "s" +fastchem_floor = 150.0 +fastchem_maxiter_chem = 60000 +fastchem_maxiter_solv = 20000 +fastchem_xtol_chem = 0.0001 +fastchem_xtol_elem = 0.0001 +ini_profile = "isothermal" +ls_default = 2 +fdo = 2 +check_safe_gas = true +spectral_file = "none" +grey_opacity_lw = 10.0 +grey_opacity_sw = 0.0001 + +[atmos_clim.janus] +F_atm_bc = 0 +tropopause = "none" +cloud_alpha = 0.0 +tmp_maximum = 5000.0 + +[atmos_clim.dummy] +gamma = 0.5 +height_factor = 3.0 +fixed_flux = -1.0 + +[atmos_chem] +module = "none" +when = "manually" +photo_on = true +Kzz_on = true +Kzz_const = "none" +moldiff_on = true +updraft_const = 0.0 + +[atmos_chem.vulcan] +clip_fl = 1e-20 +clip_vmr = 1e-10 +make_funs = true +ini_mix = "profile" +fix_surf = false +network = "SNCHO" +save_frames = false +yconv_cri = 0.05 +slope_cri = 0.0001 + +[escape] +module = "zephyrus" +reservoir = "outgas" + +[escape.zephyrus] +Pxuv = 0.001 +efficiency = 0.1 +tidal = false + +[escape.dummy] +rate = 0.0 + +[escape.boreas] +fractionate = true +efficiency = 0.1 +sigma_H = 1.89e-18 +sigma_O = 2e-18 +sigma_C = 2.5e-18 +sigma_N = 3e-18 +sigma_S = 6e-18 +kappa_H2 = 0.01 +kappa_H2O = 1.0 +kappa_O2 = 1.0 +kappa_CO2 = 1.0 +kappa_CO = 1.0 +kappa_CH4 = 1.0 +kappa_N2 = 1.0 +kappa_NH3 = 1.0 +kappa_H2S = 1.0 +kappa_SO2 = 1.0 +kappa_S2 = 1.0 + +[accretion] +module = "none" + +[observe] +module = "none" +clip_vmr = 1e-08 +reference_pressure = 10.0 +source = "all" +spectrum_type = "both" +remove_one_gas = true + +[observe.petitRADTRANS] +line_opacity_mode = "c-k" +include_rayleigh = true +include_cia = true +silent = true diff --git a/mkdocs.yml b/mkdocs.yml index de78b9c7f..2e60d9e00 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 map: Reference/module_map.md @@ -77,6 +78,9 @@ nav: - Interior energetics wrapper: Reference/api/interior_energetics_wrapper.md - Validation: - Overview: Validation/index.md + - Accretion: + - Analytical accretion (dummy.py): Validation/accretion/dummy.md + - Impact atmosphere-loss dispatch (wrapper.py): Validation/accretion/wrapper.md - Atmosphere climate: - Transparent black-body limit (agni.py): Validation/atmos_clim/agni.md - Interior structure: @@ -113,6 +117,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/pyproject.toml b/pyproject.toml index ed15fd8c7..af8d409f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,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.09.09", # 26.07.17 reads the liquid EOS table for fully molten entropy # lookups, which the super-liquidus interior initial condition @@ -101,14 +101,14 @@ documentation = "https://proteus-framework.org/proteus/" changelog = "https://github.com/FormingWorlds/PROTEUS/releases" [project.optional-dependencies] -# Optional physics backends. These are not pulled in by a default +# Optional physics backends. None of these are pulled in by a default # `pip install fwl-proteus`; a standard run uses CALLIOPE (outgassing) -# and needs neither. Install on demand, e.g. `pip install -# "fwl-proteus[atmodeller]"`. Both are licensed GPL-3.0; review their -# terms before installing. (PROTEUS's own code is Apache-2.0, but the -# default install already includes other GPL dependencies such as -# fwl-aragog, so opting out of these two does not yield a GPL-free -# environment.) +# and needs none of them. Install on demand, e.g. `pip install +# "fwl-proteus[atmodeller]"`. atmodeller and vulcan are licensed +# GPL-3.0; review their terms before installing. (PROTEUS's own code is +# Apache-2.0, but the default install already includes other GPL +# dependencies such as fwl-aragog, so opting out of those two does not +# yield a GPL-free environment.) morrigan is Apache-2.0. # # atmodeller: alternative outgassing backend (Bower+2025, ApJ 995:59), # selected via outgas.module = "atmodeller". Pulls JAX + equinox. @@ -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.27"] # inference: the Bayesian-optimisation parameter inference run by # `proteus infer`. PyTorch and the two BoTorch/GPyTorch layers on top of @@ -306,6 +311,12 @@ ref = "c9a3fd4301c7008291d4f4921506d36b6288f8ca" url = "https://github.com/ExoInteriors/BOREAS.git" ref = "0174edb04558a92a8f0b47cbd994964787f495aa" +# 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 # Pkg.add(url=..., rev=...) by tools/get_lovepy.sh. Optional. 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..565423b9e --- /dev/null +++ b/src/proteus/accretion/common.py @@ -0,0 +1,434 @@ +# 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_before', + '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 + +# 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: + """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_before: float + Eccentricity of the target immediately before the impact [1]. + 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_before: 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 + + @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. + + 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}' + ) + + 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( + 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 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 gains on, or falls too far + below, the previous merged mass. + """ + 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. 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 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 + + +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_before=float(row['e_before']), + 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 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. + + 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..6ea83e557 --- /dev/null +++ b/src/proteus/accretion/dummy.py @@ -0,0 +1,339 @@ +# 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__) + +# 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 + +# 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]. + + 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. + + 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 + 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, + ) + 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]: + """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. 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: + 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: ' + 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 + 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, + e_target: float, + e_impactor: 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 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 + 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]. + + 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). 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], + 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 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) + + 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 + 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.' + ) + + # 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) + + a_after, e_after, v_encounter = _merged_orbit( + m_target, m_impactor, a_target, e_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_before=e_target, + e_after=e_after, + id_target=0, + id_impactor=index + 1, + ) + ) + + m_target = m_merged + a_target = a_after + e_target = e_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 new file mode 100644 index 000000000..5720ebfd5 --- /dev/null +++ b/src/proteus/accretion/morrigan.py @@ -0,0 +1,259 @@ +# 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 TIMELINE_COLUMNS, 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' + +# 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]" ' + '(or bash tools/get_morrigan.sh for an editable checkout).' +) + + +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. + ValueError + If the model's outcome or its impact records do not carry the fields + the coupling requires. + """ + 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) + + 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 + # 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) + + log.info('Body %s experienced %d impacts', chosen['id'], len(events)) + return events diff --git a/src/proteus/accretion/timeline.py b/src/proteus/accretion/timeline.py new file mode 100644 index 000000000..4000c9f18 --- /dev/null +++ b/src/proteus/accretion/timeline.py @@ -0,0 +1,37 @@ +# Impact timeline replayed from file +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 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 + ---------- + config : Config + Model configuration. + + Returns + ------- + events : list of ImpactEvent + Impacts to apply during the run, in time order. + """ + 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 new file mode 100644 index 000000000..5db574065 --- /dev/null +++ b/src/proteus/accretion/wrapper.py @@ -0,0 +1,907 @@ +# Generic accretion wrapper +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING + +from proteus.utils.constants import AU, M_earth, element_list, noble_gases, vol_element_list + +if TYPE_CHECKING: + from proteus.accretion.common import ImpactEvent + from proteus.proteus import Proteus + +log = logging.getLogger('fwl.' + __name__) + +# 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 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 +# 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' + +# 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. + + 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') + + # 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 '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 _GUARANTEED_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('') + + 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), resumed=bool(config.params.resume) + ) + + +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 + + # 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 + + 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 + + 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 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. + + 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 remelt_mantle, 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, + ) + + # 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', + ) + 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, f_loss) + + # 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 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 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. + 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 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()):.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 + # 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'] + ) + + # 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) + + # Apply the sized consequences to the whole-planet budgets and refresh + # the tracked-element total the budgets aggregate into. + _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. + 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') + + # 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 + # 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 + 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 + hf_row['semimajorax'] *= ratio + hf_row['eccentricity'] = eccentricity + + log.info( + ' planet is now %.4f M_earth at %.5f AU, e = %.4f', + config.planet.mass_tot, + config.orbit.semimajoraxis, + config.orbit.eccentricity, + ) + + +def _apply_volatile_consequences( + 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. + + 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 + ---------- + 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(): + hf_row[f'{e}_kg_total'] = max(0.0, float(hf_row.get(f'{e}_kg_total', 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 * f_loss, + stripped_total, + ) + for e, added in delivered.items(): + hf_row[f'{e}_kg_total'] = float(hf_row.get(f'{e}_kg_total', 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 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: + """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, 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. 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 or f_loss <= 0.0: + 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 * f_loss + 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, 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: 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 + ---------- + 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`. + """ + 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 {} + + strip = {} + 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 + + +# 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; 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 + ---------- + config : Config + Model configuration; reads ``accretion.atmloss_module`` and + ``accretion.atmloss_frac``. + hf_row : dict + Current helpfile row (the planet state the domain check reads). + event : ImpactEvent + The impact being applied (the collision parameters the 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. + ImportError + If the zephyrus module is selected but the installed fwl-zephyrus + does not provide the collision law. + """ + atmloss_module = config.accretion.atmloss_module + if atmloss_module is None: + return 0.0 + + match atmloss_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: '{atmloss_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 '{atmloss_module}'" + ) + return f_loss + + +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 _drop_events_before_start( + events: list[ImpactEvent], time_start: float, resumed: bool = False +) -> list[ImpactEvent]: + """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. + + 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 + ---------- + events : list of ImpactEvent + 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 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) + 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: + 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 ca9585709..982b05433 100644 --- a/src/proteus/config/_accretion.py +++ b/src/proteus/config/_accretion.py @@ -1,19 +1,410 @@ from __future__ import annotations -from attr.validators import in_ +from attr.validators import ge, gt, in_, le, lt from attrs import define, field from ._converters import none_if_none +SELECTORS = ('match_config', 'mass', 'semimajoraxis', 'id') + + +def valid_morrigan(instance, attribute, value): + """The Morrigan module requires as many embryo masses as planets, all positive, and a selector value when the selector is a semi-major axis or planet id.""" + 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. + 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 + 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. + """ + + 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), le(50.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) + + +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): + """The timeline accretion module requires a path to an impact timeline file.""" + if instance.module != 'timeline': + return + + if instance.timeline.timeline_path is None: + raise ValueError( + '`accretion.timeline.timeline_path` must point at an impact timeline file ' + "when accretion.module = 'timeline'" + ) + + +@define +class AccretionTimeline: + """Impact timeline replayed from a file. + + 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 + ---------- + 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 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. + 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)) + 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': + 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.' + ) + + +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. + + 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 + 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: - """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 + 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. + + 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. 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 ---------- module: str or None - Accretion module to use. Currently only None is supported. + Accretion module to use. Choices: None, "dummy", "timeline", "morrigan". + morrigan: Morrigan + Parameters for the Morrigan giant-impact module. + dummy: AccretionDummy + 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 + disk dispersal, while PROTEUS measures it from the start of its + 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 + 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; 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 + 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]. + atmloss_module: str or None + 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 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 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(default='none', validator=in_((None,)), converter=none_if_none) + module: str | None = field( + default='none', + 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) + timeline: AccretionTimeline = field( + factory=AccretionTimeline, validator=valid_accretiontimeline + ) + + time_offset: float = field(default=0.0) + + # 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)) + # 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, valid_impactor_budget_total], + ) + + # 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', 'zephyrus')), + converter=none_if_none, + ) + atmloss_frac: float = field(default=0.0, validator=[ge(0), le(1)]) + + @property + def delivers_volatiles(self) -> bool: + """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/src/proteus/config/_config.py b/src/proteus/config/_config.py index d51b577ee..ffb180a57 100644 --- a/src/proteus/config/_config.py +++ b/src/proteus/config/_config.py @@ -105,6 +105,15 @@ 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 optional morrigan package, ' + 'which runs the giant-impact model. Morrigan is not needed for a ' + 'standard PROTEUS run. Install it with: pip install ' + '"fwl-proteus[morrigan]" (or bash tools/get_morrigan.sh for an ' + 'editable checkout).', + ), } for name, (needed, pkg, msg) in checks.items(): @@ -115,6 +124,74 @@ def check_module_dependencies(instance, attribute, value): raise ImportError(f'{msg}\n Original error: {e}') from e +# 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' + ), +} + + +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 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 = '" + + 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)." + ) + + +def check_accretion_vapourise_compatibility(instance, attribute, value): + """Reject accretion runs that also vapourise rock into the atmosphere. + + The two models keep incompatible books on the rock-forming elements. Rock + vapourisation moves rock mass into the atmosphere without debiting it from + the interior, which is why those elements are deliberately left out of + ``M_ele`` and the whole-planet mass. An impact, in contrast, grows the + planet's rock through the structure solve and conserves the per-element + volatile budgets across that growth, and it sizes both the atmosphere it + strips and the volatiles the impactor delivers from those same per-element + columns. Run together, the vapour column would be stripped and delivered as + though it were a tracked volatile budget while the mass it stands for is + accounted nowhere, so the combination is refused at configuration load. + """ + if instance.accretion.module is not None and instance.outgas.vapourise: + raise ValueError( + "accretion.module = '" + + str(instance.accretion.module) + + "' cannot run with outgas.vapourise = true: rock vapour adds " + 'rock-forming mass to the atmosphere that the whole-planet mass does ' + 'not track, and an impact sizes its atmospheric stripping and volatile ' + 'delivery from budgets that do. Disable one of the two.' + ) + + 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'): @@ -346,7 +423,12 @@ 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, + check_accretion_vapourise_compatibility, + ), ) def write(self, out: str, overrides: dict | None = None): diff --git a/src/proteus/config/_params.py b/src/proteus/config/_params.py index aa66617ba..e803dc219 100644 --- a/src/proteus/config/_params.py +++ b/src/proteus/config/_params.py @@ -146,6 +146,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)) @@ -175,6 +185,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/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/escape/wrapper.py b/src/proteus/escape/wrapper.py index 582fb8bfd..e4577ae71 100644 --- a/src/proteus/escape/wrapper.py +++ b/src/proteus/escape/wrapper.py @@ -543,6 +543,7 @@ def calc_new_elements( res[e] = float(hf_row.get(f'{e}{key}', 0.0)) M_vols = float(sum(res.values())) + # Nothing to share out, either because the reservoir is spent or because an # upstream failure left it unreadable. Return the totals unchanged: `res` # holds the reservoir the loss is sized from, which is `*_kg_atm` for diff --git a/src/proteus/interior_energetics/aragog.py b/src/proteus/interior_energetics/aragog.py index 4803e36bd..b3ffa619f 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 @@ -68,13 +69,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) @@ -139,6 +150,44 @@ def _cached_entropy_eos_jax(eos_dir_str: str): _RHO_CORE_MAX = 30000.0 +# 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 + + +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 + + def _resolve_step_cap(cap: float) -> float: """Map a configured per-call step cap to the value Aragog receives. @@ -383,6 +432,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, @@ -539,6 +600,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: @@ -1138,6 +1203,34 @@ 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. ``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 + ---------- + 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). @@ -1195,7 +1288,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( @@ -1205,14 +1300,10 @@ def _maybe_install_jax_cvode_factory(config: Config, interior_o: Interior_t) -> params_jax = build_jax_phase_params(config) - _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): @@ -1221,6 +1312,16 @@ 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: + # 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: 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. # Rebuild BoundaryParams from live solver state every @@ -1292,17 +1393,27 @@ def factory(scales, core_bc_mode): ) return rhs_fn, jac_fn - solver.set_jax_cvode_factory(factory) - log.info( + # 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() + installed = ( 'Option Z: JAX CVODE factory installed on aragog solver ' - '(core_bc=%s, n_stag=%d).', - solver._core_bc, - n_stag, + 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 + # 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) @staticmethod @@ -1402,6 +1513,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( @@ -2032,6 +2147,15 @@ 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)) + + # 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. @@ -2118,20 +2242,31 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: float(hf_row.get('Time', 0.0)), ) - # Status check: did the solver accept the step? - if out.status == 0: + # Status check: did the solver accept the step? Status 0 is a + # full step. Status 1 is a terminal event (melt-fraction cap or + # liquidus crossing) with valid partial state; the scipy + # fallback reports it while CVODE maps the same cap to status 0. + # Accept it when it advanced (dt_actual > 0): retrying refires + # the event at the same place, and a zero-advance step would + # stall the loop, so only that case is rejected. + stopped_on_event = out.status == 1 and float(out.dt_actual) > 0.0 + if out.status == 0 or stopped_on_event: # Post-solve sanity guard on the CMB temperature. It must # pass to accept the step; a trip falls through to the retry # ladder. sanity_reject_reason names the trip for the # exhaustion message. sanity_reject_reason = None - # Reject a status=0 solve with a non-finite or implausibly - # large CMB temperature. The finiteness check always runs, - # so a corrupted relaxed-rtol solve never passes even on the - # first solve. The jump-magnitude check needs a pre-solve - # reference, so it is inactive when T_core_pre <= 0 (a row - # missing both T_cmb and T_core, which includes solve one). + # Reject a solve with a non-finite or implausibly large + # CMB temperature jump. The finiteness check always runs, + # so a corrupted relaxed-rtol solve never passes even on + # the first solve. The jump-magnitude check needs a + # pre-solve reference, so it is inactive when T_core_pre + # <= 0 (a row missing both T_cmb and T_core, which + # includes solve one). A giant impact re-melts the mantle + # between solves, so the jump it produces is real and + # identical at every step size; retrying cannot shrink + # it, so the magnitude check is skipped on that step. tcore_endpoint = float(out.T_core) # tcore_change_max is the intra-solve maximum change, # >= the endpoint change by construction; on an older @@ -2143,25 +2278,56 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: if not finite_ok: sanity_reject_reason = 'T_core is non-finite' elif T_core_pre > 0: - if tcore_change_max is not None: - dT = float(tcore_change_max) - else: - dT = abs(tcore_endpoint - T_core_pre) - if dT > sanity_dT_core: + dT = ( + float(tcore_change_max) + if tcore_change_max is not None + else abs(tcore_endpoint - T_core_pre) + ) + 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, + ) + 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)' ) if sanity_reject_reason is not None: log.warning( - 'Aragog attempt %d returned status=0 but %s. ' + 'Aragog attempt %d (status=%d) rejected: %s. ' 'Treating as failure and continuing retry ladder.', attempt, + out.status, sanity_reject_reason, ) # 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 ' @@ -2197,6 +2363,22 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: # the guard that tripped rather than the misleading status=0. if out.status == 0: reason = f'status=0 but {sanity_reject_reason} 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 ' + f'{sanity_reject_reason} 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'{self._active_solver_name()} status={out.status}' if flag_name: @@ -2313,6 +2495,116 @@ def _solve_with_retry(self, hf_row, interior_o) -> SolverOutput: return out + 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 + 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. ' + + 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 def _build_helpfile_output( out: SolverOutput, @@ -2469,6 +2761,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 @@ -2551,6 +2849,63 @@ 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. + """ + # Compared against the stems on disk, so it has to be derived the way the + # writer derives them: rounded, not truncated. + cutoff = int(round(float(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', '%.0f_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', '%.0f_int.nc' % time) diff --git a/src/proteus/interior_energetics/common.py b/src/proteus/interior_energetics/common.py index 00091a3af..12eb0c0b6 100644 --- a/src/proteus/interior_energetics/common.py +++ b/src/proteus/interior_energetics/common.py @@ -543,6 +543,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 @@ -570,6 +579,25 @@ 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') + + # 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 + + # ``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/dummy.py b/src/proteus/interior_energetics/dummy.py index b4108f8ec..6ee826109 100644 --- a/src/proteus/interior_energetics/dummy.py +++ b/src/proteus/interior_energetics/dummy.py @@ -18,6 +18,85 @@ 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 scalar-backend mantle at a surface temperature. + + Linear between the active backend's 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]. + """ + tsol, tliq = _solidus_liquidus(config) + 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 +148,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 +195,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/timestep.py b/src/proteus/interior_energetics/timestep.py index 7913eec77..67a0566ee 100644 --- a/src/proteus/interior_energetics/timestep.py +++ b/src/proteus/interior_energetics/timestep.py @@ -445,5 +445,26 @@ def next_step( ) dtswitch = dt_capped + # 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: + 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/interior_energetics/wrapper.py b/src/proteus/interior_energetics/wrapper.py index ce1036a0d..41d80431a 100644 --- a/src/proteus/interior_energetics/wrapper.py +++ b/src/proteus/interior_energetics/wrapper.py @@ -86,6 +86,15 @@ # counter resets on each successful Aragog call. _ARAGOG_MAX_CONSECUTIVE_FAILS = 3 +# 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, @@ -1797,6 +1806,270 @@ 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, 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, 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 + 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 + + # 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 ' + '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. + + 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 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 + + 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.' + ) + + 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 + # 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) + S_molten = np.asarray(S_molten, dtype=float).ravel() + + # 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)) + + # 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 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 + # 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' + ) + + 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 + + # 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) + 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 + + # 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 + * event.M_impactor + / (event.M_target_before + event.M_impactor) + ) + e_impact = 0.5 * reduced * event.v_impact**2 + 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( dirs: dict, config: Config, hf_all: pd.DataFrame, hf_row: dict, outdir: str ): @@ -1886,6 +2159,16 @@ 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 + # 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: interior_o.write_tides(dirs['output']) @@ -1944,7 +2227,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: @@ -1955,6 +2238,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( @@ -2047,7 +2339,10 @@ def run_interior( # Update planet mass update_planet_mass(hf_row) - # Apply step limiters + # 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 @@ -2056,23 +2351,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 @@ -2082,13 +2378,13 @@ 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 @@ -3046,17 +3342,15 @@ 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]. 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 @@ -3067,10 +3361,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/src/proteus/proteus.py b/src/proteus/proteus.py index 19a3d19ef..89c6ff54c 100644 --- a/src/proteus/proteus.py +++ b/src/proteus/proteus.py @@ -182,6 +182,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 @@ -375,6 +378,9 @@ 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, restore_accretion_state from proteus.atmos_chem.wrapper import run_chemistry # atmosphere solver @@ -751,16 +757,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() ) @@ -877,6 +899,15 @@ 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) + + # 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. # Initialised to -inf so the first eligible iteration always writes. self.last_write_time = -np.inf @@ -912,6 +943,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') @@ -934,6 +973,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( @@ -994,6 +1038,32 @@ 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, + discard_preimpact_snapshot, + ) + + time_now = self.hf_row['Time'] + time_previous = time_now - self.interior_o.dt + 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 @@ -1152,7 +1222,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 @@ -1422,9 +1501,12 @@ def start(self, *, resume: bool = False, offline: bool = False): WriteHelpfileToCSV(self.directories['output'], self.hf_all) # Ensure the final interior state is on disk so resume can find it. + # 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/src/proteus/utils/coupler.py b/src/proteus/utils/coupler.py index a8db800e7..907db1c01 100644 --- a/src/proteus/utils/coupler.py +++ b/src/proteus/utils/coupler.py @@ -496,8 +496,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 @@ -757,6 +759,19 @@ def CreateLockFile(output_dir: str): return keepalive_file +# Schema columns a resumed run may read as zero when its helpfile predates them. Zero +# 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', + 'M_accreted_rock', + 'step_dE_impact_J', + } +) + + def GetHelpfileKeys(): """ Variables to be held in the helpfile. @@ -856,10 +871,20 @@ 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 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 @@ -904,6 +929,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] @@ -963,13 +989,15 @@ def GetHelpfileKeys(): 'O_res', # O mass-balance residual [kg] 'O_vapourised_kg', # oxygen released by rock vapourisation (LavAtmos) [kg] - # Desiccation escape balance. Read by `check_desiccation`. - # M_vol_initial is the summed *_kg_total (oxygen included) captured on - # the first escape call; esc_kg_cumulative is the mass each step took - # out of those inventories, never more than it was allowed to remove. - # Both persist to the CSV so a resume keeps the check's state. + # Desiccation escape-balance gate, read by `check_desiccation`. + # M_vol_initial is the sum over all elements (oxygen included) of + # *_kg_total captured on the first escape call, the reference point for + # the "is the loss accounted for by escape?" check. esc_kg_cumulative is + # the whole-run atmospheric-loss ledger: continuous escape plus the mass + # each giant impact strips. Both persist to the CSV so a resume keeps 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) # Loss the bulk rate asked for on this step, as a fraction of the # reservoir escape draws from. Values above the per-step cap mark a @@ -977,7 +1005,15 @@ def GetHelpfileKeys(): # distinguishable from one that ran down on its own. 'esc_clamp_frac', # requested per-step loss / escapable reservoir [1] 'esc_step_kg', # loss applied on this step, after the cap [kg] - ] + + # 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] + ] # gases from outgassing for s in gas_list: @@ -1076,6 +1112,21 @@ 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, 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 frozen-mass ``step_dE_Q_*_cons_J`` variants are not summed here. @@ -1140,15 +1191,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) @@ -1405,23 +1463,31 @@ def ReadHelpfileFromCSV(output_dir: str, *, required_columns: list[str] | None = """ Read helpfile from disk CSV file to DataFrame - A helpfile written before the output schema gained a column carries - neither that column nor any value for it. The entry points that turn a - stored run back into simulation state, resume and the two postprocessing - commands, all seed a working row from the last line of this table, so - the shortfall is caught here rather than in each of them. How much of - the schema a caller needs differs, which is what `required_columns` is - for. Readers that pull named columns straight out of the file, such as - the plotting and inference code, do not come through this function and - are not covered. - - The shortfall is reported rather than filled. Seeding a value would make - the key present, and several modules decide what to do by testing whether - a key is there at all: CALLIOPE refuses a run whose oxygen budget is - absent, the dummy and boundary interiors fall back to a configured core - size, and the atmosphere lower boundary moves to the solvus only when a - solvus radius exists. A seeded zero turns each of those off and reaches - the solvers as a physical value no solver produced. + A run started under an earlier schema writes a helpfile without the columns + added since. Resume and the two postprocessing commands all seed a working + row from the last line of this table, and ``ExtendHelpfile`` rejects a row + missing any schema key, so the shortfall is handled here rather than in each + caller. How much of the schema a caller needs differs, which is what + ``required_columns`` sets. Readers that pull named columns straight out of + 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. A column in + ``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. 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 ---------- @@ -1437,12 +1503,13 @@ def ReadHelpfileFromCSV(output_dir: str, *, required_columns: list[str] | None = Returns ------- pandas.DataFrame - Helpfile contents as stored, carrying at least ``required_columns``. + Helpfile contents, carrying at least ``required_columns``; any absent + ``RESUMABLE_ZERO_FILL_KEYS`` column is present and zero. Raises ------ HelpfileSchemaDriftError - The file does not carry every required column. + A required column that carries physical state is absent from the file. """ if required_columns is None: required_columns = GetHelpfileKeys() @@ -1453,13 +1520,35 @@ def ReadHelpfileFromCSV(output_dir: str, *, required_columns: list[str] | None = hf_all = pd.read_csv(fpath, sep=r'\s+') missing = sorted(set(required_columns) - set(hf_all.columns)) - if missing: + if not missing: + return hf_all + + fillable = [key for key in missing if key in RESUMABLE_ZERO_FILL_KEYS] + unfillable = sorted(set(missing) - set(fillable)) + + if unfillable: raise HelpfileSchemaDriftError( "Helpfile '%s' was written before %d column(s) of the current output " - 'schema existed: %s. Run this configuration again from t=0, or read ' - 'this run with the PROTEUS version that wrote it.' - % (fpath, len(missing), _describe_missing_columns(missing)) + 'schema existed that carry physical state and cannot be reconstructed: ' + '%s. Run this configuration again from t=0, or read this run with the ' + 'PROTEUS version that wrote it.' + % (fpath, len(unfillable), _describe_missing_columns(unfillable)) ) + + 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 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)), + ) + # Added in one concat rather than one insert per column, which would + # fragment the frame and warn on a schema several columns behind. + hf_all = pd.concat( + [hf_all, pd.DataFrame(0.0, index=hf_all.index, columns=fillable)], axis=1 + ) + return hf_all @@ -1507,6 +1596,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. @@ -1561,6 +1738,23 @@ def select_resumable_snapshot( can never back a resume and would otherwise be swept into the final data archive. + Each half is probed with its writer's filename convention. The interior + name depends on the module: Aragog writes ``'%.0f_int.nc'``, SPIDER writes + ``'%.0f.json'``, and the dummy and boundary interiors write no snapshot at + all (no interior constraint). Every atmosphere writer shares one name, + ``'%.0f_atm.nc'``. 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 @@ -1595,6 +1789,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 = [ @@ -1605,15 +1800,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/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..41beb649f --- /dev/null +++ b/tests/accretion/test_common.py @@ -0,0 +1,533 @@ +"""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 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 +physics validity. +""" + +from __future__ import annotations + +import re + +import numpy as np +import pytest + +from proteus.accretion.common import ( + MASS_CLOSURE_RTOL, + MAX_INTERIMPACT_MASS_LOSS_FRAC, + 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_before=0.02, + 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') 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 +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)]) + + # 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 +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 +@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. + + 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.03, + 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.02, + 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.02, + 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..3417327e9 --- /dev/null +++ b/tests/accretion/test_dummy.py @@ -0,0 +1,475 @@ +"""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 +import re +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, + orbit_eccentricity=0.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, eccentricity=orbit_eccentricity), + 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 +@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. + + 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 +@pytest.mark.reference_pinned +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 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. + """ + 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') 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 +@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. + + 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, 0.0, 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 + + +@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_morrigan.py b/tests/accretion/test_morrigan.py new file mode 100644 index 000000000..0cfe0a078 --- /dev/null +++ b/tests/accretion/test_morrigan.py @@ -0,0 +1,450 @@ +"""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 functools import partial +from types import SimpleNamespace + +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)] + + +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() + + # 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'): + 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_before': 0.03, + '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_before': 0.02, + '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) + + +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_before': 0.02, + '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') 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): + """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) 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/accretion/test_timeline.py b/tests/accretion/test_timeline.py new file mode 100644 index 000000000..5aaeb08db --- /dev/null +++ b/tests/accretion/test_timeline.py @@ -0,0 +1,153 @@ +"""Tests for the file-replay accretion module. + +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. + +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.timeline 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.02, + 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.03, + 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='timeline', + time_offset=time_offset, + timeline=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_wrapper.py b/tests/accretion/test_wrapper.py new file mode 100644 index 000000000..cf76c61bf --- /dev/null +++ b/tests/accretion/test_wrapper.py @@ -0,0 +1,2094 @@ +"""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.02, + 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.03, + 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, + interior_module='dummy', + temperature_mode='liquidus_super', + output_dir=None, + resume=False, +): + """Build the minimal Proteus handler shape init_accretion reads.""" + return SimpleNamespace( + config=SimpleNamespace( + accretion=SimpleNamespace( + module=module, + time_offset=time_offset, + 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}, + ) + + +@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='timeline', + timeline_path=_timeline_file(tmp_path / 't.csv'), + output_dir=tmp_path, + ) + + 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='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) + + 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='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='timeline', + timeline_path=path, + time_offset=3.0e5, + time_start=2.0e5, + output_dir=tmp_path, + ) + 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_before=0.02, + e_after=0.05, + id_target=1, + id_impactor=4, + ) + base.update(overrides) + return ImpactEvent(**base) + + +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), + impactor_S_ppmw=ppmw.get('S', 0.0), + impactor_O_ppmw=ppmw.get('O', 0.0), + atmloss_module=atmloss_module, + atmloss_frac=atmloss_frac, + ) + + +def _impact_handler( + mass_tot=1.0, + semimajoraxis=0.5, + eccentricity=0.1, + tsurf_init=4000.0, + crystallized=False, + desiccated=False, + accretion=None, +): + """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, 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), + accretion=accretion if accretion is not None else _impact_accretion(), + ), + 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, + desiccated=desiccated, + 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 + + # 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 +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): + """The orbit change is applied as a jump to both the config and the row. + + 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 + + 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. + # 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) + + 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 + ) + # 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 circularising impact damps the planet's own eccentricity, and stops at zero. + + 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 + + monkeypatch.setattr( + '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_before=0.05, e_after=0.0) + apply_impact(handler, event) + + 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 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 +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) + + +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 +@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 + ) + + +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 + # 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 + + # 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.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, + '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. 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 + 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: 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 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 +@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 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 (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 + + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.solve_structure', + _rescaling_solve_structure(1.2), + ) + + handler = _impact_handler( + mass_tot=1.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)) + 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 + delivered + assert handler.hf_row['H_kg_total'] == pytest.approx(after_first, rel=1e-9) + + # 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 + delivered + assert handler.hf_row['H_kg_total'] == pytest.approx(after_second, 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): + """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. + """ + 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( + 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 + event = _impact_event(M_impactor=m_impactor) + mass_delta = event.mass_delta + apply_impact(handler, event) + + content = m_impactor * 1000.0 / 1.0e6 + stripped = 0.5 * 2.0e20 + # 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: 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) + + # 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( + 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()) + + +@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 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') 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``. + + 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) + + +@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. + """ + from proteus.accretion.wrapper import apply_impact + + 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( + 'proteus.interior_energetics.wrapper.solve_structure', _solve, raising=False + ) + monkeypatch.setattr( + 'proteus.interior_energetics.wrapper.remelt_mantle', lambda *a, **k: None, raising=False + ) + + 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. + + 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 + + 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, '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) + + # 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): + """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. + """ + 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. + """ + 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 + + +@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. + """ + 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. + """ + 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] + + +@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. + """ + 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 + + +@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' + ) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +def test_the_rock_and_volatile_element_sets_partition_the_registry(): + """An impact grows rock through the structure solve and volatiles through + the budgets, and no element may travel by both routes. + + Contract clause: the mass an impact adds arrives as rock, through the + planet's mass anchor and the equation of state. Separately, the impact + conserves the per-element whole-planet budgets across that growth and sizes + both its atmospheric stripping and its volatile delivery from them. An + element counted in both places would have its mass booked twice, once in + the rock and once in a budget, and the whole-planet total would drift + upward on every impact. + + 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 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 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 _VOLATILE_ELEMENTS + from proteus.interior_energetics.wrapper import update_planet_mass + from proteus.utils.constants import ( + element_list, + noble_gases, + vap_element_list, + vol_element_list, + ) + + 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) + + # 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 + # 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 " + 'conserved as a volatile budget across an impact, so its mass is ' + 'counted both in the rock the impact adds and in the budget' + ) + + # 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' + + +@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 new file mode 100644 index 000000000..8e3f64ca2 --- /dev/null +++ b/tests/config/test_accretion.py @@ -0,0 +1,551 @@ +"""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.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 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, AccretionTimeline + + assert Accretion(module='morrigan').module == 'morrigan' + assert Accretion(module='dummy').module == 'dummy' + assert ( + Accretion(module='timeline', timeline=AccretionTimeline(timeline_path='x.csv')).module + == 'timeline' + ) + 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_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, AccretionTimeline + + with pytest.raises(ValueError, match='timeline_path'): + Accretion(module='timeline') + + supplied = Accretion( + module='timeline', timeline=AccretionTimeline(timeline_path='/tmp/t.csv') + ) + assert supplied.timeline.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').timeline.timeline_path is None + assert Accretion().timeline.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(): + """The delivery flag follows the content mode, then the ppmw budgets. + + The flag decides whether the impact handler touches the element + 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(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) + + # 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. + with pytest.raises(ValueError): + 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) + + # 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') + + +@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 find_key_problems + + all_options = PROTEUS_ROOT / 'input' / 'all_options.toml' + with open(all_options, 'rb') as f: + raw = tomllib.load(f) + + assert 'accretion' in raw + orphans, mistyped = find_key_problems(raw) + accretion_orphans = [k for k in orphans if k.startswith('accretion')] + accretion_mistyped = [k for k in mistyped if k.startswith('accretion')] + assert accretion_orphans == [], ( + f'{accretion_orphans} are set in the reference config but absent from the ' + 'schema, so a user copying the file gets settings that do nothing' + ) + assert accretion_mistyped == [], ( + f'{accretion_mistyped} are shaped in the file the way the schema does not ' + 'expect, so the value the run uses is not the one the file states' + ) + + # 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) + ) + + +def _compat_instance( + accretion_module, interior_module, temperature_mode='liquidus_super', vapourise=False +): + """Duck-typed config instance the compatibility validators read.""" + from types import SimpleNamespace + + return SimpleNamespace( + accretion=SimpleNamespace(module=accretion_module), + interior_energetics=SimpleNamespace(module=interior_module), + outgas=SimpleNamespace(vapourise=vapourise), + 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) + + +@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. + + Rock vapour puts rock-forming mass into the atmosphere that the + whole-planet mass deliberately does not track, while an impact sizes the + atmosphere it strips and the volatiles it delivers from budgets that are + tracked. The two accountings are not reconciled, so the pairing must fail + at configuration load rather than produce a mass budget neither model owns. + Each feature on its own is unaffected. + """ + from proteus.config._config import check_accretion_vapourise_compatibility + + # The unreconciled pairing is refused, whichever accretion module drives it. + for module in ('morrigan', 'dummy', 'timeline'): + with pytest.raises(ValueError, match='outgas.vapourise'): + check_accretion_vapourise_compatibility( + _compat_instance(module, 'aragog', vapourise=True), None, None + ) + + # Either feature alone passes, and so does a run using neither. + check_accretion_vapourise_compatibility( + _compat_instance('morrigan', 'aragog', vapourise=False), None, None + ) + check_accretion_vapourise_compatibility( + _compat_instance(None, 'aragog', vapourise=True), None, None + ) + check_accretion_vapourise_compatibility( + _compat_instance(None, 'aragog', vapourise=False), 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 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 + # 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) + + +@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 + + +@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 + # 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', + 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, + ) + + # 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/config/test_config_schema_invariants.py b/tests/config/test_config_schema_invariants.py index f64f3e284..78a98a5c3 100644 --- a/tests/config/test_config_schema_invariants.py +++ b/tests/config/test_config_schema_invariants.py @@ -211,6 +211,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/escape/test_wrapper.py b/tests/escape/test_wrapper.py index 865e32290..489f79857 100644 --- a/tests/escape/test_wrapper.py +++ b/tests/escape/test_wrapper.py @@ -1144,6 +1144,62 @@ def test_run_escape_zephyrus_zeroes_elemental_rates_when_unfract_raises(): 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) + + # ======================================================================================= # SECTION: limit_escape_step(), per-step cap on the mass escape may remove # ======================================================================================= diff --git a/tests/integration/golden_run.tsv b/tests/integration/golden_run.tsv index 8ea52e263..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 @@ -95,6 +95,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 @@ -130,6 +131,7 @@ M_vol_initial series 0.0 0.0 0.0 0.0 0.0 0.0 3.634835485187904e+23 3.63483548518 esc_kg_cumulative series 0.0 0.0 0.0 0.0 0.0 0.0 69704452756075.03 145346477249131.03 227477660455531.03 316706206040054.8 413703076364278.8 519209930913131.3 634046608813896.8 759120821202760.8 895438855319368.8 1044117383312614.5 1206396976307558.5 1383657859915110.5 1577437791722854.5 1789452278062302.5 2021617985548244.2 2276079631232980.0 2555241499619284.0 2861802847015892.0 3177408170841726.0 3493016650720798.0 3808628286684669.5 4124243078764900.5 4439860965103972.5 4755482069511607.0 5071106330130285.0 5386733745928045.0 5702364248733549.0 6017997978174733.0 6333634856322317.0 6649274888586509.0 6964918007858445.0 7280564281246989.0 7596213792690282.0 7911866441420906.0 8227522265885992.0 8543181222849832.0 8858843360462975.0 9174508558551168.0 9490177009438628.0 9805848582868900.0 1.0121523347156946e+16 1.043720116171157e+16 1.0752882197491666e+16 1.1068566432117342e+16 1.1384253823585364e+16 1.1699944321715284e+16 1.2015637973961812e+16 1.2331334780324948e+16 1.2647034799478042e+16 1.2962737914074394e+16 esc_clamp_frac series 0.0 0.0 0.0 0.0 0.0 0.0 1.9176783389543597e-09 2.081031173324544e-09 2.2595586628806232e-09 2.454816621109131e-09 2.6685385369530414e-09 2.9026583210954373e-09 3.1593363312525516e-09 3.440989210526975e-09 3.750324171388447e-09 4.090378476973307e-09 4.464565016006718e-09 4.876725043943699e-09 5.331189378782069e-09 5.832849605532661e-09 5.821617109258438e-09 3.974890263820033e-09 3.1841651297942626e-09 2.7654647411216794e-09 2.362821707530744e-09 2.0438094736931887e-09 1.8229878596538319e-09 1.660637770304236e-09 1.535965810943926e-09 1.437023447391064e-09 1.3564512010685322e-09 1.2894654569545972e-09 1.2328202131573416e-09 1.184233928603169e-09 1.1420551966545348e-09 1.1050586290443243e-09 1.0723153581947622e-09 1.0431081399054674e-09 1.0168740943781458e-09 9.931651192223873e-10 9.71619916185942e-10 9.51943837458921e-10 9.338941117514216e-10 9.172688438384465e-10 9.018987074052706e-10 8.876405908209297e-10 8.743726794950844e-10 8.684792781198004e-10 8.684879636668444e-10 8.684966493007612e-10 8.685053350215477e-10 8.685140208292072e-10 8.685227067237393e-10 8.685313927051456e-10 8.685400787734245e-10 8.685487649285847e-10 esc_step_kg series 0.0 0.0 0.0 0.0 0.0 0.0 69704452756075.03 75642024493056.0 82131183206400.0 89228545584523.77 96996870324224.0 105506854548852.48 114836677900765.45 125074212388864.0 136318034116608.0 148678527993245.75 162279592994944.1 177260883607552.0 193779931807744.0 212014486339447.88 232165707485941.75 254461645684736.0 279161868386304.0 306561347396607.8 315605323825834.06 315608479879072.25 315611635963871.7 315614792080231.2 315617886339072.0 315621104407634.25 315624260618677.75 315627415797760.0 315630502805504.0 315633729441184.25 315636878147584.0 315640032264192.0 315643119271936.0 315646273388544.0 315649511443293.5 315652648730624.0 315655824465086.3 315658956963840.0 315662137613142.56 315665198088192.0 315668450887460.94 315671573430272.0 315674764288045.7 315677814554624.0 315681035780096.0 315684234625676.94 315687391468022.94 315690498129920.0 315693652246528.0 315696806363136.0 315700019153094.4 315703114596352.0 +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 diff --git a/tests/integration/test_slow_accretion_resume.py b/tests/integration/test_slow_accretion_resume.py new file mode 100644 index 000000000..13c26abd0 --- /dev/null +++ b/tests/integration/test_slow_accretion_resume.py @@ -0,0 +1,399 @@ +"""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 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. + +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 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' + +# 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' + +# 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 +# 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 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 + 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 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 + 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=1.0), ( + 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. The 1 yr + # timestep floor (issue #676) can land the step past IMPACT_TIME, so the + # key a snapshot would be filed under is the step's own rounded time, not + # int(IMPACT_TIME). + impact_step_key = round(float(stored.iloc[-1]['Time'])) + snapshots = _snapshot_times(outdir) + assert impact_step_key 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 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 + # 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. The row the ledger first carries the delivered mass on + # is the impact step; its own rounded time is the key a stale snapshot + # would be filed under, for the same reason as the first leg above. + resumed_impact_row = int(np.argmax(np.diff(ledger) > 0.0)) + 1 + resumed_impact_key = round(float(times[resumed_impact_row])) + resumed_snapshots = _snapshot_times(outdir) + assert resumed_impact_key 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' + ) diff --git a/tests/integration/test_slow_zalmoxis_aragog_calliope.py b/tests/integration/test_slow_zalmoxis_aragog_calliope.py index 600ceeadc..24089f416 100644 --- a/tests/integration/test_slow_zalmoxis_aragog_calliope.py +++ b/tests/integration/test_slow_zalmoxis_aragog_calliope.py @@ -94,6 +94,15 @@ @pytest.mark.slow @pytest.mark.physics_invariant +@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 ' + "advance within the test's 2-timestep/1e3 yr budget. See " + 'FormingWorlds/PROTEUS#840.' + ), +) def test_zalmoxis_aragog_calliope_two_timesteps(proteus_multi_timestep_run): """Two-step PROTEUS run with real Zalmoxis + Aragog + CALLIOPE on the Earth-IC fiducial. diff --git a/tests/integration/test_smoke_accretion.py b/tests/integration/test_smoke_accretion.py new file mode 100644 index 000000000..a9c1325eb --- /dev/null +++ b/tests/integration/test_smoke_accretion.py @@ -0,0 +1,370 @@ +"""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 + - 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 + - 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)] + +# 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 +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. 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 = 1e0 + 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' + + # 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) + + 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 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) + + # 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' + + # 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' + ) + + +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' + ) diff --git a/tests/interior_energetics/test_aragog.py b/tests/interior_energetics/test_aragog.py index 7bcf66d90..f9100ef95 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,15 +14,21 @@ 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 +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)] @@ -771,6 +780,330 @@ def test_setup_or_update_solver_tracks_stale_structure_steps(): 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 rounded, matching the writer's convention, so a fractional + time still finds its file. A fraction above a half discriminates that: + truncating would look for the year below and miss the file entirely, + leaving a step's stale snapshot on disk for a resume to load. + """ + 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 round, matching '%.0f_int.nc' in the writer. + (data / '411_int.nc').write_text('stale') + assert discard_snapshot(str(tmp_path), 410.9) is True + assert not (data / '411_int.nc').exists() + + # Discrimination: the year below is not what the writer named, so a + # truncating discard would remove a different step's snapshot. + (data / '420_int.nc').write_text('keep') + assert discard_snapshot(str(tmp_path), 420.9) is False + assert (data / '420_int.nc').read_text() == 'keep' + + +@pytest.mark.unit +def test_earlier_snapshot_exists_counts_by_the_writers_naming(tmp_path): + """The fallback check reads the same year the writer wrote. + + Contract clause: a snapshot is only discarded when an older one survives + it, so this check decides whether the run keeps any interior state on disk. + It compares against the stems the writer produced, and the writer rounds, + so the cutoff has to round as well. + + Verifies: + - A strictly older snapshot is found, and the step's own is not counted as + older than itself. + - An empty directory reports False rather than raising, which is what the + first step of a run looks like. + - A file whose stem is not a number is skipped rather than raising. + - A step whose time rounds up sees the snapshot below it. Truncating the + cutoff would put it on the same year as that file, report no fallback, + and leave a stale post-impact snapshot in place for a resume to load. + """ + from proteus.interior_energetics.aragog import earlier_snapshot_exists + + data = tmp_path / 'data' + data.mkdir() + + # Nothing on disk: the first step of a run, not an error. + assert earlier_snapshot_exists(str(tmp_path), 100.0) is False + + (data / '100_int.nc').write_text('older') + assert earlier_snapshot_exists(str(tmp_path), 200.0) is True + # A step does not count its own snapshot as one it can fall back on. + assert earlier_snapshot_exists(str(tmp_path), 100.0) is False + assert earlier_snapshot_exists(str(tmp_path), 100.4) is False + + # A stem that is not a year is passed over, not raised on. + (data / 'merged_int.nc').write_text('not a snapshot time') + assert earlier_snapshot_exists(str(tmp_path), 100.0) is False + assert earlier_snapshot_exists(str(tmp_path), 200.0) is True + + # Discrimination: the writer names this step 101, so 100 belongs to an + # earlier step and is a genuine fallback. A truncating cutoff of 100 would + # report none and leave this step's stale snapshot in place. + assert earlier_snapshot_exists(str(tmp_path), 100.6) is True + + +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, 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, + 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_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. + + 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 def test_solve_with_retry_ladder_exhaustion_names_the_solver_that_actually_ran( monkeypatch, @@ -899,6 +1232,644 @@ def test_aragog_still_exposes_cvode_availability_flag(): ) +@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 + 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 + # 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 +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(): + """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 + + +@pytest.mark.unit +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_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() + 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 + + +@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. + + 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: + - 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 + + monkeypatch.delenv('PROTEUS_CI_NIGHTLY', raising=False) + + 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('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' + + # 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 + + 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 + + meshes = [c.args[0] for c in mesh_arrays.from_numpy_mesh.call_args_list] + assert meshes == [before_mesh, after_mesh] + + +@pytest.mark.unit +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_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 +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. + + 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 path off rather than leaving the stale one 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, + ) + 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 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 + + @pytest.mark.unit def test_retry_exhaustion_labels_unknown_when_cvode_probe_fails(monkeypatch): """Exhaustion names the probe failure, not a wrong integrator. @@ -1031,6 +2002,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 diff --git a/tests/interior_energetics/test_timestep.py b/tests/interior_energetics/test_timestep.py index 593806e1e..507f8de5c 100644 --- a/tests/interior_energetics/test_timestep.py +++ b/tests/interior_energetics/test_timestep.py @@ -36,6 +36,10 @@ 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, + impact_maximum: float = 0.0, escape_dt_floor_frac: float = 1.0e-3, ): """Build a minimal duck-typed config that ``next_step`` reads from. @@ -63,6 +67,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) @@ -75,14 +80,25 @@ 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, + ) escape = SimpleNamespace(step_dt_floor_frac=escape_dt_floor_frac) return SimpleNamespace(params=params, star=star, escape=escape) -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)) @@ -93,14 +109,16 @@ 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)), } ) -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) # --------------------------------------------------------------------------- @@ -635,6 +653,349 @@ def test_next_step_maximum_rel_default_widens_cap_proportional_to_Time(): 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}' + + # 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. + + 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 + + @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. + + 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) + + class TestEscapeStepLimit: """The controller must honour a shorter step requested by a capped escape step, without letting it override the run's own floor.""" diff --git a/tests/interior_energetics/test_timestep_bolscale_event.py b/tests/interior_energetics/test_timestep_bolscale_event.py index ad35780f3..1dfb81803 100644 --- a/tests/interior_energetics/test_timestep_bolscale_event.py +++ b/tests/interior_energetics/test_timestep_bolscale_event.py @@ -301,7 +301,10 @@ def test_next_step_flags_interior_o_when_bolscale_clamps_the_step(): ) hf_row = {'Time': 100.0} hf_all = _next_step_hf_all(age_star=5.0e8 - 5.0e4) - interior_o = SimpleNamespace(timestep_clamped=False) + # t_next_impact is infinite whenever no giant impact is scheduled, which + # is every run without accretion; next_step reads it to decide whether to + # cap dt so the loop lands on the impact. + interior_o = SimpleNamespace(timestep_clamped=False, t_next_impact=float('inf')) dt = next_step(config, {}, hf_row, hf_all, step_sf=1.0, interior_o=interior_o) @@ -319,7 +322,10 @@ def test_next_step_does_not_flag_interior_o_when_window_is_far_away(): ) hf_row = {'Time': 100.0} hf_all = _next_step_hf_all(age_star=1.0e8) # window opens at 5e10 yr, far away - interior_o = SimpleNamespace(timestep_clamped=True) # start True to prove it flips + interior_o = SimpleNamespace( + timestep_clamped=True, # start True to prove it flips + t_next_impact=float('inf'), + ) dt = next_step(config, {}, hf_row, hf_all, step_sf=1.0, interior_o=interior_o) diff --git a/tests/interior_energetics/test_wrapper.py b/tests/interior_energetics/test_wrapper.py index c3f4ac3fb..16c86005b 100644 --- a/tests/interior_energetics/test_wrapper.py +++ b/tests/interior_energetics/test_wrapper.py @@ -19,8 +19,10 @@ from __future__ import annotations import logging +import math import os from pathlib import Path +from types import SimpleNamespace from unittest.mock import MagicMock, patch import numpy as np @@ -33,6 +35,7 @@ _eos_grid_extent_up_step, _prevent_warming_clamp_active, _refresh_composition_sentinels, + remelt_mantle, update_structure_from_interior, ) @@ -2117,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 @@ -5663,3 +5712,710 @@ 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, + 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, temperature_mode='liquidus_super'), + 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(caplog): + """A reset temperature below the liquidus cannot fully re-melt, and says so. + + 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) + + 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.messages) + + +@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 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. + + ``_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) + + 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): + # Read-only view over the solved trajectory, as on the real solver. + return self._solution.y[:, -1] + + 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(): + """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 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)) + interior_o = SimpleNamespace( + aragog_solver=solver, _last_entropy=np.full(6, 2400.0), impact_reset=False + ) + config = _remelt_config('aragog') + + # _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 + + 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=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) + assert solver._solution is None + assert solver._dSdr_cmb_init is None + + # 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 + + # 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. + """ + 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(): + """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) + + +@pytest.mark.unit +@pytest.mark.physics_invariant +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, 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. + """ + 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) + would_remove = 6 * (3900.0 - 2400.0) * _FakeAragogSolver._HEAT_PER_ENTROPY + + 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: colder_ic, + ): + remelt_mantle({'output': '/tmp/out'}, config, hf_row=hf_row, interior_o=interior_o) + + # 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 +@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. + """ + 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_before=0.02, + 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_before=0.02, + 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 + + +@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 + + +# ---------------------------------------------------------------------------- +# 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 diff --git a/tests/outgas/test_atmodeller.py b/tests/outgas/test_atmodeller.py index ea26e2166..fe573cf6d 100644 --- a/tests/outgas/test_atmodeller.py +++ b/tests/outgas/test_atmodeller.py @@ -214,15 +214,17 @@ 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: an empty atmospheric reservoir gives escape nothing to draw - # on, so the whole-planet budget is left alone rather than debited. The two - # debits are a resolvable percent of the budget apart, not a tolerance width. + # Boundary: an empty atmospheric reservoir gives escape nothing to + # partition the loss over, so the whole-planet budget is preserved rather + # than debited. A collapse to zero here is the failure mode, so the + # assertions pin the budget and floor it well above zero. empty_row = dict(hf_row) for e in element_mmw: empty_row[f'{e}_kg_atm'] = 0.0 tgt_empty = calc_new_elements(empty_row, dt=dt, reservoir='outgas') assert (h_budget - tgt_empty['H']) == pytest.approx(0.0, abs=1e-6) 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 @@ -263,15 +265,18 @@ def test_o2_endpoint_keeps_live_budget_no_false_desiccation(): # the O2 endpoint. assert tgt['O'] > 0.99 * 9.0e21 - # Discrimination: with no O in the atmospheric reservoir escape has nothing to - # draw on, so the budget is untouched. Pinning the two debits rather than the - # two totals separates them by the full escape mass, well clear of tolerance. + # Boundary: with no O in the atmospheric reservoir escape has nothing to + # draw on, so the O budget is preserved. A collapse to zero here is the + # false-refusal failure mode. The empty-reservoir totals are pinned and + # the non-empty debit is checked against the full escape mass. o_budget = hf_row['O_kg_total'] empty_row = dict(hf_row) for e in element_mmw: empty_row[f'{e}_kg_atm'] = 0.0 tgt_empty = calc_new_elements(empty_row, dt=1.0, reservoir='outgas') assert (o_budget - tgt_empty['O']) == pytest.approx(0.0, abs=1e-6) + assert tgt_empty['O'] == pytest.approx(o_budget, rel=1e-12) + assert tgt_empty['O'] > 8.0e21 # discriminates against the zero collapse # rel=1e-4 because the debit is a 3e10 kg difference between two 9e21 kg # totals, where double precision resolves only about 2e6 kg (rel 6e-5). assert (o_budget - tgt['O']) == pytest.approx(1.0e3 * secs_per_year * 1.0, rel=1e-4) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index bfe32cb12..70b06166a 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, @@ -1621,3 +1622,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' diff --git a/tests/test_proteus.py b/tests/test_proteus.py index b5245135a..ba75cf1e9 100644 --- a/tests/test_proteus.py +++ b/tests/test_proteus.py @@ -1388,6 +1388,63 @@ def test_structure_baseline_skipped_for_superliquidus_adiabat(tmp_path): 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 + import re + + 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. + # ``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' + # 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' + ) + + # 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 + + # --------------------------------------------------------------------------- # Resume path: crystallization flag restoration (proteus.py, resume branch) # --------------------------------------------------------------------------- @@ -1515,6 +1572,92 @@ def test_proteus_resume_keeps_crystallized_after_remelting(tmp_path): ) +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' + ) + + # --------------------------------------------------------------------------- # Proteus.start() main loop: plot-cadence gating (proteus.py ~1200-1207). # @@ -1557,9 +1700,10 @@ def _make_main_loop_proteus(tmp_path, *, plot_mod, write_mod, dt_write_rel, vapo `interior_energetics.module` / `interior_struct.module` are set to 'dummy' so the Zalmoxis structure-update and SPIDER-specific branches - are no-ops; `observe.module=None` and a non-'online'/'offline' - atmos_chem.when skip the postprocessing branches. None of these - short-circuits touch the plot-gating condition under test. + are no-ops; `observe.module=None`, `accretion.module=None` and a + non-'online'/'offline' atmos_chem.when skip the postprocessing and + impact branches. None of these short-circuits touch the plot-gating + condition under test. `vapourise` selects which half of the mass-conservation invariant the loop enforces: with it True the M_atm <= M_planet half is replaced by a warning, @@ -1578,6 +1722,7 @@ def _make_main_loop_proteus(tmp_path, *, plot_mod, write_mod, dt_write_rel, vapo config.interior_energetics.flux_guess = 100.0 # >=0: skips sigma*T^4 branch config.orbit.module = None config.observe.module = None + config.accretion.module = None config.atmos_chem.when = 'never' config.outgas.vapourise = vapourise config.planet.temperature_mode = 'isothermal' diff --git a/tests/tools/test_generate_config_reference.py b/tests/tools/test_generate_config_reference.py index dda9934f3..1fd13ac62 100644 --- a/tests/tools/test_generate_config_reference.py +++ b/tests/tools/test_generate_config_reference.py @@ -161,9 +161,9 @@ def test_page_map_covers_every_section(schema): sections = {f['toml_section'] for f in schema['fields']} for section in sections: assert section.split('.')[0] in _gcr.PAGE_MAP, f'[{section}] unmapped' - # The map points only at the seven real pages. + # The map points only at the eight real pages. pages = set(_gcr.PAGE_MAP.values()) - assert len(pages) == 7 + assert len(pages) == 8 for page in pages: assert (_REPO_ROOT / 'docs' / 'Reference' / 'config' / page).is_file() @@ -291,7 +291,7 @@ def test_committed_pages_are_current_and_fully_described(schema): carries a description, and all_options.toml agrees with the schema; any of these failing means a source edit landed without regeneration.""" targets = _gcr.build_targets(schema) - assert len(targets) == 8 # seven pages plus the JSON sidecar + assert len(targets) == 9 # eight pages plus the JSON sidecar for path, content in targets: assert path.read_text() == content, f'{path.name} is stale' assert _gcr.check_all_options(schema) == [] diff --git a/tests/tools/test_generate_module_map.py b/tests/tools/test_generate_module_map.py index 211e46918..570ec2617 100644 --- a/tests/tools/test_generate_module_map.py +++ b/tests/tools/test_generate_module_map.py @@ -89,9 +89,9 @@ def test_verify_passes_on_committed_tree(schema): a stale declaration in DISPATCH_SITES.""" problems = _gmm.verify(schema) assert problems == [] - # The map covers all nine dispatch sites, one per physics area. - assert len(_gmm.DISPATCH_SITES) == 9 - assert len({site['config_path'] for site in _gmm.DISPATCH_SITES}) == 9 + # The map covers all ten dispatch sites, one per physics area. + assert len(_gmm.DISPATCH_SITES) == 10 + assert len({site['config_path'] for site in _gmm.DISPATCH_SITES}) == 10 def test_verify_flags_broken_entry_and_option_drift(schema, monkeypatch): diff --git a/tests/tools/test_install_scripts.py b/tests/tools/test_install_scripts.py index f876704f6..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 @@ -697,8 +698,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 +708,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 +807,180 @@ 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 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')) + + 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' + + 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}' + ) + + # 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 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' + ) + + # --------------------------------------------------------------------------- # Portable-flag rewrite and guards (tools/get_socrates.sh) # --------------------------------------------------------------------------- diff --git a/tests/tools/test_migrate_config_v2_to_v3.py b/tests/tools/test_migrate_config_v2_to_v3.py index 8d03e906a..bed31f600 100644 --- a/tests/tools/test_migrate_config_v2_to_v3.py +++ b/tests/tools/test_migrate_config_v2_to_v3.py @@ -78,6 +78,41 @@ 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, 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.eccentricity', + 'accretion.dummy.timeline_path', + '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', + '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.time_offset', 'atmos_clim.aerosols_enabled', 'atmos_clim.agni.grey_opacity_lw', 'atmos_clim.agni.grey_opacity_sw', @@ -219,6 +254,9 @@ def _v3(): 'outgas.vapourise', 'params.dt.hysteresis_iters', 'params.dt.hysteresis_sfinc', + # The giant-impact landing-step cap defaults to 0 (disabled), and a + # migrated config schedules no impacts, so it never engages. + 'params.dt.impact_maximum', 'params.dt.max_growth_factor', 'params.dt.mushy_maximum', 'params.dt.mushy_upper', diff --git a/tests/utils/test_coupler.py b/tests/utils/test_coupler.py index 9e108689f..aeec6a82d 100644 --- a/tests/utils/test_coupler.py +++ b/tests/utils/test_coupler.py @@ -58,7 +58,9 @@ _interior_snapshot_names, _netcdf_readable, _populate_energy_residual, + _snapshot_belongs_to, _snapshot_readable, + _snapshot_time, get_proteus_directories, print_citation, print_module_configuration, @@ -1664,6 +1666,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, @@ -1687,6 +1690,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 @@ -1710,6 +1714,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', @@ -2049,6 +2055,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.""" @@ -3755,6 +3827,420 @@ def test_select_resumable_snapshot_rejects_cross_row_atm_collision(tmp_path): 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: + 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, + GetHelpfileKeys, + ReadHelpfileFromCSV, + ZeroHelpfileRow, + ) + + 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' + 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) + + +@pytest.mark.unit +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. + + 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, + 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) + + +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', + ) + + 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) + + +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', + ) + + 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) + + # ============================================================================= # Test: select_profile_plot_times() - atmosphere/interior profile-time selection # ============================================================================= diff --git a/tools/_helpfile_scan.py b/tools/_helpfile_scan.py index e3c58f1be..9f4ab0ee2 100644 --- a/tools/_helpfile_scan.py +++ b/tools/_helpfile_scan.py @@ -59,6 +59,7 @@ # domain lists the template spans; unions over-approximate and are trimmed # against the schema downstream. TEMPLATE_OVERRIDES: dict[tuple[str, str], tuple[str, ...]] = { + ('accretion/wrapper.py', '_kg_total'): ('element_list',), ('escape/wrapper.py', '_kg_total'): ('element_list',), ('escape/common.py', 'esc_rate_'): ('element_list',), ('outgas/calliope.py', '_kg_total'): ('element_list',), @@ -93,6 +94,8 @@ # hf_row.update(saved) restores of pre-call snapshots. ('interior_energetics/wrapper.py', '_solve_structure_with_adiabat_or_rollback'), ('interior_energetics/wrapper.py', 'update_structure_from_interior'), + # Impact re-melt rewrites melt-state columns run_dummy_int already produces. + ('interior_energetics/wrapper.py', '_remelt_scalar_backend'), } # Producers that assemble their key through a local variable the visitor 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..59040d2d7 --- /dev/null +++ b/tools/figures/gen_tikz.py @@ -0,0 +1,517 @@ +"""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: + """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: + 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 000000000..fbd10a8aa Binary files /dev/null and b/tools/figures/img/arch_dark_img00.png differ diff --git a/tools/figures/img/arch_light_img00.png b/tools/figures/img/arch_light_img00.png new file mode 100644 index 000000000..2535d332d Binary files /dev/null and b/tools/figures/img/arch_light_img00.png differ diff --git a/tools/generate_config_reference.py b/tools/generate_config_reference.py index e143ab9d3..9b241b21c 100644 --- a/tools/generate_config_reference.py +++ b/tools/generate_config_reference.py @@ -52,7 +52,7 @@ 'escape': 'escape_outgas.md', 'outgas': 'escape_outgas.md', 'observe': 'observe.md', - 'accretion': 'observe.md', + 'accretion': 'accretion.md', } # Dotted paths deliberately absent from input/all_options.toml, each with a diff --git a/tools/generate_module_map.py b/tools/generate_module_map.py index 4b356d0dd..aedb36069 100644 --- a/tools/generate_module_map.py +++ b/tools/generate_module_map.py @@ -45,6 +45,29 @@ # file marks a disabled-module option with no implementation. Rows are # verified against the source on every run; see the module docstring. DISPATCH_SITES = [ + { + 'area': 'Accretion', + 'config_path': 'accretion.module', + 'wrapper': 'accretion/wrapper.py', + 'entries': { + 'dummy': ( + 'accretion/dummy.py', + 'get_timeline', + 'Analytical exponential accretion law', + ), + 'timeline': ( + 'accretion/timeline.py', + 'get_timeline', + 'Impacts replayed from a timeline file', + ), + 'morrigan': ( + 'accretion/morrigan.py', + 'get_timeline', + 'Monte Carlo giant-impact dynamics (Morrigan)', + ), + None: (None, None, 'Accretion disabled; no impacts'), + }, + }, { 'area': 'Atmosphere climate', 'config_path': 'atmos_clim.module', @@ -324,8 +347,7 @@ def verify(schema: dict) -> list[str]: choices_by_path = {f['path']: f['choices'] for f in schema['fields']} # Completeness: every module-selection field with at least one non-None - # option must have a dispatch site here. accretion.module accepts only - # None today, so it is exempt until an implementation lands. + # option must have a dispatch site here. mapped = {site['config_path'] for site in DISPATCH_SITES} for field in schema['fields']: if not field['path'].endswith('.module') or field['path'].count('.') != 1: diff --git a/tools/generate_version_badges.py b/tools/generate_version_badges.py index cd2793833..a469eb328 100644 --- a/tools/generate_version_badges.py +++ b/tools/generate_version_badges.py @@ -117,6 +117,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 new file mode 100755 index 000000000..e79bfd122 --- /dev/null +++ b/tools/get_morrigan.sh @@ -0,0 +1,101 @@ +#!/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 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 + +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 + +echo "Cloning from GitHub" +if [ "$use_ssh" = true ]; then + uri="git@github.com:FormingWorlds/Morrigan.git" +else + uri="https://github.com/FormingWorlds/Morrigan.git" +fi +echo " $uri -> $workpath" +git clone "$uri" "$workpath" || { echo "ERROR: git clone failed" >&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; } + +# Done +echo "Done!"