Partition-law hook and strong-partition limit for phase-aware volatiles#67
Open
maraattia wants to merge 11 commits into
Open
Partition-law hook and strong-partition limit for phase-aware volatiles#67maraattia wants to merge 11 commits into
maraattia wants to merge 11 commits into
Conversation
Introduce the partition-law hook upstream of VolatileProfile so a
future strong-partition rule (w_liquid = X_i / phi_avg, w_solid = 0)
and the deferred D_const and solubility prescriptions share one
signature. This first commit ships the function shell, the config
key, the validator, and the tests; the physics for partition_rule
!= "uniform" lands in subsequent commits on this branch.
- mixing.apply_partition_rule(rule, X_bulk, phi_avg, *, pressure,
temperature, melt_fraction, D_const, solubility_fn, phi_floor)
returns (w_liquid, w_solid) compatible with VolatileProfile.
"uniform" returns (X_bulk, X_bulk) so the blend collapses to a
constant per-shell fraction; "strong", "D_const", "solubility"
raise NotImplementedError until wired in subsequent commits.
- config.py registers "partition_rule" in [EOS] with default
"uniform"; validator accepts ("uniform", "strong", "D_const",
"solubility") and rejects unknown values.
- input/default.toml documents the four rules; the key itself stays
commented out so existing TOMLs are byte-for-byte unchanged.
- 19 unit tests cover the dispatch, the dormant-path round-trip
through VolatileProfile.blend, the loader default, and an
explicit "strong" round-trip.
python -m zalmoxis -c input/default.toml produces planet_profile.txt
identical to the baseline; full unit tier (1124 passed, 1 skipped)
is green; ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace the NotImplementedError stub on partition_rule = "strong" with the strong-partition limit (w_liquid = X_i / phi_avg, w_solid = 0) and plumb the resulting VolatileProfile through the per-shell density evaluator so the rule actually affects the structure solve. Bulk X_i is read from the existing mantle EOS string fractions, so no new TOML keys are needed beyond the partition_rule itself. - mixing.apply_partition_rule grows the strong-partition branch with a graceful uniform fallback below phi_floor for ill-conditioned thin partially-molten shells. - mixing.split_mantle_volatile_inventory and mixing.build_partition_profile convert a parsed mantle LayerMixture into a VolatileProfile via the hook. - mixing.calculate_mixed_density and calculate_mixed_density_batch take an optional volatile_profile; when provided, per-component fractions are phi-blended per shell via VolatileProfile.apply_to_mixture against compute_melt_fraction(P, T, solidus, liquidus). Default behavior is byte-for-byte unchanged. - structure_model.solve_structure and coupled_odes thread the profile through, gated to the mantle layer. The JAX fast path forces a numpy fallback when a profile is set. - solver.py forwards volatile_profile at both solve_structure call sites and the Picard density-update batch site. New solver.solve_strong_partition mirrors solve_miscible_interior: it builds the profile from the current phi_avg guess, runs main(), integrates a mass-weighted mantle phi_avg from the result, and iterates to convergence on |delta phi_avg| < phi_tolerance. - output.post_processing dispatches to solve_strong_partition when partition_rule = "strong"; the default "uniform" path keeps calling main() directly. - Unit tests: 15 in TestApplyPartitionRule (strong-partition algebra, fully-molten and below-floor limits, mass conservation through VolatileProfile.blend), 6 in TestSplitMantleVolatileInventory, 4 in TestBuildPartitionProfile, 5 in TestSolveStrongPartitionOuterLoop (mocked main(), covering convergence, non-convergence, missing mantle, and main() failure). - Smoke (@pytest.mark.smoke) for a 1 M_earth wet-mantle run on the strong-partition path: outer loop converges in ~5 min with PALEOS data. - New input/grids/strong_partition.toml for the standalone 1-10 M_earth validation sweep. Verification: - python -m zalmoxis -c input/default.toml: planet_profile.txt byte-for-byte identical to the baseline on the default "uniform" config (the new code path is dormant). - Full unit tier: 1144 passed, 1 skipped (+20 net new tests). - ruff check and ruff format --check clean across src/ and tests/. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous floor (phi_floor=0.05) was a heuristic with no principled bound: for X_total >= 0.05 the strong rule would push w_liquid above the mass-fraction bound w_liquid <= 1 before the fallback engaged. For the smoke at 1 M_earth + 10% H2O the rule happened to stay safe because phi_avg lived near 1 throughout, but mostly-solid mantles (phi_avg approaching X_total) would have produced unphysical w_liquid > 1. Drop the phi_floor kwarg from apply_partition_rule, build_partition_profile, and solve_strong_partition; compute the effective floor internally as _STRONG_PARTITION_PHI_SAFETY * sum(X_bulk.values()). The constant is 1.2, which keeps the per-shell volatile fraction at most 1/1.2 ~ 0.83 in the most-molten shells. The floor scales with the bulk inventory, so a heavier volatile load (X_total = 0.4) raises the fallback boundary to phi_avg < 0.48 automatically. Three new unit tests pin the X-derived floor: the pivot is at safety * X_total, the floor scales with X_total (a phi_avg that activates the strong rule for light loads falls back for heavy ones), and an empty X_bulk yields a zero floor. Verification: - python -m zalmoxis -c input/default.toml: planet_profile.txt byte-for-byte identical to the prior commit (the new code path is reached only on partition_rule = "strong", which the default config does not exercise). - Full unit tier: 1146 passed, 1 skipped (+2 net new tests vs the prior commit). - ruff check and ruff format clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The mass-fraction bound w_liquid <= 1 requires phi_avg >= sum(X_bulk). The previous 1.2 safety factor added 20% headroom as if the bound were physical; it is not. Reduce _STRONG_PARTITION_PHI_SAFETY to 1.01 so the fallback floor sits just above the true bound, keeping only enough headroom to avoid the w_liquid = 1 singularity. Update the floor-value assertions and docstrings in test_mixing.py accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "Phase-aware volatile partitioning" section to the mixing docs. Frame partitioning phenomenologically: the apportionment of a volatile between solid and molten silicate is a modeling choice (partition_rule) expressed as a per-phase (w_liquid, w_solid) split that VolatileProfile blends per shell. Two rules are implemented (uniform, strong) with their constraints, plus D_const and solubility as reserved hooks. Note that the uniform and strong prescriptions give almost identical radii at the present EOS fidelity, so the rule matters for the chemistry coupling rather than for structure. Closes the docs item on issue #65. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Newton outer solver exists in the solver (refactor #59) but its config keys were never readable from TOML. Register outer_solver and wall_timeout as optional [IterativeProcess] keys, and add partition_rule, outer_solver, wall_timeout, relative_tolerance, and absolute_tolerance to the run_grid parameter map. This is what made the strong-partition validation grid deterministic (the Picard run carried a ~0.16 R_earth non-determinism noise floor that buried the signal). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The strong-partition validation grid is a one-off analysis artifact, not a shipped input. Move it (with its Newton and optimal-region variants and the Newton base config) into the project workspace under prj_zalmoxis/grid_validation_strong_partition/grids/, where the full analysis lives. The #65 PR references that analysis rather than carrying the grid in the solver repo. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve _PARAM_MAP conflict in run_grid.py by keeping both main's target_surface_pressure entry and the branch's IterativeProcess keys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR adds an opt-in partition-law interface upstream of VolatileProfile so mantle volatiles can be distributed between melt and solid phases as a function of melt fraction, and wires in the “strong partition” limit as the first implemented physical rule. It keeps backward compatibility by defaulting partition_rule to "uniform" (preserving the existing uniform mantle-fraction behavior).
Changes:
- Introduces
partition_ruleconfig plumbing ("uniform"default) and dispatch inoutput.post_processingto route"strong"to a dedicated outer solver loop. - Implements phi-aware mantle blending by threading an optional
volatile_profileinto density mixing and the structure solve; addssolve_strong_partition()to converge self-consistent mantle-averaged melt fraction. - Adds unit + smoke tests for the partition hook, strong-partition outer loop behavior, and config loading/validation; updates docs to explain the new interface and rule set.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/grids/run_grid.py | Exposes new config keys in grid runner parameter mapping. |
| tests/test_solve_strong_partition.py | Adds unit tests for the outer phi_avg loop and a PALEOS-gated smoke test. |
| tests/test_mixing.py | Adds unit coverage for partition hook, mantle inventory splitting, and profile builder. |
| tests/test_config_validation.py | Validates partition_rule accepted values and errors. |
| tests/test_config_loaders.py | Ensures loader defaults partition_rule to "uniform" and round-trips "strong". |
| src/zalmoxis/structure_model.py | Threads volatile_profile into ODE RHS and forces numpy fallback when JAX can’t support it. |
| src/zalmoxis/solver.py | Adds strong-partition outer loop and uses volatile_profile in density iteration paths. |
| src/zalmoxis/output.py | Dispatches solves based on partition_rule. |
| src/zalmoxis/mixing.py | Adds partition hook + builder and applies profile-driven per-shell fractions in mixing. |
| src/zalmoxis/config.py | Registers/loads partition_rule and additional iterative-process keys. |
| input/default.toml | Documents the new partition_rule option (opt-in). |
| docs/Explanations/mixing.md | Explains the partition-law hook, strong-partition rule, and extension points. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Closes #65.
Adds a configurable partition-law hook upstream of$X_i$ and a partition rule into the $(w_\mathrm{liquid}, w_\mathrm{solid})$ pair that
VolatileProfileand ships the strong-partition limit as the first physical rule wired through it. The hook maps a bulk interior mass fractionVolatileProfilealready consumes, so dissolved volatiles can live in the silicate melt instead of being smeared uniformly across the mantle. The new path is opt-in: a new config keypartition_ruledefaults to"uniform", which reproduces the existing single-EOS-string mantle behavior byte-for-byte, so every existing config keeps running unchanged. Settingpartition_rule = "strong"activates the new path.What landed, by commit:
e9c9938— partition-law hook signature and thepartition_ruleconfig key, registered inconfig.pywith default"uniform"and valid values("uniform", "strong", "D_const", "solubility"). The"D_const"and"solubility"rules raiseNotImplementedError; they are the documented extension points for the next issues.f40799f— strong-partition physics wired end-to-end:apply_partition_rulereturns numerical values (build_partition_profileconstructs aVolatileProfilefrom a mantleLayerMixture,calculate_mixed_densityand its batch variant gained an optionalvolatile_profilekwarg for per-shell phi-aware blending, andsolver.solve_strong_partitionis the outer self-consistency loop onoutput.post_processing.09bf448,c28c5d2— thedf22c9c—docs/Explanations/mixing.mddocuments the hook, the strong-partition rule, and where future partition rules plug in.850501d— registersouter_solverandwall_timeoutas[IterativeProcess]config keys and addspartition_rule,outer_solver,wall_timeout,relative_tolerance, andabsolute_toleranceto thetools/grids/run_gridparameter map. The Newton outer solver already existed from refactor Interior refactor: JAX path, Newton outer, robustness hardening #59 but those keys were not TOML-settable; exposing them is what made the strong-partition validation deterministic.bd24597— removes the strong-partition validation grid from the repo; it is a validation artifact, not a shipped input.Key touchpoints:
src/zalmoxis/mixing.py(hook,VolatileProfileblend,build_partition_profile),src/zalmoxis/solver.py(solve_strong_partition),src/zalmoxis/output.py(dispatch),src/zalmoxis/config.py(partition_ruleregistration),src/zalmoxis/structure_model.py, andinput/default.toml(the single-fraction EOS strings stay as the uniform path; the strong path is opt-in).Out of scope, tracked separately on #64: the$\mathrm{H_2}$ melt-partition / binodal-miscibility handoff, physically motivated partition coefficients and solubility laws, and the PROTEUS-side wrapper changes.
Validation of changes
Test configuration: Linux (kernel 6.8), Python 3.12, conda
proteusenvironment.Automated tests (all green locally):
@pytest.mark.unit):tests/test_mixing.py,tests/test_config_validation.py, andtests/test_config_loaders.pycover strong-partition mass conservation against the mantle integral, the fully-molten and fully-solid limits, the ill-conditioned thin-melt-shell fallback, thepartition_rule = "uniform"byte-for-byte no-regression against the current code path, and config validation accepting both rule names while rejecting unknown values.tests/test_solve_strong_partition.py::TestSolveStrongPartitionSmokeruns a single 1partition_rule = "strong"end-to-end and converges the outerStandalone grid validation (closed 2026-05-26, Newton-tier verdict): a 96-cell grid spanning 1–10$M_\oplus$ × {0, 5, 10, 20}% $\mathrm{H_2O}$ × {2000, 3000, 4000} K was run with the Newton outer solver ($7.1 \times 10^{-5}$ (90th percentile $3.7 \times 10^{-5}$ ). All 12 dry-pair null cells ($X_{\mathrm{H_2O}} = 0$ , where the strong path short-circuits to the uniform path) reproduce $R_\mathrm{strong} = R_\mathrm{uniform}$ to machine precision. In the active-subset cells where the strong rule cleared the $\phi$ floor, $|R_\mathrm{strong} - R_\mathrm{uniform}| \le 1.06 \times 10^{-6}$ $R_\oplus$ , and the $X_{\mathrm{H_2O}}$ and $T_\mathrm{surf}$ monotonicity checks pass at the $10^{-4}$ $R_\oplus$ tolerance.
relative_tolerance = 1e-9,wall_timeout = 1200). All 96 cells converged tightly, with mantle mass-conservation error at mostThe grid was run with the earlier floor$\phi_\mathrm{floor} = 1.2 \sum X$ . The subsequent reduction to the shipped $1.01 \sum X$ was checked directly: only the $X_{\mathrm{H_2O}} = 0.20$ column had cells in the affected band ($1.01 X \le \phi_\mathrm{avg} < 1.2 X$ ), and rerunning the two candidates ($M = 5$ $M_\oplus$ / $T_\mathrm{surf} = 3000$ K and $M = 10$ $M_\oplus$ / $T_\mathrm{surf} = 4000$ K) leaves both bit-identical between the uniform and strong paths, since their self-consistent strong melt fraction settles below the $1.01$ floor. The floor reduction flips no cell, so the active subset, the published $\Delta R$ table, and every conclusion above carry over unchanged.
Conclusion: the strong-partition implementation is verified correct, namely deterministic, mass-conserving, monotonic, and stable at low melt fraction. The partition law's signal does not live in the structural radius at the present EOS fidelity (the volume-additive density closure is approximately linear in volatile mass fraction); its impact is expected to surface in the chemistry coupling (surface speciation and melt-driven outgassing), not in$R(M)$ . This motivates the coupled-evolution direction rather than indicating a problem with the path.
The figures come from a focused follow-up I ran to check that this small signal is intrinsic, not an artifact of where the validation grid happened to sample. I first worked out analytically where the strong-partition radius signal should be largest. At fixed total water mass, moving water into the melt changes the planet volume in proportion to the mantle-mass-weighted covariance between the melt fraction$\phi(r)$ and the water–silicate specific-volume contrast, multiplied by a suppression factor that excludes vapor-phase water from the structural mixing. A nonzero signal therefore requires three conditions to hold in the same shells at once: a genuine melt-fraction gradient (a partially molten mantle, neither fully molten nor fully solid), water that is condensed rather than vapor (denser than the suppression-gate threshold), and water that is genuinely lighter than silicate at the local pressure. That argument, referred to as the heuristic below, predicts the signal is maximized for a low-mass planet (lower mantle pressures keep condensed water lighter relative to silicate), a partial-melt surface temperature (a real melt gradient rather than a uniformly molten mantle), and the largest water fraction that still clears the activation floor. That corner of parameter space is what the optimal region refers to; the two figures below probe it directly, and both confirm the signal stays far below any practical threshold there.
The two figures attached to this PR, from the optimal-region investigation, show why the radius signal is intrinsically small rather than merely under-sampled. The figure above traces the best active in-grid case (1$M_\oplus$ , $T_\mathrm{surf} = 2000$ K, $X_{\mathrm{H_2O}} = 0.20$ ). Across a single outer shell the water density jumps from vapor (about 0.1 kg m⁻³, one near-massless shell, below the 322 kg m⁻³ suppression gate) to condensed (about 470 kg m⁻³), then tracks within roughly 30% of silicate down to the core-mantle boundary where both exceed 12000 kg m⁻³, while the melt fraction falls from 1 at the surface to about 0.22 at depth. The mass-weighted signal contribution (bottom panel) is therefore confined to a thin shell at $P < 5$ GPa, where water has just condensed above the gate yet is still lighter than silicate, and it collapses to zero by about 10 GPa. There is essentially no mantle mass that is simultaneously molten, condensed, and volumetrically distinct from silicate, so concentrating water in the melt rearranges almost nothing the radius integral can see.
The figure above confirms this is not a sampling accident. Sweeping the three levers the heuristic identifies (planet mass 0.3 vs 1.0$M_\oplus$ , surface temperature 2000 vs 2500 K, suppression gate 322 vs 50 kg m⁻³) at fixed $X_{\mathrm{H_2O}} = 0.20$ leaves every configuration at $|R_\mathrm{strong} - R_\mathrm{uniform}| \le 2.3 \times 10^{-7}$ $R_\oplus$ , at or below the integrator-noise floor and at least three orders of magnitude under the $10^{-3}$ $R_\oplus$ modeling bar. The fully molten 0.3 $M_\oplus$ / 2500 K cells collapse to about $10^{-10}$ $R_\oplus$ because the strong rule reproduces uniform once $\phi(r) = 1$ everywhere, and relaxing the gate from 322 to 50 kg m⁻³ changes nothing, since the vapor-to-condensed transition is a near-discontinuity in this EOS and the density window the relaxed gate would rescue holds no mantle mass. The actionable consequence is that a measurable melt-localized-volatile radius signature needs a treatment that keeps dissolved water volumetrically distinct from silicate at mantle conditions (an explicit low-density hydrous-melt phase or a partial-molar-volume model), which is exactly what the deferred
D_constandsolubilityrules are positioned to carry.Checklist
@timlichtenberg