diff --git a/tests/integration/test_integration_resume.py b/tests/integration/test_integration_resume.py index a3abdca78..de8929650 100644 --- a/tests/integration/test_integration_resume.py +++ b/tests/integration/test_integration_resume.py @@ -813,3 +813,173 @@ def test_resume_drops_retired_helpfile_column(tmp_path): f'rows appended after resume hold NaN in {nan_columns}; the retired column ' 'rode along into the concat instead of being dropped first' ) + + +# Leg 1 stays molten so the resume row is hot; leg 2 cools but stops short of +# the solidus, so only the on-resume restore can set the flag. +CRYST_LEG1_STOP_TIME = 3.0e3 +CRYST_LEG2_STOP_TIME = 1.2e4 + +# Bulk escape rate for the scenario [kg s-1]. Nonzero so the escape branch +# runs and the frozen path (atmosphere-only escape) is exercised, small +# enough that its per-step mass loss is negligible against the atmosphere. +CRYST_ESCAPE_RATE = 1.0e6 + +# Float-noise band for the across-seam non-increase check on the atmospheric +# hydrogen inventory. A frozen mantle does not degas, so the inventory only +# falls by escape; on a correct restore the largest across-seam step is a fall +# of about 9e-9 relative, so this rejects any degassing rise while absorbing +# round-off. +CRYST_ATM_NOISE_RTOL = 1.0e-6 + +# Largest fraction of the resume-seam atmosphere the frozen run may lose. +# Escape removes only a negligible fraction here (about 8e-6 of the baseline), +# so a loss past this bound means the atmosphere collapsed rather than that +# escape ran. This is the lower-bound guard; a cleared flag instead grows the +# inventory and trips the non-increase check above. +CRYST_ATM_LOSS_BOUND = 1.0e-2 + + +def _make_freeze_runner(output_dir, stop_time): + """Build a runner that freezes volatiles once the mantle is solid. + + Extends :func:`_make_runner` with the two settings the + crystallization-on-resume scenario needs: volatile freezing enabled, so a + mantle that once crystallized stays frozen for the rest of the run, and a + nonzero dummy escape rate, so the escape branch runs and the frozen escape + path is exercised. The escape reservoir stays at its ``'outgas'`` default; + a frozen mantle forces atmosphere-only escape whatever the reservoir + setting is, so no override is needed to reach that path. + + Parameters + ---------- + output_dir : pathlib.Path + Absolute run directory, shared by both legs. + stop_time : float + Maximum simulation time for this leg [yr]. + + Returns + ------- + Proteus + Configured runner; the caller starts it. + """ + runner = _make_runner(output_dir, stop_time) + runner.config.params.stop.solid.freeze_volatiles = True + runner.config.escape.dummy.rate = CRYST_ESCAPE_RATE + return runner + + +@pytest.mark.integration +@pytest.mark.physics_invariant +def test_resume_restores_crystallization_across_remelting(tmp_path): + """A resume restores the crystallization flag from the whole history. + + Physical scenario: a magma-ocean planet crystallizes early, then remelts, + so its stored history holds a solid row followed by molten ones with the + final row molten. With volatile freezing enabled, a mantle that once + crystallized stays frozen for the rest of the run, so the restored state + on resume must reflect the whole history and not only the resumed row. + + Reading only the resumed row clears the flag in exactly this case: the + resume row is molten, so the flag comes back False, the volatile branch + treats the mantle as molten again, and the melt degasses into the + atmosphere. The restore reads the whole loaded history instead, keeps the + flag set, and the frozen mantle stops feeding the atmosphere. + + Verifies, on unmocked dummy backends with no patching in the call chain: + - The stored history is a genuine remelting record: an early row is below + the crystallization fraction while the resume row is above it. + - The crystallization flag comes back set after the resume. + - The downstream consequence holds: the atmospheric hydrogen inventory + changes by escape only across the resume seam, so it does not grow and + loses at most a negligible fraction. A resume that cleared the flag + would run outgassing and grow it several-fold, which the non-increase + check rejects; the loss bound rejects a collapse to near zero. + - Escape is active over the resumed leg, so the frozen escape path + (atmosphere-only escape, forced by the frozen mantle) is exercised + rather than skipped. + + Scope and boundary. The dummy interior splits melt and solid by an + algebraic rule and writes no interior snapshot, so this test covers the + control-flow and reservoir-bookkeeping consequence of restoring the flag, + not the magnitude of a physically faithful melt/solid partition. The + remelting history is constructed on disk because the dummy cools + monotonically and cannot remelt itself, the same on-disk-construction + approach as :func:`test_resume_drops_retired_helpfile_column`. Coverage + of the partition magnitude belongs with an interior module that resolves + melt/solid volatile exchange and is not attempted here. + """ + outdir = tmp_path / 'crystallization_resume' + leg1 = _make_freeze_runner(outdir, CRYST_LEG1_STOP_TIME) + phi_crit = float(leg1.config.params.stop.solid.phi_crit) + leg1.start(resume=False, offline=True) + n_leg1 = len(leg1.hf_all) + assert n_leg1 > leg1.loops['init_loops'] + 1, ( + f'first leg produced only {n_leg1} rows, too short to resume from' + ) + + helpfile = outdir / 'runtime_helpfile.csv' + assert helpfile.is_file(), 'first leg left no helpfile on disk' + stored = pd.read_csv(helpfile, sep=r'\s+') + # Inject the solid marker on the first post-init evolution row (Time>0), + # not an init row (Time=0.0), so it stands for real early crystallization. + evolution_rows = stored.index[stored['Time'] > 0.0] + assert len(evolution_rows) > 1 and int(evolution_rows[0]) < len(stored) - 1, ( + f'stored history has too few post-init rows ({len(evolution_rows)}) to ' + 'inject an early solid row and keep a later molten resume row' + ) + inject_row = int(evolution_rows[0]) + # The dummy cools too little over the short first leg to crystallize on + # its own, so force one early row solid to build the remelting history. + stored.loc[inject_row, 'Phi_global'] = phi_crit / 2.0 + injected_phi = float(stored['Phi_global'].iloc[inject_row]) + resume_phi = float(stored['Phi_global'].iloc[-1]) + assert injected_phi <= phi_crit < resume_phi, ( + f'injection did not build a remelting history: injected Phi {injected_phi} ' + f'and resume Phi {resume_phi} do not straddle phi_crit {phi_crit}' + ) + stored.to_csv(helpfile, index=False, sep='\t', float_format='%.10e') + + leg2 = _make_freeze_runner(outdir, CRYST_LEG2_STOP_TIME) + leg2.start(resume=True, offline=True) + + appended = leg2.hf_all.iloc[n_leg1:] + assert len(appended) > 0, 'resume appended no rows to advance from' + # The resumed leg cools but must stay above the solidus, so nothing during + # leg 2 latches the flag; only the on-resume restore can set it. + assert (appended['Phi_global'] > phi_crit).all(), ( + 'the resumed leg crystallized on its own, so a set flag no longer ' + 'isolates the on-resume restore' + ) + + assert leg2.crystallized, ( + 'resume cleared the crystallization flag; the restore read only the ' + 'resumed row instead of the whole history' + ) + + assert (appended['esc_step_kg'] > 0).any(), ( + 'escape never ran over the resumed leg, so the frozen escape path was not exercised' + ) + + # The frozen mantle no longer degasses, so run_crystallized changes the + # atmospheric hydrogen inventory by escape only: across the resume seam it + # falls and never rises, and it loses only a negligible fraction. + atm_baseline = float(leg2.hf_all['H_kg_atm'].iloc[n_leg1 - 1]) + assert atm_baseline > 0.0, ( + 'resume-seam atmosphere holds no hydrogen, so the inventory check has ' + 'no baseline to measure against' + ) + # Include the last pre-resume row so the first post-resume step is measured + # against the seam value, not divided away. + seam_atm = leg2.hf_all['H_kg_atm'].iloc[n_leg1 - 1 :].to_numpy() + atm_steps = np.diff(seam_atm) + assert (atm_steps <= atm_baseline * CRYST_ATM_NOISE_RTOL).all(), ( + f'atmospheric hydrogen rose by up to {float(atm_steps.max()):.3e} kg across ' + 'the resume seam; the frozen mantle degassed, so the crystallization flag ' + 'was not restored' + ) + assert float(seam_atm.min()) >= atm_baseline * (1.0 - CRYST_ATM_LOSS_BOUND), ( + f'atmospheric hydrogen fell to {float(seam_atm.min()):.3e} kg after resume, ' + f'below {1.0 - CRYST_ATM_LOSS_BOUND:g} of the {atm_baseline:.3e} kg baseline; ' + 'escape-only removal from a frozen mantle cannot lose this much' + )