Skip to content

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

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

timlichtenberg wants to merge 80 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.

Golden trajectory. This branch adds two helpfile columns, M_accreted_rock and step_dE_impact_J, and writes them on every run rather than only when accretion is on, so the recorded trajectory that test_integration_golden_run.py compares against no longer covered everything the run produces. It is recorded again here, on top of the reference #812 recorded for the four columns #809 added, so this one carries both sets rather than either alone. Nothing else about it moved: the run still produces 56 rows, the count goes 761 to 763, all 761 columns already recorded are identical to the bit with none differing, the configuration is untouched, and both new columns are zero down every row.

What that does and does not establish is worth stating, because re-recording a reference is exactly the step that can bury a regression. The golden configuration runs with accretion off, so the comparison never enters apply_impact, the stripping and delivery arithmetic, or the re-melt energetics. It establishes that this branch leaves a run without impacts completely unchanged, which is what protects everyone else's runs from it, and it now pins both new columns at zero on that path with zero tolerance. It is not a validation of the impact physics; that rests on the accretion unit tests, the smoke and slow accretion tests, and the coupled run reported above.

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 per-element ppmw budgets are fractions of the impactor mass and the rock an impact adds to the interior anchor is what is left once they are taken out, so budgets totalling 1e6 ppmw or more leave nothing to accrete and the collision removes rock from the planet instead of adding it. Five budgets at 3e5 ppmw each look modest individually and sum to 150% of the impactor, which loaded without complaint. Nothing downstream could catch it: the anchor and the volatile budgets are both updated, so whole-planet mass stays self-consistent and the mass-conservation check passes on a run whose accretion has gone backwards.

The budgets are now bounded on their total rather than per field, since a per-field ceiling passes exactly that case. The planet-matching mode composes its content at the moment of impact and never sees the config check, so apply_impact also refuses a negative rock remainder directly.

Two smaller corrections alongside. The test pinning where the impact-heat column is cleared compared against the first of two identical row copies, so moving the clear before the copy that creates the stepped row would have satisfied it while leaving every row carrying the previous impact's heat; it now requires a clear after every copy. The accretion configuration reference named only SPIDER as a refused interior, where the validator also refuses the boundary interior, which cannot be stepped onto the moment of an impact.
The impactor's volatile content is a fraction of its own mass while the rock left to accrete is taken from what the merge adds to the planet, and a timeline is accepted when the merged mass closes to a relative tolerance rather than exactly. A budget approaching the whole impactor therefore lands slightly negative on arithmetic alone, so refusing every negative remainder would abort runs whose configuration is fine: a 1:100 impactor carrying 999999 ppmw comes out at -6e18 kg with nothing wrong.

The remainder is now refused only when it is short by more than that closure tolerance, and clamped to zero within it. The tolerance is measured against the merged mass, which is what the closure check itself uses; a fraction of the added mass instead is a hundred times too tight for a small impactor and refuses the rounding it exists to allow. A genuine overrun stays three orders of magnitude clear of the band at every mass ratio.

Two test corrections alongside. The budget test asserted a comparison between two literals as its discrimination; it now shows each budget loading on its own, which is what makes the summed check the only one that catches five modest-looking budgets. The impact-heat ordering test carried an assertion the loop above it already made.
A giant impact grows the planet between two interior solves. Two things did not follow it.

The JAX right-hand side captures the mesh by value when its factory is installed, and the factory was installed once per process. After an impact the numpy mesh was rebuilt for the new structure while the JAX side kept integrating the old one, so the boundary-flux budget and the state-heat integral stopped describing the same planet. The gap shows up as a step in their ratio at every impact, flat in between, and it disappears if the run is restarted, because a restart rebuilds the factory. Record the geometry the factory was built against and rebuild it when the mesh no longer matches, which for a run without impacts never fires.

The core-temperature jump guard also treated the impact as a bad solve. The re-melt moves T_core by thousands of kelvin outside the solver, so the jump is identical at every step size and the retry ladder cannot reduce it; the run died at its first impact after burning six attempts. The guard now stands aside on the step a re-melt fires and keeps its full strength everywhere else, reusing the impact_reset flag the temperature clamps already honour. That flag is cleared before the interior solver runs, so the value is kept for the rest of the step.
The rebuild check sat at solver setup, where the mesh has not yet reached the geometry the step will integrate: the structure update propagates a step later, so the first solve after an impact still ran on the old right-hand side and only the second picked up the new one. Moving the check to immediately before the solve puts it where the mesh is final.

