Skip to content

Grow the planet by giant impacts: the accretion module and Morrigan - #789

Open
timlichtenberg wants to merge 35 commits into
mainfrom
tl/accretion-morrigan-coupling
Open

Grow the planet by giant impacts: the accretion module and Morrigan#789
timlichtenberg wants to merge 35 commits into
mainfrom
tl/accretion-morrigan-coupling

Conversation

@timlichtenberg

@timlichtenberg timlichtenberg commented Jul 26, 2026

Copy link
Copy Markdown
Member

Description

This adds accretion, a module that grows the planet by giant impacts while the magma ocean evolves, and couples Morrigan into PROTEUS as its dynamical implementation. Closes #168. Related to #407, which asks for a parametrised impact mass-loss scaling; the loss used here is the Kegerreis et al. (2020) law evaluated by ZEPHYRUS, not the scaling that issue proposes.

An impact is applied at the end of the timestep that lands on its time, so the orbit and structure of that step already use the grown planet. Each collision adds the impactor's rock and re-solves the interior structure at the new mass, delivers the impactor's volatiles while the same collision fraction strips both bodies' atmospheres, re-melts the mantle by re-applying the run's temperature-mode initial condition, and moves the orbit by the impact's change in semi-major axis and eccentricity, clamped to a bound orbit. The whole-planet mass closes to before plus rock plus delivered minus stripped, which can be a net shrink when a small impactor blows off a heavier atmosphere.

Three implementations:

  • morrigan follows a system of embryos through the giant-impact phase with the semi-analytical Monte Carlo model of Kimura et al. (2025) and hands PROTEUS the impact history of one survivor.
  • timeline replays an impact history from a file, for reproducing a published history or driving PROTEUS from one computed elsewhere.
  • dummy builds a history from scaling laws: exponential approach to an asymptotic mass, evenly spaced impacts, Noack & Lasbleis (2020) radii, momentum-conserving mergers.

Impactors are dry by default, can carry the planet's own formation abundances, or can carry per-element budgets set in the config. Impact atmosphere loss is off by default, and can be a fixed fraction or the ZEPHYRUS erosion law.

Morrigan is an optional dependency, installed with pip install "fwl-proteus[morrigan]" or tools/get_morrigan.sh, and pinned by its PyPI floor like VULCAN. The floor is release 26.07.27, which carries the accretion coupling documentation and the tutorials that walk through the configuration below.

The two documentation figures now show the module. The code-architecture diagram gained an Accretion stage in the coupling loop and an accretion module group, and is generated from a model in tools/figures rather than drawn by hand, so its light and dark variants come from one source and it rebuilds with pdflatex. Both figures' labels are real vector text, so a still export shows the same wording as a browser.

What review would help most with

The physics is being tested and developed by Anna Grace, Tadahiro and me, so review effort is best spent on the software engineering and the framework integration rather than on the impact physics itself.

That said, this touches a lot of the framework, and a look at those parts would be very welcome:

  • the new module's shape against the rest of src/proteus/: the wrapper and dispatch pattern, the configuration schema and its validators, and whether anything here should have been shared rather than added;
  • where the step sits in the coupling loop, and what it mutates: config.planet.mass_tot, the structure re-solve, the volatile budgets, the solidification latch, and the orbit;
  • the resume path, since the impact history has to survive a restart and is written to the run directory for that reason;
  • the interaction with modules it did not previously touch, in particular the interior re-melt and the ZEPHYRUS collision call;
  • test coverage and whether the seams are tested at the right level;
  • the documentation and the figures, which now carry a generated source.

Validation of changes

Unit and integration tests. New tests cover the impact accounting, the volatile delivery and strip, the re-melt guards, the timeline resolution and resume path, and the analytical module's scaling limits.

Coupled capstone run. A full run with Aragog, AGNI, CALLIOPE, ZEPHYRUS and Zalmoxis, starting from 1 M⊕ at temperature_mode = "liquidus_super", with Morrigan seed 7 over eight embryos and atmloss_module = "zephyrus":

Quantity Result
Impacts applied 2, at 912 yr (+0.70 M⊕) and 1939 yr (+0.50 M⊕)
Final planet mass 1.30e25 kg (2.18 M⊕)
Impact re-melt heat 3.49e30 J and 6.11e30 J
Solidification 2.014 Myr at global melt fraction 0.05
Steps 3772

The run logged no clamp or strip warnings, so those paths did not fire in this configuration. It ran from the branch at "Depend on the published fwl-morrigan release instead of a commit"; the commits after it correct the impact accounting and the orbit update, and were assessed against this run rather than re-run, so the numbers above are not a validation of the branch tip unqualified.

Figures. The architecture diagram was reproduced from the previous one before anything was added, and checked against it: 98.6 % of label lines within one pixel, and 0.009 % of non-text pixels differing by more than a quarter step, all of it corner antialiasing. Every region that only moved was then checked as a pure translation. All 42 links in the figure resolve to a real file, and every line anchor into proteus.py lands on the call site of the stage it labels. A geometric check over both figures, which flattens every stroked shape and allows for paint order, confirms that no line crosses a label and no two labels overlap.

Test configuration. macOS 15 (Apple silicon), Python 3.12.13, Aragog 26.7.4, fwl-morrigan 26.7.27.