Measured on a case whose two impacts land inside the first 2000 yr: the booked-flux to state-heat ratio now reads 0.99999 on every ordinary step either side of both impacts, against 0.688 and 0.425 held indefinitely before. The one step after each impact still reads 0.74 and 0.60, which is the CMB term the per-call integrals over-count there, not this.
The option Z factory is now built more than once: it is rebuilt whenever the mesh moves under it. Its failure path was only ever correct for the first build, where there is nothing installed yet, so reporting a fall back to the finite-difference Jacobian is accurate. On a rebuild the solver still carries the factory built against the geometry the rebuild was meant to replace, and that factory stayed installed and in use while the log said the run had fallen back. The result is the failure this whole path exists to prevent: the solve keeps integrating the planet from before the structure changed, silently.

Clear the factory and the recorded geometry key together, so the solve-time check turns the path off rather than running it on a stale mesh, and so a later geometry comparison cannot measure itself against a factory that is no longer there.

The new test drives a rebuild to failure on a solver that already has a factory installed, and fails without this change.
The JAX right-hand side was built from a copy of the mesh taken when its factory was installed, so it kept describing the planet as it stood at that moment. A giant impact grows the planet, and Zalmoxis re-solves the structure as the mantle freezes; either one replaces the mesh, and the right-hand side went on integrating the old one while every other consumer saw the new one.

Comparing a stored geometry fingerprint against the current one, which is how I first approached this, only closes part of it. With mass coordinates the mesh pins its first and last node to the core and surface radii and solves every interior node from the density profile, so a structure change can leave both of those radii untouched and still move the whole interior, along with the pressure, gravity, area and volume arrays the right-hand side is built from. The fingerprint would report nothing had changed.

Read the mesh inside the factory instead, alongside the boundary conditions and the heating arrays, which are already reread there for the same reason. The factory runs once per solve, so the mesh can no longer be older than the step it describes, and the fingerprint and its refresh check are no longer needed.

The test asserts the mesh is read once per solve rather than once per install, and that the second solve sees a replaced mesh. It fails if the read is moved back out of the factory.
The factory was installed and then a purely diagnostic log line read the mesh radii, still inside the same try. Anything raising in that line reached the handler, which clears the factory, so a working install would have been torn down and the run quietly moved to the finite-difference Jacobian while reporting an install failure. Read the diagnostic values first and install last, so nothing that can fail runs after the install.

Also moves a test marker that was left on a helper rather than on the two tests it belonged to, and adds the case that defeated the earlier approach: a mesh whose cell count and both bounding radii are bit-identical while its interior has moved. Mass coordinates pin the first and last node and solve the rest from the density profile, so that case is what a fingerprint on those three numbers cannot see.
The P-S tables are rebuilt whenever the structure solve reruns, and a giant impact raises their pressure ceiling along with the planet's mass. Aragog kept the copy it loaded at startup, so once the planet outgrew that table the deepest cells were evaluated at its edge instead of at their own pressure. On a probe run growing 0.5 to 4.5 Earth masses the core-mantle boundary reaches 1.67 times the starting table's ceiling, 60 percent of the rows sit beyond it, and density there is understated by 15 percent.

Two things kept the stale copy in place. The loader's cache key was a list of file names and sizes, and a regenerated table keeps the same grid shape, so every file keeps its length and the key could not tell the new tables from the old. The key now uses the marker the generator already writes beside the tables, which records the pressure ceiling, the grid shape and the material. Second, the tables were read once when the solver was built; they are now read where the mesh is read, once per solve, so both follow the planet the step is about.

This closes the reload the structure-update path documented as missing for composition changes. The remaining piece there is unchanged: the entropy carried over from the previous step is still not bounds-checked against a regenerated range, which a composition change can move and a pressure-ceiling change cannot.
The interior solver kept whichever P-S tables it was built with at startup. Those tables are rewritten whenever the structure solve reruns, with a pressure ceiling that grows with the planet, so a run that outgrows its starting table went on integrating the old one. The right-hand side already follows the regenerated tables; this is the same reload for the state-heat integral, which is the other half of the energy budget and the quantity the conservation residual is measured against.