Checklist

  • I have followed the contributing guidelines
  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • My changes generate no new warnings or errors
  • I have checked that the tests still pass on my computer
  • I have updated the docs, as appropriate
  • I have added tests for these changes, as appropriate
  • I have checked that all dependencies have been updated, as required

Giant impacts during accretion grow the planet, deliver volatiles, re-melt the mantle, strip part of the atmosphere, and move the orbit. This fills in the previously empty accretion section of the config so a run can describe that history.

Two backends are selectable. "morrigan" runs the giant-impact model of Kimura et al. (2025) for a system of embryos and follows one survivor; "dummy" replays a timeline written earlier, which is how the impact consequences get tested against a known event sequence. Impactor volatile content is set per element in ppmw of impactor mass and defaults to zero, so impactors are dry unless delivery is asked for.

The section stays off by default, so existing configs are unaffected. The backend parameter blocks are only validated once their backend is selected.
Builds the module that turns a giant-impact history into something the main loop can act on. An impact timeline is prepared once at start-up, in the same way the stellar evolution track is, and lists the impacts the planet will experience with the masses, velocities, geometry, and post-impact orbit of each one.

Two ways to obtain the timeline. The morrigan backend runs the giant-impact model of Kimura et al. (2025) for a system of embryos and follows one survivor, chosen by mass, by target orbit, by name, or by resemblance to the configured planet. The dummy backend replays a timeline written earlier. Either way the result is validated before the run starts: masses must close across each merger, the collision velocity cannot fall below the mutual escape velocity, the impact geometry and eccentricity must be in range, and the target mass must chain from one impact to the next, so an inconsistent history is rejected up front instead of surfacing halfway through a run.

Impacts that land before the run begins are reported with the mass they would have added, since the configured planet mass and orbit define the initial state and those impacts cannot be applied without contradicting it.

Nothing is applied at an impact yet; that follows. With no accretion module selected the schedule is empty and the run is unchanged.
Morrigan is developed in its own repository and cloned into the PROTEUS tree alongside the other modules, so a local checkout should not show up as untracked here.
The impact timeline reports the perfect-merger mass, the plain sum of target and impactor, so that the atmosphere a collision strips is accounted for once, by the escape module, rather than twice. The dynamical model meanwhile hands the next collision a body that has already lost that atmosphere, so the target mass of one impact legitimately sits below the merged mass of the one before it. Requiring the two to match exactly would reject every real chain in which any atmosphere is lost.

The chain check is now one-way. A body may lose up to ten percent of its mass between impacts, which is generous against the percent-level envelopes these embryos carry, and may not gain any: nothing feeds it in between, so a gain is a bookkeeping error. A larger drop still fails, since that means rows from two different planets have been spliced together. The ceiling is a keyword argument for models whose bodies carry heavier envelopes.
A giant impact grows the planet and re-melts its mantle, so it has to be applied at the state the impact timeline places it at. Left to itself the adaptive controller picks a step from stiffness and flux history alone, and will happily jump over an impact and apply it to whatever state it lands on instead.

The main loop now hands the time-stepper the time of the next scheduled impact, and the stepper shortens the step to end on it. The clamp only ever shortens: a distant impact leaves the controller in charge. It is floored at the minimum step, so an impact a few years away cannot drive the step towards zero and stall the run; the event window is half-open, so an impact inside a floored step is still applied exactly once. With no accretion module selected there are no scheduled impacts and the step is untouched, which is every run today.
The accretion timeline scheduled the impacts and the time-stepper landed each step on one, but nothing happened when a step reached an impact. The main loop now applies each impact as the step that contains it advances the time, once per impact, since the step window is half-open.

An impact grows the planet by the impactor mass and re-solves the interior structure, so the radius, gravity and the core and mantle masses follow the new total at the configured core fraction rather than staying at the old mass. It moves the orbit by the impact's proportional change in semi-major axis and its post-impact eccentricity, writing both the configuration, which sets the orbit when tides are off, and the running row, which the tidal evolution carries forward when tides are on, so the change holds under either. Applied after the time advance, this step's orbit and structure already use the grown planet and the next interior solve evolves it.

A run with no accretion module is untouched. The mantle re-melt and the optional volatile delivery are the remaining consequences and follow separately.
Each giant impact returns the mantle to a molten initial condition recomputed for the grown planet, so the interior evolves from a magma ocean after the impact. The reset is applied to the running state at the end of the impact step and the interior stepper is told to expect the temperature jump so it does not clip the deliberate reset away.

The backends carry their state differently. The dummy and boundary backends cool a surface temperature and are reset to the configured initial value together with the melt fraction and reservoir masses derived from it, so the impact row is internally consistent rather than carrying a hot temperature beside a cooled melt fraction; the boundary backend also resets its surface temperature. Aragog re-applies its entropy initial condition and, crucially, carries the molten profile through the restore the coupling performs on the following step, so the reset is not silently overwritten by the previous cooled solution; the stale trajectory and cached boundary gradient are cleared so they are re-derived from the molten profile.

A re-melted mantle is a magma ocean again, so the impact clears the one-way solidification latch; without that, outgassing would stay shut off and the volatiles would be treated as locked in a solid for the rest of the run. The reset temperature is checked against the liquidus, so a value too low to fully melt warns instead of passing silently. SPIDER holds its state in a restart file written by the external binary and has no validated re-melt path, so an accretion run on SPIDER is refused at configuration load; an Aragog run whose temperature mode does not guarantee a molten start warns at load. The impact kinetic energy is logged next to the re-melt for context.
The mantle re-melt landed but did not hold up under scrutiny. This is the correction round.

The Aragog re-melt was being discarded: it wrote the molten profile into the solver's initial vector, but the next step restored the entropy from the previous cooled solution and overwrote it. The molten profile is now taken from the initial-condition helper's return value and placed on the carrier the next step restores from, and the stale trajectory and its cached boundary gradient are dropped before the initial condition is rebuilt, so the gradient is derived from the molten profile rather than inherited from the cooled one. A real coupled run confirms it: the step after an impact restores a uniform molten profile, not the pre-impact field.

The temperature-jump limiter no longer clips the re-melt: the reset raises a one-shot flag consumed at the top of the interior step, which is cleared even on an early solver-retry exit so it can never suppress the limiter on a later ordinary step. The flux positivity floor stays outside that bypass, since a negative flux must never reach the atmosphere regardless.

The boundary backend is re-melted with its own melting curve rather than the dummy backend's, and the scalar backends now refresh the interior melt-fraction arrays the same-iteration tidal call reads. The solidification latch, cleared by an impact, is no longer re-armed on the same iteration from the stale pre-impact melt fraction. The end-of-run interior snapshot guards on the solver's solution, which an impact on the final step clears. The molten-start advisory for Aragog moves to the accretion setup, where the run log exists, and no longer fires for the temperature modes that are molten by construction.

Several items are deliberately left open and recorded in the project notes: the injected re-melt enthalpy is not yet entered into the energy-conservation residual; the structure re-solve already re-melts the scalar backends, so a coupled dummy run does not isolate the re-melt; and for Aragog the melt fraction the same-iteration outgassing sees lags one step. These need design decisions rather than a quick patch.
An impact can carry volatiles into the planet. Each impact now adds, per element, the impactor mass times the configured content in parts per million by weight to the whole-planet element budget the outgassing step reads, so the volatiles are re-equilibrated into the melt and atmosphere on the same iteration. Every element defaults to zero, a dry impactor, so a run that does not opt in is unchanged, and an element that is deferred to the chemistry step is left untouched when nothing is delivered for it. A real run delivering 5000 ppmw of hydrogen on a half-Earth-mass impactor adds 1.49e22 kg of hydrogen, as expected.

One interaction is recorded in the project notes rather than patched here: the structure re-solve that follows the mass growth recomputes the parts-per-million element budgets against the grown mass, which rescales the volatile budgets as if the accreted rock carried the planet's volatile content. Handling that correctly, rock scaling with the added mass while volatiles are conserved and only grown by delivery, is part of the whole-planet element accounting and wants a deliberate decision.
A giant impact grows the planet's mass and re-solves its structure, which recomputed the ppmw volatile budgets against the grown mass. A dry, rock-dominated impactor then inflated the hydrogen, carbon, nitrogen and sulfur inventories as if the added rock carried the planet's own volatile content, which is unphysical.

The mass growth now adds rock, not volatiles: the volatile budgets are snapshotted before the structure re-solve and restored afterwards, so they are conserved across the growth and grow only through the impactor's explicit delivery. The rock mass still grows through the structure solve. The tracked-element total is refreshed from the conserved-plus-delivered budgets.

Verified in a dummy coupled run: a dry 0.5 Earth-mass impactor leaves H, C, N, S, O and M_ele flat across the impact while the interior mass grows from 1.0 to 1.5 Earth masses.
A full mantle re-melt injects mantle-scale heat as an entropy jump between solver calls, which no per-call state integral carries, so the energy-conservation residual reported closure across an impact it never accounted for.

The Aragog re-melt now books the injected heat into a step_dE_impact_J column, evaluated with the solver's own entropy-transported heat quadrature over the jump from the cooled to the molten profile, the same rho(P,S) T dS frame the residual integrates. The coupler adds the column to both sides of the budget: the state side gains the heat that was actually added and the predicted side gains the impact as an energy source, so the residual is invariant across an impact by construction and the injection is quantified in the helpfile instead of silently absorbed. When no pre-impact profile exists the booking is skipped with a warning rather than invented. The re-melt itself remains unconditional.

Verified in an Aragog coupled run: a 0.5 Earth-mass impact books 1.944e30 J, the residual stays at 3.79e27 J across the impact row and settles at 8.1e27 J after, a shift of 0.2 percent of the injection and within the diagnostic's closure floor, where an unbooked injection would have shifted it by the full amount. The relative-residual column spikes on the impact row because the injection nearly cancels the cumulative state heat in its denominator; the absolute column is the diagnostic there.
A giant impact blows off part of the atmosphere the planet already holds, a loss channel the accretion coupling did not yet model.

Each impact now removes a fraction of the atmosphere, debited through the same path continuous escape uses: the lost mass is partitioned over the elements in proportion to their atmospheric reservoirs and subtracted from the whole-planet budgets, so the per-element loss can never exceed what the atmosphere holds and the dissolved interior inventory is untouched. The stripping acts on the pre-impact atmosphere before the impactor's volatiles are delivered, and the removed mass is added to the cumulative escaped-mass ledger so a planet dried out partly by impacts still passes the desiccation audit. calc_new_elements accepts an explicit mass for this, partitioning an impulsive loss exactly like the rate integral.