That makes it a correctness fix for the diagnostic rather than for the trajectory: on a planet that stays inside its starting table nothing changes, and on one that outgrows it the reported budget was being taken against tables that no longer describe the planet. The reload sits immediately before the solve, so the integral a step books is taken against the tables that step runs on, and the loader is cached on the table parameters so an unchanged table costs one small read.
The compression-work diagnostic inside solver.reset() evaluates the re-read mesh pressures against whatever table object the solver holds, so on the step after an impact regenerates the tables it was evaluated against the outgrown set, which clamps at the old ceiling. The refresh now runs before reset(), and the later refresh inside the retry ladder stays as a cheap cache hit.

Two pieces of wiring had no test: the ladder's refresh call (every ladder test ran with no solver wired, so removing the call changed nothing) and the translation of the one-shot impact flag into the per-step flag inside run_interior. Both are now pinned, the first with an ordering assertion that the refresh lands before the first solve, the second across two steps so one impact cannot exempt two.

The docstring on the refresh helper now names the consumers that actually read the live table object (the RHS, the per-call energy integrals, and the compression diagnostic) rather than the impact heat booking, which deliberately runs on the pre-impact tables. A wrapper comment claimed a raised pressure ceiling cannot move the entropy range of the regenerated tables; the range is scanned up to the ceiling, so it can, and the comment now says so. Four structural tests carried the physics-invariant marker without asserting an invariant and lose it, and the test file docstring now covers the contract areas the file grew.
The step that lands exactly on a scheduled giant impact is clamped
only to whatever simulated time remains before it. After a long
quiescent phase has let dt coarsen, that remaining time can itself be
large, so the step absorbing the impact's melt-fraction jump inherits
the same coarseness the run had just grown into. campaign-2 hit this
on probe18_capsoff_guardtip: an impact landed while dt had coarsened
to 1.06e6 yr, the remaining-time clamp only cut it to 2.14e5 yr, and
CVODE burned two failed attempts before succeeding at 4.27e4 yr.

I added dt.impact_maximum, an optional absolute ceiling on the
landing step, applied before the existing minimum-step floor so a
misconfigured ceiling smaller than the floor still can't collapse dt
to zero near an impact. It defaults to 0 (disabled), so no existing
run's behaviour changes unless I set it.

Reproduced the coarse-phase landing cheaply with the dummy interior
and the analytical accretion module: a proportional-dt run reaching
an impact after dt had grown to ~8e5 yr lands on it at ~3.35e5 yr
uncapped, and at the configured ceiling once impact_maximum is set.
Added unit tests for the new ceiling to test_timestep.py, including
that it never beats the minimum-step floor, and confirmed each one
fails against the unfixed clamp before passing against this change.

I have not reproduced the CVODE retry-ladder itself against the real
Aragog solver; the tests above verify the dt-sizing mechanism a
coarse-phase impact triggers, not the solver's response to it.
Resolve conflicts between the accretion coupling changes and the upstream entropy initial-condition fix in aragog.py, coupler.py, and the affected tests and docs.

Regenerate tests/integration/golden_run.tsv from a real run on the merged code. The new trajectory adds M_accreted_rock and step_dE_impact_J, both produced by this branch's own accretion energetics; the other 763 columns agree with the prior reference.