The loss fraction comes from accretion.atmloss_module, disabled by default; the constant module applies a fixed accretion.atmloss_frac and stands in for a coming ZEPHYRUS collision-loss law with the same call shape, so PROTEUS itself ships no impact loss physics. A loss module returning a fraction outside zero to one is rejected rather than clamped.
A sub-threshold atmosphere made the impact strip destructive: the below-threshold return in calc_new_elements handed back the atmospheric reservoir masses, which the caller then wrote into the whole-planet totals, deleting the dissolved inventory and booking it as escaped. The below-threshold branch now returns the totals unchanged, a true no-op for every caller and reservoir mode, and the strip skips an atmosphere below the outgassing mass threshold outright. A negative explicit removal mass is rejected rather than silently adding mass.

The conserved-volatile set is now derived from the tracked-element registry minus the rock-forming elements, so a future element is conserved by default instead of silently mass-scaled. The energy-budget documentation now states the booking convention precisely: the re-melt heat is evaluated on the pre-impact solver mesh, the impactor's own heat content arrives as part of the new initial condition unbooked, and the residual is invariant for any booked value, so the column quantifies a convention rather than a residual-checked quantity. The escaped-mass ledger comments name both of its channels, continuous escape and impact stripping.

Tests: a regression pins the sub-threshold strip as a no-op that preserves the dissolved inventory; the atmosphere-loss config bounds and module selection are exercised through real config construction; new edge cases cover an airless planet with loss enabled, atmospheric oxygen in the strip partitioning, two sequential impacts, an explicit zero removal, and the negative-mass rejection. The empty-reservoir arms of the atmodeller escape tests now pin totals-preserved instead of the historical collapse.
Impactors previously carried either nothing or hand-set per-element budgets, with the whole content delivered regardless of the collision. Embryos that co-form from the same disk material should instead share the planet's own composition, and a giant impact should not deliver the volatiles its own atmosphere loses in the collision.

A new accretion.impactor_volatiles selector selects the impactor content: dry (the default), match_planet, or ppmw for the existing per-element fields. Planet-matching impactors carry the planet's fractional volatile abundances at formation, read from the settled initial row of the run's own helpfile so a resumed run recovers the same composition, scaled to the impactor mass. In every non-dry mode the content is split by mirroring the planet's per-element atmosphere fraction at impact time: with impact atmosphere loss active the impactor's atmospheric part is lost with the collision and only its dissolved part is delivered, while with loss disabled the whole content arrives. An element the planet no longer holds falls back to the bulk atmospheric fraction. The mirror understates a smaller body's atmospheric fraction, so delivery is somewhat overestimated; the coming ZEPHYRUS collision law replaces the convention.

The planet's total mass now grows by the merger mass net of every volatile lost at the impact, the target strip included, so mass_tot matches what the planet actually holds; the net change can be negative when a small impactor blows off a heavier atmosphere. All consequence sizes are computed from the pre-impact state and the structure is solved once against the final mass.

Verified in a coupled run against closed-form values: delivered, lost, stripped, and net mass all match exactly.
The accretion coupling gains atmloss_module = "zephyrus", which evaluates the giant-impact erosion scaling law of Kegerreis et al. (2020) through zephyrus.collision.mass_loss. The law is fed entirely from the impact record, so the contact speed, masses, radii, densities, and angle stay in the one frame the dynamical model produced them in; Morrigan bodies carry no modelled atmosphere, matching the law's atmosphere-excluded conventions. An installation lacking the collision law produces an upgrade instruction at the first impact, and a planet whose atmosphere exceeds a few percent of its mass logs a warning that the fitted thin-atmosphere regime no longer covers the impact.

One collision fraction now governs both bodies at each impact: the target loses that fraction of its atmosphere, and a volatile-bearing impactor loses the same fraction of its atmospheric part and delivers the remainder, so a fast head-on impact loses nearly all of it while a slow grazing one delivers most of it. The fraction is computed once per impact and passed to the strip sizing and the impactor partition; the constant module's fraction plays the same role on both sides, and the configuration documents the widened scope on every field it touches.

The dispatch is pinned against the paper's closed form for identical twin bodies and against absolute fractions on both sides of the target/impactor mass assignment, so an argument-order regression fails the pins outright; the thin-atmosphere warning is pinned at its threshold from both sides. The validation inventory records the dispatch anchors.

Verified in a coupled run against an independent evaluation of the law: the logged fraction, the stripped mass, the delivered and lost impactor volatiles, and the interior anchor growth all match hand computation exactly.
The accretion coupling's zephyrus atmosphere-loss module imports zephyrus.collision, which ships from fwl-zephyrus 26.7.24 on, so the dependency floor moves there.
Selecting the giant-impact accretion module told the user to clone Morrigan by hand, which leaves the checkout on whatever main happens to be that day. Every other external module PROTEUS pulls outside of pip resolves its URL and commit from a single table in pyproject.toml, and Morrigan now does too.

Adds tools/get_morrigan.sh, which clones into Morrigan/, checks out the pinned commit, and installs it editable. It carries the same guard as the other installers, so a checkout with uncommitted work or unpushed commits is never deleted without --force. Morrigan stays out of the mandatory dependencies: only accretion.module = "morrigan" runs need it, and the package is not on PyPI.

The two install hints, the one raised when the package is missing and the one in the module dependency check, now name the installer instead of a bare clone.
Impacts that still land at or before the start of a run are discarded with a warning and their mass is not applied anywhere; the field description said they were folded into the initial condition, which would let a reader believe the planet starts already grown by them.
Morrigan is on PyPI now, so it follows the same arrangement as VULCAN, Aragog and Zalmoxis: one version floor in the morrigan extra, and no second git pin that could drift from the published release. A plain `pip install "fwl-proteus[morrigan]"` is enough for a coupled accretion run, and both places that report the package missing now say so.

This moves the model as well as the packaging. The old pin sat five commits behind the release, and one of those commits corrects the embryo layout, the ejection eccentricity and the per-system seeding. Accretion timelines produced on this branch before this change are therefore not reproducible after it: the same configuration and seed now give a different impact schedule. The capstone run was repeated against the release to confirm the coupled chain still behaves, and its schedule differs from the earlier one exactly as that correction predicts.

The floor is written zero-padded, 26.07.25, because get_morrigan.sh checks out that string as a tag for an editable checkout; PEP 440 treats it as the same version as PyPI's normalised 26.7.25, so one string serves both. The floor is read with comments stripped, since the pin carries a rationale comment above it and a version named in prose would otherwise be picked up first and checked out as a tag that does not exist.

Embryo spacing also gains an upper bound. It is measured in mutual Hill radii, and a value large enough to be a typo previously travelled all the way into the dynamical run before anything complained. Fifty is a typo guard and nothing more: the layout condition's pole moves with the embryo masses and with the cube root of the stellar mass, so a compact system around a low-mass host reaches it well below fifty, and the model's own check is what refuses such a layout by name.

Morrigan also joins the optional-module install page and the module version table, which are generated from a list it was missing from.
The mantle re-melt applied at each giant impact books the heat it injects into the energy budget, but that column is added to both sides of the budget, so the conservation residual stays closed for any value and cannot tell a correct injection from a wrong one. Three problems followed from that.

An initial condition sitting below the mantle's current state would make the "re-melt" cool the mantle and book negative impact heat, which no collision can do. The re-melt now compares the molten profile against the cooled one and stops the run with a message naming the temperature mode, before it rewrites any solver state, so a refused re-melt leaves nothing half-applied.

Two impacts can fall inside a single timestep, since the timestep clamp is floored at the minimum step and the scheduler deliberately sweeps up every impact in the overshot window. The second re-melt measures an already-molten mantle and injects almost nothing, so assigning the booked heat rather than accumulating it replaced the first impact's real injection with that near-zero value. The column now accumulates over the step.

The magnitude itself is the harder issue. The re-melt re-applies the temperature-mode initial condition to the whole mantle, so its cost scales with the mantle rather than with the impactor, and it can sit far above or far below the energy the collision carried. Each impact now reports the injection as a fraction of its kinetic energy and warns when that fraction leaves a physically plausible band, which is the only runtime signal that can catch it.

Only liquidus_super guarantees a molten initial condition for any planet mass and melting curve, so the advisory at model start now fires for every other mode rather than treating two conditional ones as guarantees.
PROTEUS had one accretion module besides the dynamical model, called "dummy", which read a sequence of impacts from a file and replayed it. That name was wrong twice over. Every other dummy module in the codebase is a cheap approximation standing in for a heavy solver, whereas this one approximated nothing: it applied the full impact physics and only took the event list from disk. Replaying a timeline is also a capability people want in its own right, for reproducing a published impact history, driving a run from one computed elsewhere, or applying a hand-written sequence in a controlled experiment, and filing that under "dummy" hid it.

The file-driven module is now accretion.module = "timeline", matching the vocabulary the code already uses everywhere, and "dummy" is a genuine analytical module. It grows the planet along an exponential approach to an asymptotic mass, the standard picture of an accretion rate that decays as the feeding zone empties. Impacts are placed at evenly spaced times and each delivers the mass the law accretes over its interval, so the increments decay and the first impact is the largest; the increments are rescaled to sum to the configured budget exactly. Radii come from the Noack & Lasbleis (2020) scaling already used by the dummy interior structure, collision speeds combine the pair's mutual escape velocity with an encounter velocity set by the eccentricity, and each merged orbit follows from conserving linear momentum through the collision. Nothing is random, so a configuration always produces the same history, and it needs no optional dependency.

That also closes a gap in the test suite: no test at any tier ran an enabled accretion module through the coupled loop, so the timestep clamp, the impact handler, the ordering against escape and outgassing, and the runtime mass-closure assertion were exercised only through mocks. A smoke test now runs a real impact through the loop, built on the analytical module so it needs neither a fixture file nor the dynamical model.

Alongside this, four defects in the impact accounting.

A resumed run rewound the planet. Each impact accumulates its growth into the configuration, which is rebuilt from file on every start, so all growth and orbital migration before a resume point was discarded: the structure was re-solved against the original mass and, with tides off, the first resumed step snapped the orbit back to its configured value. The rock each impact adds is now recorded in the helpfile and the configuration is rebuilt from it on resume. The mass is restored from that ledger rather than from the whole-planet mass, which also carries the volatile budgets and would fold them into the rock anchor. The resolved timeline is written to the output directory and replayed on resume as well, so the impact history is a property of the run rather than of a dynamical model being bit-reproducible.