Mark test_zalmoxis_aragog_calliope_two_timesteps xfail(strict=False): a pre-existing temperature/entropy step cap stalls the solve near the first liquidus crossing before the test's 2-timestep budget completes. See #840.
step_dE_impact_J resets to zero every step and only differs on an
impact-event step, so a helpfile written before this column existed
never had a reason to hold anything else. Add it to
RESUMABLE_ZERO_FILL_KEYS alongside the run-accumulated columns, and
reword the comment to cover both cases zero can legitimately mean.
The resume test for a helpfile predating a schema column only exercised the two cumulative-ledger columns in RESUMABLE_ZERO_FILL_KEYS. Extending its absent-columns set to include step_dE_impact_J, the reset-to-zero-per-step member of that set, catches a regression where the key drops out of the frozenset: the resumed row would then be rejected as missing physical state instead of zero-filled.
The header comment and the ReadHelpfileFromCSV docstring described only the accumulating-ledger case, which does not cover step_dE_impact_J: that column resets to zero every step and only differs on a giant-impact step, so a file predating the event correctly reads as zero for a different reason. Both are reworded to state either mechanism, and the header comment is trimmed to fit the five-line comment limit.
The docstring called the backfill correct only for cumulative ledgers, which is now false for step_dE_impact_J: it resets every step and only differs on an event step, so it never accrues anything to begin with. State both mechanisms so the docstring matches what the test actually covers.
The Returns section and the runtime warning still called every RESUMABLE_ZERO_FILL_KEYS column a cumulative ledger, which the docstring's own kind paragraph already stopped claiming. step_dE_impact_J resets every step and only differs on an event step, so it never accrues anything a warning could call lost. Reword both to match, and restore the header comment's exclusion clause for state columns while spelling out that a reset-per-step key only belongs here if its column arrived with the event it tracks.
The docstring said only cumulative columns are zero-fillable, but
step_dE_impact_J resets every step and is not a ledger. Reword the
opening line and the zero-is-correct rationale to cover both
resumable mechanisms, matching the wording already used for the
sibling resume test above it.
step_dE_impact_J resets every step, so zero-filling a file that
predates it loses nothing. esc_kg_cumulative and M_accreted_rock
accumulate over a run, so zero-filling a file that predates either
column discards real prior escape or accretion mass that cannot be
recovered. Say so explicitly in ReadHelpfileFromCSV's docstring, the
resume warning, and the header comment above the frozenset, rather
than describing both mechanisms as equally lossless.

State the rule for adding a column to the set: it must fit one of
these two reasons, not just resemble the ones already there.
Both docstrings restated the reset-vs-accumulate distinction inline,
so a later correction to that distinction landed in coupler.py and
one test but missed the other, including a dangling "It" left over
from an earlier edit. Point both at ReadHelpfileFromCSV instead of
restating it, and fix the dangling reference.
restore_accretion_state logs on any M_accreted_rock <= 0.0, whether that is a genuine no-impact run or a backfilled zero, and it does not fire once accretion is disabled after impacts finish. It does not detect a zero-filled ledger. The clause also implied esc_kg_cumulative gets the same safety net; escape/wrapper.py has no resume loss-report at all, only an unrelated desiccation-baseline reset.

The docstring keeps its real, sufficient reason for zero-filling both columns: refusing would turn a routine schema addition into a run-killing failure on every in-flight run.
The reconcile merge combined two independent column additions, longitude/latitude from this branch and step_dE_impact_J/M_accreted_rock from main, without touching the declared column count comment. Bumped it from 765 to 767 and re-recorded the reference to confirm every value still reproduces exactly.
Merged origin/main (tip b3eee32, "Recover from CVODE step-budget
stalls by relaxing rtol") into tl/accretion-morrigan-coupling. Main
landed one more commit here after the previous reconcile, touching
the same retry-ladder method in aragog.py.

Combined both sides of the sanity guard rather than picking one:
kept this branch's terminal-event acceptance (status=1 with a
nonzero dt_actual) and the giant-impact exemption on the T_core jump
check, and took main's finite-value check plus the tcore_change_max-
aware jump measurement, which is stricter than the old bare endpoint
delta and is what the new fwl-aragog pin exposes. The exhaustion
error message now reports the actual guard trip on all three status
paths instead of a fixed jump-threshold string.

Took fwl-aragog>=26.09.09 from main (the pin the relaxed-rtol fix
needs) and kept fwl-zephyrus>=26.7.24 from this branch, mirrored in
the module_versions.md badge table.
_retry_ladder_runner's mock solver dropped rtol and max_steps, so the
retry ladder's getattr(solver, '_max_steps', solver.parameters.solver.max_steps)
raised AttributeError on the eagerly-evaluated default before the
step-budget ramp could even check the fallback. Added rtol, max_steps,
and _max_steps to the fixture.

_retry_runner built interior_o as a bare MagicMock, which auto-vivifies
any unset attribute as a truthy Mock. That made impact_reset_this_step
read True on every guard check, so the sanity-guard tests it drives
never exercised the guard they were meant to trip. Set the attribute
explicitly to False.

Also restored "T_core jump" in the sanity-reject message the last
merge dropped in favor of main's tcore_change_max wording, which broke
the two tests asserting on that phrase even though the guard itself
still fires correctly.
The finiteness check on the CMB temperature guard runs unconditionally
and the impact-step exemption only gates the jump-magnitude check, but
no test pinned that a NaN T_core is still rejected on an impact step.
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