The atmospheric strip could delete dissolved mantle inventory. It was sized through the continuous-escape path, whose desiccation floor zeroes an element's whole-planet total once it falls below the outgassing threshold. That is a reasonable convention for an element ground down over many steps and wrong for a single collision: it removed mass the impact never touched and booked it as lost to space. The strip is now computed directly, each element losing that fraction of its own atmospheric mass and no more.

The whole-planet mass was left disagreeing with its parts. The handler refreshed the tracked-element total but not the planet mass, and escape runs later in the same iteration and reads it, so escape on an impact step was sized against a planet that did not exist. Both are now refreshed together, which also removes a third copy of the same aggregation.

Impact records from the dynamical model were unpacked wholesale into the event type. The dependency is pinned by a version floor, so a later release adding a field would have turned into a fatal argument error at the first impact of a run. The fields are now selected by name, as the file reader already did, and a missing one reports which.
Follow-up corrections to the accretion work, most of them in the fixes themselves rather than in what they were fixing.

The most serious one reached every run in the repository, not just accretion runs. Adding a column to the helpfile schema broke resume for any run started before it: the restored row lacks the column and the row writer rejects a row missing any schema key. Loading a helpfile now backfills whatever the current schema defines and the file does not carry, with zero, which is the right value for the cumulative ledgers this affects since nothing was recorded. That makes every future column addition safe for runs already in flight, rather than only this one.

The re-melt guard tested the wrong quantity. It compared the mean entropy of the two profiles, while the value it exists to keep out of the energy budget is a quadrature weighting each cell by volume and by density times temperature. Those weightings disagree with depth, so a profile that rises on average can still integrate to a heat loss and pass the guard. The test is now on the booked quantity itself. It also no longer aborts: a negative value usually means the mantle is already above the state the impact resets it to, which happens whenever two impacts land together or the initial condition shifts with the grown planet, so nothing is booked and the size of the discrepancy is reported. Only the booking was ever the problem.

Every resumed run past its first impact printed a warning saying those impacts would not be applied and advising a change to the time offset. On a resume both claims are wrong: the impacts were applied, their mass is restored from the ledger, and following the advice would accrete them a second time. The resume path now reports them as already applied. The restore itself no longer depends on the module still being selected, so continuing a run whose impacts are finished with accretion switched off keeps the mass the planet accreted.

The per-step impact heat is cleared where the row is created rather than in one solver's success branch, so a step on which that solver falls back to a retry cannot carry the previous step's injection forward and book it twice.

In the analytical module, the mass-radius scaling was extrapolated far below its calibration and returned impactors less dense than their own uncompressed minerals, down to about half the floor, which fed straight into the collision speed and the erosion law. The radius is now capped so bulk density cannot fall below the zero-pressure value of an iron and silicate mixture, which is the correct limit for a small body. The guard on an unusable timescale tested for exact underflow, so an ordinary configuration could still schedule impacts carrying a millionth of the accreted mass, each of which would re-melt the whole mantle; it now tests the delivered masses against a floor. An impactor heavier than its target is refused, since everything downstream treats the target as the surviving body. The chain also carries its eccentricity forward instead of computing each merger against a circular target and then discarding the result.

A configuration written for the old file-driven module under the name it used to have is now refused with a message naming its new home, rather than silently being served a generated timeline at default settings.
…jor axis

The semi-major axis moves by the fractional change an impact made, because the configuration owns the planet's orbit and a borrowed impact history should move it rather than replace it. The eccentricity did not: it took the followed body's absolute post-impact value. A user who set an eccentricity deliberately, most often for tidal heating, had it overwritten at the first impact, and with tides off the orbit is re-pinned from the configuration every step so it never recovered. Tidal heating goes as the square of eccentricity, so this was a large change applied silently.

Both elements now move by the change the collision made: the ratio for the semi-major axis, the difference for the eccentricity. A difference rather than a ratio because eccentricity is dimensionless and routinely zero, which a ratio cannot express. The result is clamped to a bound orbit, so an impact cannot drive a planet past unity on paper.

This needs the impact record to report the eccentricity on both sides of the collision, so the timeline gains an e_before column and the dynamical model reports it. The analytical module carries its own eccentricity through the chain rather than computing each merger against a circular target and discarding the result, which is the same inconsistency in a different place.

An accretion run needs a giant-impact model new enough to report the column; the version floor is bumped once that release is out.
The backfill added for resuming across a schema change filled in every missing column with zero, justified for the cumulative ledgers it was written for but applied to everything. Zero is a true statement about a ledger a file never recorded, and a specific wrong one about instantaneous state: a zero-filled surface temperature or planet mass reads as real to everything downstream and quietly poisons a resumed run, where the loud failure it replaced at least stopped. The fill is now scoped to a declared set of cumulative columns and anything outside it fails as before, naming what cannot be reconstructed.

A run already in flight when the accretion ledger appeared gets that column filled with zero, so the restore finds nothing and continues from the configured mass, which is the behaviour the ledger exists to prevent. It cannot be distinguished from a run that has genuinely not accreted yet, so both the reader and the restore now say what happened rather than leaving it to be inferred from a planet that quietly shrank.

The eccentricity clamp reports when it saturates. An impact asking for an orbit the model cannot represent is worth a line, and absorbing it silently is how a compounding drift in the applied change would hide for a whole run.

The analytical module refused an impact carrying too small a share of the accreted budget, which is a statement about how the budget was divided rather than about the collision. A large budget spread over many impacts onto a heavy planet still schedules collisions far too small to melt a mantle, so an impactor is now also required to be a meaningful fraction of the body it strikes.

Eight fixes across these commits had no test that failed against the code before them: the per-step impact-heat reset, the impactor-heavier-than-target guard, the density floor on small impactors, the eccentricity carried along the chain, the impact-mass floor at its discriminating boundary, the eccentricity clamp, the pre-impact eccentricity bound in the timeline validator, and the refusal of a timeline path aimed at the analytical module. Each now has one, and each was checked by removing the fix and confirming the test fails.

The validation page cited three tests as reference-pinned that carried no such marker. They pin against analytical limits and have earned it, so they now carry it.
…city

The coupling applies an impact's orbit change rather than the followed body's absolute orbit, which needs the eccentricity on both sides of the collision. That field arrived in 26.07.26, so the floor moves to it and the generated version table follows.

The installer derives its checkout tag from the same floor, so an editable checkout lands on the matching release without a second edit.
The planet schematic and the architecture flowchart both name every module group the framework runs, and neither showed accretion. Both now carry it, with the module chip linking to the Morrigan documentation the way the other chips link to theirs.

The colour is the ecosystem's accretion domain token rather than a new one. One hue per physical domain is held stable across the website, these figures and the module tables, so the mapping a reader learns in one place carries to the others; inventing a hue here would have broken that for the sake of a figure. The stylesheet gains the full domain palette alongside the brand colours it already had, plus the diverging phase ramp and the status colours, all matching the ecosystem tokens exactly so a future edit has one place to check against.

Subscripts on the flux labels are upright. The quantity is the variable and stays italic; the subscript names which flux it is, so it is a label rather than a variable and should not be set in italics.

Both the picture and the draw.io source inside each file are updated. They are two representations of the same figure and a change to one alone leaves the next person editing in draw.io silently dropping it.
The coupling loop in the code-architecture diagram now has an Accretion stage between Interior and Orbit & Tides, which is where an impact is applied, and the left column carries the accretion module group with Morrigan, Timeline and Dummy. The quantities the step hands on are named on the edge into Orbit & Tides, and the flux and state labels throughout the figure are set with real subscripts.

The diagram is generated from a model in tools/figures rather than drawn by hand. Both colour modes come from that one model, so the dark variant cannot drift away from the light one, and the whole figure rebuilds with pdflatex and poppler. A label takes its ink from the surface underneath it: on a chip whose colour is the same in both modes it keeps the ink it has in light mode, and only a label whose surface changes has its ink recomputed. tools/figures/README.md describes the model and the rebuild.

Module links that pointed into src/proteus/interior now point at interior_energetics and interior_struct, the escape backends point at the functions that implement them, and each loop stage points at the call site in proteus.py that runs it. Atmodeller and the three remaining dummy backends are linked as well.

The module schematic keeps its artwork, but its labels are native SVG text instead of HTML with a raster copy alongside, so a still export shows the same wording as a browser and the file is a third smaller.

The module list, the loop order and the model description gained the accretion step, including what the re-melt after an impact does and does not guarantee.
The accretion module now gets the same treatment as the long-established ones. There is a configuration reference page covering every `[accretion]` parameter and all three implementations, the model description explains what Morrigan does and what its statistical nature costs, and the module appears in the dummy-module table, the configuration guide list, the submodule links and the pin-maintenance table. A placeholder that described accretion as not yet implemented is gone.

The branch labels of the decision node sat centred on the very edges they name, so the feedback line and the exit line were drawn through the lettering. Both now sit clear to the right of their line. Every label in both figures is checked against every stroked shape, allowing for paint order so a line passing behind a chip does not count, and neither figure has a line crossing a label.

The labels the figure model adds carry their measured width, so an extent means the same thing for every label in it. The module schematic no longer holds a second, invisible copy of the accretion group inside the star icon's own viewport.
@timlichtenberg
timlichtenberg requested a review from a team as a code owner July 26, 2026 18:20
Comment thread tests/accretion/test_wrapper.py Fixed
Comment thread tests/accretion/test_wrapper.py Fixed
Comment thread tests/accretion/test_wrapper.py Fixed
Comment thread tests/accretion/test_wrapper.py Fixed
Comment thread tests/interior_energetics/test_wrapper.py Fixed
Comment thread tests/interior_energetics/test_wrapper.py Fixed
Comment thread tests/interior_energetics/test_wrapper.py Fixed
Comment thread tests/accretion/test_wrapper.py Fixed
The accretion docs now live in the Morrigan documentation itself, and the
tutorial figures are drawn against that release, so the floor moves to the
release that carries them.
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.47222% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.85%. Comparing base (873e471) to head (6943e72).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/proteus/accretion/wrapper.py 97.28% 6 Missing ⚠️
src/proteus/interior_energetics/aragog.py 88.23% 2 Missing ⚠️
src/proteus/accretion/dummy.py 98.78% 1 Missing ⚠️
src/proteus/interior_energetics/wrapper.py 98.52% 1 Missing ⚠️
src/proteus/utils/coupler.py 93.75% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #789      +/-   ##
==========================================
+ Coverage   92.61%   92.85%   +0.24%     
==========================================
  Files         111      117       +6     
  Lines       15913    17070    +1157     
  Branches     2856     3060     +204     
==========================================
+ Hits        14738    15851    +1113     
- Misses       1175     1208      +33     
- Partials        0       11      +11     
Flag Coverage Δ
unit-tests 84.45% <93.88%> (+0.43%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@nichollsh

Copy link
Copy Markdown
Member

@timlichtenberg we can discuss this at the meeting, but would you like me to review this PR at some point? What is the timescale?

@timlichtenberg
timlichtenberg requested a review from AnnaUlses July 27, 2026 08:26
Several test functions imported logging and init_accretion inside the function body even though both are already imported at module scope, so those statements did nothing. One test also imported proteus.accretion.wrapper under an alias purely to reach a patch target, which made it patch remelt_mantle through the module object while the three sibling patches in the same function used a dotted path.

That test now imports apply_impact directly and patches remelt_mantle by dotted path like the targets around it, so the alias is no longer needed. Behaviour is unchanged: both files pass in full, and the unit tier is unchanged at 2839 passed and 11 skipped.
@timlichtenberg

Copy link
Copy Markdown
Member Author

@nichollsh Yes, this would be good actually, thank you! I would like someone to have an independent eye on this. Not super urgent, we are basically testing this right now. The major focus should be on the framework integration, not physics, as we are still testing this and are probably going to add a few changes. But this PR basically brings in the whole scheme, and follow-up PRs would be much more confined.

Two things could leave a resumed run in a state the uninterrupted run never passes through, both of them where the giant-impact coupling meets the resume path.

The solidification latch was rebuilt by searching the whole stored melt-fraction history, on the basis that nothing ever clears it. A giant impact does clear it, because it re-melts the mantle to a magma ocean, so a run that crystallised, took an impact and carried on molten came back with outgassing frozen for the rest of the run. The search now starts after the last impact, and skips the impact's own row because that records the melt fraction from before the re-melt. Runs without accretion carry no accreted rock and search the whole history exactly as before.

The interior writes its snapshot while a step is solved, which is before the impacts falling in that step are applied at the end of it. A step that did both left a snapshot of the mantle from before the re-melt beside a helpfile row already carrying the impact's mass, orbit and volatile budgets, and resuming there restored a mantle the impact had melted while treating the impact as already applied. That snapshot is now discarded, so the resume walks back to the previous complete pair and applies the impact again in full. It is only discarded when an older one survives it: removing the last one would leave the run with no interior state at all, so that case is reported and the snapshot kept.

The scalar interiors are unaffected, since they carry their state in the helpfile row, which is already post-impact, and SPIDER is refused for accretion runs before the first impact.
Several of these tests checked only that a bad input was refused, without checking that the refusal says enough to act on, and one checked a clamp without checking the property that makes it a clamp.

The velocity floor now confirms both velocities are reported and pins the tolerance from both sides, so it tells an exact comparison apart from one wide enough to swallow a real error. The timescale guard confirms both named parameters and the spacing to aim for are quoted, and that a usable timescale yields finite positive masses, which is the underflow the guard exists to prevent. The record and outcome checks confirm the whole required set is listed rather than the first gap found. The zephyrus loss module confirms the message names the setting, the missing module and the fix, and that the same call is silent when no loss module is configured. The timestep test confirms a distant impact returns exactly the step the controller chose on its own, which is what makes the clamp one-way rather than something the timeline can steer.

Values quoted from error messages are matched on the number rather than its formatting, so rewording a message does not fail a test.

Also drops a monkeypatch of remelt_mantle on the accretion module. It is imported there inside the function from interior_energetics, so the module attribute it created was never read and the patch beside it was already doing the work.
The interior writes its snapshot while a step is solved, which is before the impacts falling in that step are applied at the end of it, so the loop drops that snapshot when a step does both. The condition has two halves and nothing tested either of them: it could be reduced to the impact alone, or to the write alone, or moved ahead of the impact, without a single test failing.

The new smoke test runs the real loop over a scheduled impact twice, once writing on every iteration and once with the write cadence set above the run length, and pins the discard to firing exactly once, at the impact time, with the accreted-rock ledger already carrying the impactor's mass. The run that writes on every iteration produces forty snapshots against its single impact, so a discard wired to the write alone is an order of magnitude away from the asserted count.

Both runs use the dummy interior, which writes no snapshot of its own, so this covers the loop's decision to call the discard rather than the file removal it performs.
Only Aragog writes an interior snapshot, so on every other interior the discard returns immediately and the resume has no interior half to walk back over. The accretion-plus-resume interaction was therefore not reachable from any existing test, even though it is the case the discard exists to protect: a run killed on the step that lands an impact.

The new slow test runs the real Aragog interior across a scheduled impact, stops the run on it, and resumes with nothing in the resume path mocked. It pins the impact step leaving no snapshot while the previous one survives, the resume landing on that previous pair and re-applying the impact, the accreted rock and the planet mass showing exactly one impact across both legs, and the mantle carried past the impact being the one the impact melted rather than the cooler mantle the discarded snapshot held. The initial condition is the molten one, so the re-melt adds heat and the warming step is a signal nothing else in the configuration can produce.

Two Aragog legs cost about ten minutes, so the file sits in the slow tier and runs in the nightly aragog shard on both platforms.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Accretion & delivery module

2 participants