diff --git a/.github/.claude/rules/calliope-code-review.md b/.github/.claude/rules/calliope-code-review.md deleted file mode 100644 index 2f009200..00000000 --- a/.github/.claude/rules/calliope-code-review.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -description: CALLIOPE-specific code review criteria for the generator-evaluator pattern. Applies domain expertise (chemistry, fO2 buffers, solubility, PROTEUS coupling) to all code review in this repo. ---- - -# CALLIOPE Code Review Criteria - -When reviewing CALLIOPE code (either your own or via code-reviewer agents), apply these domain-specific checks in addition to standard code quality review. - -> **Discovery note.** CALLIOPE keeps its Claude-Code rule files under `.github/.claude/rules/` (not the conventional repo-root `.claude/`) so they can be tracked in git and shared across collaborators. Claude does NOT auto-discover them at this path; the repo-root `CLAUDE.md` (symlinked to `.github/copilot-instructions.md`) names this file and `calliope-tests.md` explicitly. **Before opening any review pass, read both this file and `calliope-tests.md`.** - -## Physics plausibility - -- Temperature must be positive everywhere (Kelvin). Flag any code path where T could reach zero or go negative. -- Total pressure must be positive; partial pressures must be non-negative and sum to total pressure within solver tolerance. -- Mole fractions must sum to 1.0. Flag any composition-returning function that doesn't enforce or verify normalization. -- Henry's-law solubility outputs must be non-negative. Flag any solubility law that could return a negative or `nan` partial pressure for a physically valid input. -- `log10(fO2)` outputs must be finite (no `nan`, `inf`, or `complex`). Flag any buffer evaluation that does not clip / sanitize before return. -- Equilibrium constants `Keq` must be positive. The modified-Keq formulation in `chemistry.py` returns `log10(Keq)`; verify the exponentiation step has not been accidentally inverted. -- Mantle mass closure: `M_mantle = M_planet - M_core`; flag any path that lets `M_mantle <= 0` reach return. - -## Unit convention boundaries - -CALLIOPE has a mixed unit convention: - -- **Solver inputs**: T in K, P in bar, mass in kg, mole fractions dimensionless. -- **Henry's-law constants**: literature values in `(mol / kg) / bar^n` (variable `n` per fit); the conversion to internal units lives in `solubility.py`. -- **Oxygen fugacity**: `log10(fO2)` (dimensionless) and `fO2_shift_IW` (dex relative to the IW buffer). -- **PROTEUS interop**: the returned dictionary uses kg for species masses, bar for partial pressures, K for temperatures. - -When reviewing code that crosses these boundaries (e.g. a new solubility law, a new buffer fit, a new PROTEUS-side caller), verify the unit is correct at each conversion site. The `bar` vs `Pa` boundary is the recurring trap: literature fits are nearly always in bar, internal SI bookkeeping is in Pa. - -## Buffer-default flip safety - -When the default value of a dispatched-by-name physics path (IW buffer, modified-Keq formulation, solubility law) is changed, the change has fan-out across: - -1. Tests pinning a buffer-specific reference value (e.g. `EARTH_VOLATILE_O_REF_KG` was tied to the O'Neill 2002 default and had to update when the default flipped to Fischer 2011). -2. Documentation pages citing the buffer-specific number (the cross-backend comparison, the oxygen-fugacity reference page, the tutorials). -3. PROTEUS-side tests pinning against CALLIOPE outputs. -4. Frozen reference fixtures used by the cross-backend harness. - -Required workflow for any buffer / law default flip: - -1. `git grep` the old buffer / law name; update every reference value tied to it. -2. Update test discrimination guards (Section 2 rule 4 of `calliope-tests.md`) so the test would fail loudly under the wrong-default regression. -3. Update `docs/Explanations/cross_backend_comparison.md` and any tutorial that quotes a buffer-specific number. -4. Note the flip explicitly in the release notes. - -A PR that changes a default value but does not touch the test reference values is a red flag during review. - -## Solver intermediate-state types - -`solve.py`'s root-finder and equilibrium solvers operate on intermediate vectors that can drift through `complex` / `nan` / `inf` during iteration. The current source has `clip` hardening against these; flag any new solver code path that: - -- Does not check `np.isreal` and `np.isfinite` on intermediate state before passing to a downstream step. -- Catches `RuntimeWarning` from numpy without preserving the warning category in a log line, or silences it altogether. -- Coerces a complex intermediate to a real float without first asserting the imaginary part is negligible. - -The unit tests of `solve.py` verify the intermediate-state defenses fire (see `calliope-tests.md` Section 16); the code review's job is to make sure the defenses are present before the test asks them to fire. - -## PROTEUS coupling patterns - -CALLIOPE is called by PROTEUS through the chemistry / outgassing step. Four coupling patterns need explicit care during review. - -### 1. Authoritative-O IC reconciliation - -Under Path C (`fO2_source = "from_O_budget"`), PROTEUS supplies an authoritative oxygen mass `O_kg_total` and CALLIOPE inverts the IW shift that recovers it. PROTEUS stashes the user-supplied `O_kg_total` as `O_kg_user_ic` in `hf_row` before the first CALLIOPE call; the runtime helper `check_ic_oxygen_budget` compares CALLIOPE's solver-derived `O_kg_total` against the sentinel and hard-fails on >50% divergence. - -Any change to `solve.py`'s authoritative-O path (`equilibrium_atmosphere_authoritative_O`) must preserve this contract: - -- The return dict MUST contain `O_kg_total` (the solver-recovered value, used by PROTEUS to verify the reconciliation). -- The return dict MUST NOT silently substitute a clipped or fallback value for `O_kg_total` without surfacing the substitution in the return dict (e.g. via a `O_kg_total_clipped` flag). - -A regression that breaks reconciliation will surface as a >50% divergence hard-fail in PROTEUS; the test on the CALLIOPE side must catch it before it ships. - -### 2. fO2_shift_IW echo-back pattern - -PROTEUS sometimes overrides `hf_row['fO2_shift_IW']` (e.g. when iterating Path C) and must restore the original value after the CALLIOPE call. CALLIOPE returns `fO2_shift_IW_derived` as a separate key so the user-supplied and solver-derived values are distinguishable in the helpfile CSV. - -Required for any change that touches the chemistry-step return contract: - -- The return dict MUST keep `fO2_shift_IW_derived` distinct from `fO2_shift_IW`. -- A new field that overloads the meaning of `fO2_shift_IW` (e.g. silently treating it as a target rather than a user input) is a red flag. - -The save/restore pattern lives on the PROTEUS side; CALLIOPE's responsibility is to not silently mutate the input. - -### 3. Per-species mass closure across modules - -PROTEUS's mass-conservation invariant `M_atm <= M_planet` depends on CALLIOPE summing per-species masses (`H2O_kg_total`, `CO2_kg_total`, etc.) consistently. A regression in CALLIOPE's `_kg_total` aggregation (e.g. forgetting the oxygen contribution from H2O when O is treated as a separate element) would silently violate the PROTEUS-side invariant. The asymmetry between "elements as buffered reservoirs" (chemistry view) and "elements as tracked masses" (PROTEUS view) is the lesson from PROTEUS issue #677. - -Flag any chemistry-step code that: - -- Adds a new species without updating the corresponding `_kg_total` aggregation. -- Treats oxygen as a separate accounting entity from H2O / CO2 / SO2 in a way that changes the per-species totals. -- Returns a per-species total that is not the sum of `kg_atm + kg_liquid + kg_solid` for that species. - -### 4. Buffer-default flip impact on downstream PROTEUS - -A buffer-default flip in CALLIOPE (the 2026-05 Fischer-vs-O'Neill flip is the canonical example) changes the IW value silently for any PROTEUS run that uses the CALLIOPE default. PROTEUS-side tests that pin a specific IW value are sensitive to this. The rule: - -- Announce buffer-default flips in CALLIOPE's release notes. -- Bump the CALLIOPE version pin in PROTEUS's `pyproject.toml` to the post-flip release. -- Run PROTEUS's unit + smoke suite against the new CALLIOPE before merging the version bump. -- Update any PROTEUS-side hardcoded IW value tied to the old default. - -A CALLIOPE PR that flips a default but does not anticipate the PROTEUS-side fallout is a red flag during review. - -## Config mutability - -`Config` (or any dataclass / attrs object) used to carry user input must not be mutated at runtime after IC. Flag any code that sets `config.X.Y = value` outside of config initialization. Use local variables instead. - -## Cross-module constant duplication - -Physical constants (`R_gas`, `M_earth`, `R_earth`, `N_avogadro`, `M_O`, `M_H`) are defined in `src/calliope/constants.py`. When reviewing code that uses a physical constant, check that the import is from `calliope.constants` and not re-derived. A new constant introduced as a literal in a body (e.g. `5.4e-26 * T` for a Boltzmann-related coefficient) is a red flag. - -## Test marker discipline - -Every test file must begin with a module-level `pytestmark = [pytest.mark., pytest.mark.timeout()]` (unit/30 s, smoke/60 s, integration/300 s, slow/3600 s, or four times the slowest CI duration rounded up to a full minute when that test takes 7.5 s or more). Per-function markers are additive but do not replace the module-level marker; CI runs `pytest -m "(unit or smoke) and not skip"` and any file missing the tier marker ships untested. - -## Test quality (cross-reference) - -Test-content rules (anti-happy-path, discriminating-value guards, physics-invariant tiering, `physics_invariant` / `reference_pinned` certification markers, adversarial-review trigger, mocking discipline, `importorskip` + module-constant-monkeypatch traps, buffer-flip propagation, hypothesis seed stability, solver intermediate-state assertions) live in [`calliope-tests.md`](calliope-tests.md). When reviewing tests, apply both files: this one for marker discipline and review-pass gate, the deep-dive for the content contract. - -## Sister rules (cross-link) - -- [`.github/copilot-instructions.md`](../../copilot-instructions.md) "Testing Standards" -- high-level rules visible to all readers. Repo-root `CLAUDE.md` is a symlink to this file. -- [`calliope-tests.md`](calliope-tests.md) -- test quality deep-dive; the canonical source for anti-happy-path patterns and the validation certification markers. diff --git a/.github/.claude/rules/calliope-tests.md b/.github/.claude/rules/calliope-tests.md deleted file mode 100644 index 61e4b003..00000000 --- a/.github/.claude/rules/calliope-tests.md +++ /dev/null @@ -1,395 +0,0 @@ ---- -description: CALLIOPE test quality deep-dive. Anti-happy-path patterns, discriminating-value guards, physics-invariant tiering, validation certification markers, adversarial-review trigger. Extends the Testing Standards section in `.github/copilot-instructions.md`. ---- - -# CALLIOPE Test Quality Rules - -This file is the canonical deep-dive on test quality. The high-level summary lives in [`.github/copilot-instructions.md`](../../copilot-instructions.md) under "Testing Standards". The two files MUST stay in sync. If you change one, mirror the change in the other. - -> **Discovery note.** CALLIOPE keeps its Claude-Code rule files under `.github/.claude/rules/` (not the conventional repo-root `.claude/`) so they can be tracked in git and shared across collaborators. Claude does NOT auto-discover them at this path; the repo-root `CLAUDE.md` (symlinked to `.github/copilot-instructions.md`) names this file and `calliope-code-review.md` explicitly so AI tooling and human readers know to load them. **When opening or editing any file under `tests/**` or `src/calliope/**`, read this file first.** - -Sister rule files: - -- [`.github/copilot-instructions.md`](../../copilot-instructions.md): high-level rules, applied repo-wide. -- [`.github/.claude/rules/calliope-code-review.md`](calliope-code-review.md): review-pass gate, domain-aware code review (buffer-flip propagation, solver intermediate-state types, PROTEUS-coupling patterns). Test-marker discipline lives there too. - -CALLIOPE is scientific simulation code and the test suite is held to physics-grade rigor. Tests exist to catch real bugs. A test that asserts the wrong thing, or that passes for the wrong reason, is worse than no test because it generates false confidence. The rules below codify what "real test" means here. - ---- - -## 1. Anti-happy-path rules (every new test) - -Every new test function MUST include: - -1. **At least one edge case**: a boundary value (Phi = 0 or 1, T = T_solidus, P = 0, fO2_shift_IW = 0), an empty input, or an extreme physical parameter. -2. **At least one path that exercises the error contract**: - - If the function under test has documented validation (raises on negative T, refuses to dispatch with an unknown buffer name), test that the error fires AND that no side effect ran. - - If the function has no validation (closed-form mathematics: thermodynamic relations, equilibrium constants), exercise the **limit-input behavior** (single-species composition is a degenerate fixed point of the multi-species solver) and assert the corresponding mathematical invariant. - - "No validation in source therefore no error test" is not an exemption; the limit-input substitute is. -3. **Assertion values NOT trivially derivable from the implementation**: discriminating numeric pins (see Section 2 below) or property-based assertions (monotonicity, conservation, symmetry, boundedness). - -### Forbidden patterns - -These are flagged by `tools/check_test_quality.py` and rejected at PR time. - -- **Single-assert test functions**. Two or more assertions per test; the second usually pins the invariant the first hand-waves over. Exception: a single assertion of a hard-fail invariant (mass closure within `1e-12`) is acceptable if the test is the only test of that invariant in the file. -- **Weak assertions when they stand alone as the sole meaningful check in the test.** The shapes are: - - `assert result is not None` - - `assert result > 0` - - `assert len(result) > 0` - - `assert isinstance(result, dict)` - - `assert result is None` where the function returns `None` implicitly - - Required carve-out: the three-class discrimination guard (Section 2) uses `assert val > 0` as the sign-error guard and `assert lo < val < hi` as the scale-error guard alongside a primary `pytest.approx(...)` pin. Those secondary lines look like weak assertions in isolation; they are NOT flagged when paired with a stronger primary assertion in the same test. The linter applies the carve-out automatically: weak shapes are flagged only when the test has exactly one `assert` statement (`len(asserts) == 1`) and that assertion is itself the weak shape. -- **Tests with no function-level docstring**. The docstring states which physical scenario or contract clause is being verified. -- **`==` adjacent to a float literal**. Use `pytest.approx(val, rel=...)` or `np.testing.assert_allclose(actual, expected, rtol=..., atol=...)`. Comparing two floats with `==` is a known flake source even for "exact" identities like 0.0 (-0.0 vs +0.0, NaN propagation). -- **Tests asserting on a fixture's implicit default**: e.g. `assert fixture_returning_none() is None`. This is trivially true. Delete the test; do not strengthen it by adding more `is None` assertions. - ---- - -## 2. Discriminating test values - -The test contract is: a regression that introduces a plausible bug must fail the test. "Plausible bug" means off-by-one exponent, wrong sign, swapped factor of 2, missing factor of pi, dimensionally-wrong unit, **wrong-buffer / wrong-law selection**. Pick input values where the wrong-formula result is far from the correct one. - -### Bad / good examples - -| Pattern | Bad (any-exponent-passes) | Good (discriminates) | -|---|---|---| -| `log10(fO2) = A/T + B` (IW buffer) | Test at `T = 1500` only (degenerate against any reasonable A) | Test at `T = 1500` AND `T = 2500`; assert the difference matches the Fischer-vs-O'Neill slope ratio | -| Henry's-law solubility | Composition all equal (1/N each, symmetric) | Asymmetric composition (one dominant species + traces) so a swapped Henry constant changes the dominant species more than the test tolerance | -| Equilibrium constant interpolation | Test at grid nodes (interpolation is identity there) | Test at off-grid temperatures where bilinear vs nearest-neighbor differ | -| Stoichiometric closure | One species at unit pressure (the closure is trivial) | Multi-species with non-trivial fO2_shift_IW so each Keq matters | - -### Discrimination guard (REQUIRED for pinned-value tests) - -When a test pins a numeric value, include explicit assertions that the wrong-formula result would differ from the correct one for **each plausible bug class**. "Each plausible bug class" means at minimum: - -1. **Exponent or factor error** (off-by-one exponent, missing factor of 2 / pi). `abs(val - wrong_value)` discriminates. -2. **Sign error** (`-x` vs `+x`). `abs()` hides this; assert the sign explicitly with `val > 0` or `val < 0`. -3. **Unit-conversion error** (Pa vs bar, K vs C, log10 vs ln). Pin the absolute scale with the unit named in the comment. -4. **Wrong-buffer / wrong-law selection** (Fischer vs O'Neill, Dasgupta vs Iacono-Marziano). When the function dispatches by name, the discrimination guard MUST include a value that distinguishes the chosen path from a sibling path. - -**Carve-out for conservation-style invariants.** When the primary assertion IS a conservation closure (mass closure, energy balance, sum-equals-total), the equality form `sum(parts) == pytest.approx(total)` already discriminates exponent / factor errors by construction. The exponent guard is satisfied by the conservation equality itself; sign and scale guards remain mandatory. - -Canonical pattern: - -```python -def test_iw_buffer_fischer_at_3000K_matches_published_value(): - """Pin IW(Fischer 2011) at T = 3000 K against the original Table 1 fit.""" - val = oxygen_fugacity.iw_buffer('fischer', T=3000.0) - expected = -6.57 # log10(fO2) at the IW buffer, Fischer+2011 Table 1 - assert val == pytest.approx(expected, rel=1e-3) - # Wrong-buffer discrimination: O'Neill 2002 at the same T gives ~-7.52. - # A regression that silently dispatches to 'oneill' instead of 'fischer' - # would land outside the tolerance. - wrong_oneill = -7.52 - assert abs(val - wrong_oneill) > 0.3 - # Sign guard: log10(fO2) at IW is always negative under standard conditions. - assert val < 0 - # Scale guard: order of magnitude is -7, not -70 (forgotten log10) or - # -0.7 (factor-10 unit slip). Pin the magnitude. - assert -10 < val < -3 -``` - -The guard lines are mandatory whenever the test's primary assertion is a `pytest.approx` against a hand-calculated or published value. Property-based assertions (monotonicity, conservation, symmetry) do not need a separate guard because they are already discriminating across the input space. - ---- - -## 3. Physics-invariant assertions (tiered) - -### When required - -Every unit test on a **physics module** must assert at least one of the four invariants below. Physics modules are: - -``` -src/calliope/chemistry.py -src/calliope/oxygen_fugacity.py -src/calliope/solubility.py -src/calliope/solve.py -src/calliope/structure.py -``` - -Per-source-file granularity: each of the five physics files needs at least one `@pytest.mark.physics_invariant` test and at least one `@pytest.mark.reference_pinned` test in `tests/test_.py`. Granularity is per source file, not per directory. - -Utility modules are exempt from the physics-invariant requirement but still subject to all anti-happy-path rules: - -``` -src/calliope/__init__.py (re-exports) -src/calliope/_version.py (auto-generated by setuptools-scm) -src/calliope/constants.py (pure physical constants, no derivation) -``` - -### The four invariant families - -1. **Conservation** - - Mass closure: `sum(species_kg_atm + species_kg_liquid + species_kg_solid) ≈ species_kg_total` per species. - - Element closure: per-element mass balance across the gas / melt / solid partitioning. - - Stoichiometric closure: `sum(mole_fractions) == 1.0` for any returned composition vector. -2. **Positivity / boundedness** - - `T > 0` Kelvin everywhere, `P > 0` Pa everywhere. - - Partial pressures non-negative; mole fractions in `[0, 1]`. - - Henry's-law solubility non-negative; mantle melt fraction in `[0, 1]`. - - `log10(fO2)` finite (no `nan` / `inf` / `complex`). -3. **Monotonicity or symmetry** - - `log10(fO2)` decreasing with `1/T` along an isobaric buffer. - - CO2 solubility increasing with pressure at fixed T (Henry's law in the linear regime). - - Doubling pressure at fixed mole fractions doubles partial pressures. - - Swapping two non-reacting species in the input list leaves all other outputs unchanged. -4. **Pinned numeric value with a discrimination guard**: see Section 2. This is acceptable as the sole invariant when a closed-form result or published table value is the contract. - -Property-based assertions (monotonicity, conservation, symmetry, boundedness) are preferred over point-value pins when both are possible. They hold for any valid input and so catch bugs across the entire input space. - -### Validation certification markers - -Two markers track validation quality independently of line coverage: - -- **`@pytest.mark.physics_invariant`** -- this test asserts at least one of the four invariants. Tag every qualifying test in a physics-source test file. `tools/check_test_quality.py` warns when a physics-source test asserts no invariant and is not tagged. -- **`@pytest.mark.reference_pinned`** -- this test pins behavior against a **published benchmark** (paper, figure, table; cite explicitly in the test docstring), an **analytical limit** (Henry's-law linear regime, single-species degenerate solve, IW buffer at a tabulated reference T), or a **cross-implementation cross-check** (CALLIOPE vs atmodeller at the Earth fiducial; see `docs/Explanations/cross_backend_comparison.md`). - - **Per-source-file**: each of the five physics source files must have at least one `reference_pinned` test in `tests/test_.py`. Anchor type is one of {published benchmark, analytical limit, cross-implementation cross-check}; the specific paper or limit is chosen by the test author and recorded in `docs/Validation/.md`. - - **Tracking**: each physics source gets a page at `docs/Validation/.md`, created when the first reference_pinned test for that source lands. The page records: the source under test, the reference cited, the test ids carrying the marker, and the date of last comparison against the source. - - **Status report**: `python tools/check_test_quality.py --reference-pinned-status` reports the physics source files missing a `reference_pinned` test. This is the punch list for follow-up validation work. - -Both markers are registered in `pyproject.toml` under `[tool.pytest.ini_options] markers`. They do not gate CI on their own; their coverage is a separate KPI surfaced in the PR summary comment. - ---- - -## 4. Mocking discipline - -- Default to `unittest.mock` for ALL external calls in unit tests: atmodeller cross-backend calls, file I/O, HTTP, subprocess. -- Mock at the **narrowest scope**: patch the specific function (`unittest.mock.patch('calliope.solve.some_helper')`), not the whole module. -- A mocked physics function MUST return **physically plausible** values. A mock that returns `0.0` or `1.0` for everything will mask sign / clamp / fallback bugs. -- NEVER mock the function under test. If you're tempted to, the test is asking the wrong question. -- Smoke tests use the real CALLIOPE solver on minimal compositions; integration and slow tests use the full multi-species CHNS solver. The rules in this file still apply to those tiers, but the mocking discipline is relaxed because the real call is the contract. - ---- - -## 5. Optional-dependency imports - -Any test that imports an optional dependency MUST call `pytest.importorskip` at module top so `pip install --no-deps` CI runs do not fail collection: - -```python -import pytest - -hypothesis = pytest.importorskip('hypothesis') -# ... or for a module-level helper that requires the dep: -pytest.importorskip('atmodeller') -``` - -Optional deps recognized by the linter (`OPTIONAL_DEPS` constant in `tools/check_test_quality.py`): - -- `hypothesis` (used in property-based / fuzz tests; lives in `[develop]` extras). -- `atmodeller` (used in cross-backend comparison work; lives in neither `[dependencies]` nor `[develop]` extras, must always be guarded if imported into a test file). - -The lint script enforces this. Rule key `missing_importorskip`: any module-top `import ` or `from import ...` that is not preceded by a module-scope `pytest.importorskip('')` is flagged. - ---- - -## 6. Module-level constants and `monkeypatch` - -When the source under test reads an env var or a class-level default into a **module-level constant** at import time, `monkeypatch.setenv` alone is not sufficient. The constant is frozen at the original import. - -Pattern: - -```python -# Source: src/calliope/oxygen_fugacity.py -DEFAULT_BUFFER = 'fischer' # frozen at import after the 2026-05 default flip -``` - -```python -# Test (wrong): -monkeypatch.setattr('os.environ', {'CALLIOPE_BUFFER': 'oneill'}) # too late - -# Test (right): -monkeypatch.setattr('calliope.oxygen_fugacity.DEFAULT_BUFFER', 'oneill', raising=False) -``` - -To run a solve on a non-default IW buffer, pass `fO2_model=` to `equilibrium_atmosphere` / `equilibrium_atmosphere_authoritative_O` (or set `ddict['fO2_model']`). Do not patch `OxygenFugacity.__init__.__defaults__`: the solver passes the buffer explicitly to every `OxygenFugacity`, `ModifiedKeq` and `SolubilityS2` it builds, so a patched default never reaches a solve, and the patch has process-wide visibility that races under pytest-xdist. - -When in doubt, do both the env-var monkeypatch and the constant monkeypatch. The lint script does NOT currently flag this pattern (it would require source-side analysis to know which constants are env-derived); this is a discipline rule enforced via the >50 LOC review trigger and the recurring-trap table in Section 16. - ---- - -## 7. Marker discipline and timeouts - -### Module-level marker is mandatory - -Every test file MUST begin with: - -```python -import pytest - -pytestmark = [pytest.mark., pytest.mark.timeout()] -``` - -with budgets: - -- `unit` -> `timeout(30)` (target wall-time per test is `< 100 ms`; the 30 s cap is a defensive net). -- `smoke` -> `timeout(60)` (target `< 30 s`). -- `integration` -> `timeout(300)`. -- `slow` -> `timeout(3600)`. - -A module whose slowest test takes 7.5 s or more in CI (the four PR jobs and the nightly) uses four times that duration, rounded up to a full minute, in place of the tier value; the solver tests vary several fold between runs. pytest-timeout is a required plugin, so a run without it stops. A collection hook in `tests/conftest.py` stops the run when the timeout mark that applies to a test has no positive budget. - -PR CI runs `pytest -m "(unit or smoke) and not skip"`. Tests without the tier marker are invisible to CI and shipped untested. The lint script blocks any file missing the module-level `pytestmark`. - -### Per-function markers - -Per-function `@pytest.mark.` markers are **additive**, not a replacement for the module-level marker. They are useful when a file's tests span multiple tiers (rare; prefer separate files). - -### Timeout is a safety net, not a target - -The `timeout` ceiling exists so a future regression that introduces a hang (real solver call, infinite loop, network retry) surfaces as a specific-test failure rather than a generic job timeout. Most unit tests run 100x below the ceiling; if you find yourself needing the full 30 s for a unit test, something has gone wrong and you should reduce scope or move the test to a slower tier. - ---- - -## 8. Float and numerical comparison - -- NEVER use `==` for floats. Use `pytest.approx(val, rel=1e-5)` or `np.testing.assert_allclose(actual, expected, rtol=..., atol=...)`. -- State the tolerance rationale in a comment when the choice is non-obvious. E.g. "`rtol=1e-3` because the Fischer 2011 fit reports four significant figures". -- For pinned numeric values, include a **discrimination guard** (Section 2). -- For property-based assertions, use `pytest.approx` against the exact symbolic relation, with the tightest tolerance the implementation can hit (typically `rel=1e-12` for closed-form algebra; looser for solver outputs). - ---- - -## 9. Voice rule for test artifacts - -The repo-wide voice rule (zero AI-process disclosure in any public artifact) applies to test code with the same strictness as to source. The voice rule is **scoped** to public artifacts other contributors and external readers see; it does NOT apply to the rule documents themselves (this file, `calliope-code-review.md`, `copilot-instructions.md`), which legitimately name the procedures they define. - -In scope (the voice rule is BANNED here): - -- Test-skip reasons (`@pytest.mark.skip(reason='...')`). -- Test-file docstrings. -- Test-function and test-class names. -- Test-function docstrings. -- Parametrize ids (`@pytest.mark.parametrize('name', [...], ids=[...])`). -- Log-capture assertions (regex against `caplog.records`). -- Commit messages on test-touching commits (subject AND body). -- **Pull-request titles and bodies on test-touching PRs**. -- GitHub Actions job names and step names that ship to the PR Checks tab. -- Inline source comments and docstrings on `src/calliope/**`. -- Log strings that ship with the repo. -- **All public-facing documentation** (anything under `docs/`, the repo README, CONTRIBUTING.md, tutorials, wiki pages). Public docs apply the rule silently; they do NOT enumerate the banned phrases, name the voice rule, advertise the existence of `.github/.claude/` rule infrastructure, or cross-reference `.github/.claude/rules/*.md` files. A user docs page that describes the testing infrastructure must do so without naming the AI-process rules that produced it. - -Out of scope (these may NAME the procedures they define): - -- This file (`calliope-tests.md`). -- `calliope-code-review.md`. -- `copilot-instructions.md`. - -Banned phrases inside the in-scope artifacts: "audit", "review pass", "adversarial review", "Phase X" (when "X" is an AI-organized roadmap label, not a real project phase), "T1.x", "Group A/B/C/D" (when AI-organized work groups), `claude-config/...` paths, "Generated with Claude", AI-tool names, em-dashes, en-dashes (except in bibliographic page ranges within citations), process meta-commentary ("after careful analysis"). - -Write the OUTCOME (what the test verifies; what the PR achieves) never the PROCESS (how the rule was derived; which review caught what). First-person Tim voice. Going-forward only, no history rewrite. - ---- - -## 10. Fixture and parameter conventions - -- Use SI units in test parameters unless the function under test explicitly expects config units (bar, K, ppmw). -- Use `@pytest.mark.parametrize` when the same logic spans multiple physical regimes (Earth-like, Mars-like, sub-Neptune, high-fO2, low-fO2). Each parametrize id must read like a physical scenario, not a tuple of numbers. -- Set seeds for any randomness: - ```python - np.random.seed(42) - random.seed(42) - ``` - Hypothesis tests use `@settings(derandomize=True)` or an explicit `--hypothesis-seed` to keep replays stable across versions (see Section 16 trap). -- Use `tmp_path` (pytest fixture) for temporary files. Do not produce large outputs in the test path. - ---- - -## 11. Documentation per test - -- **File-level docstring**: name the source file under test (`Tests for src/calliope/.py`), list the invariants and contract clauses the file exercises, link to `docs/How-to/build_tests.md`. Required. -- **Function-level docstring**: state the physical scenario or contract clause in plain language. Required (lint-enforced). -- **Inline comments**: explain **why** a specific input range was chosen ("T=1500 K and T=2500 K so the Fischer-vs-O'Neill slope difference is resolved well above tolerance"). - ---- - -## 12. Naming - -- Test names describe behavior, not the called function: `test_iw_buffer_monotonic_with_temperature`, NOT `test_iw_buffer`. -- Test names use snake_case and read as full sentences. -- Group related tests in classes (`class TestIWBuffer:`) when they share setup; use the class to thread a single fixture through several scenarios. -- Test file names mirror source 1:1: `src/calliope/.py` -> `tests/test_.py`. Three documented exceptions to the 1:1 rule: - - **Cross-cutting fuzz / init tests** (`test_invariants_hypothesis.py`, `test_init.py`): tests that span multiple source files or test package-level concerns. - - **Repository-configuration tests** (`test_precommit_config.py`): tests that hold a checked-in configuration file to an invariant rather than exercising a source file. - - **Topical sub-files of a large physics source**: when a physics source exceeds ~500 LOC and its tests split into independent topics that would not benefit from consolidation, topical sub-files are acceptable alongside the primary `tests/test_.py`. The primary file must still exist and carry at least one `reference_pinned` and one `physics_invariant` test; the topical sub-files cover the remaining surface. The current exemption is `solve.py` (>1200 LOC), whose tests split across `test_authoritative_O.py`, `test_equilibrium_paths.py`, `test_partial_species.py`, `test_stoichiometry.py`, `test_targets.py`, `test_invariants.py`. The primary `tests/test_solve.py` carries the round-trip self-consistency anchor. - ---- - -## 13. Adversarial review trigger - -A pull request that adds or substantially modifies **> 50 lines of test code across all its commits** triggers an independent review pass before merge. This is a discipline rule, not CI-automated: the author runs the review pass via a `code-reviewer` agent before pushing the final test-touching commit. The denominator is PR-level, not per-commit: `git diff origin/main...HEAD -- 'tests/**'` is the source of truth. Splitting one large change into 49 + 49 + 49 line commits does NOT dodge the trigger. - -The reviewer's mandate: - -- Cite the anti-happy-path rule (Section 1) and the discrimination-guard requirement (Section 2). -- Flag single-assert tests, weak `is not None` patterns, missing module-level marker, missing `physics_invariant` tag on a physics-source test, missing `reference_pinned` tag on a per-source benchmark test, dead tests (passes for the wrong reason), tests that mock the function under test. -- Verify discriminating values: re-derive the expected value from a plausible wrong formula and assert the test fails with that wrong formula. -- Verify physics-source coverage of the four invariants: which of the four does this test exercise? If none, why is the test in `tests/test_.py`? - -The reviewer is a separate process from the test author. For Claude-Code workflow this means spawning a `proteus-review` skill or a `code-reviewer` agent with the test files in scope; the review must complete and surface findings before the test commit is pushed. - -The reviewer's findings are addressed in a follow-up commit (not amended into the test commit). The follow-up subject line is in plain language describing the OUTCOME ("sharpen IW-buffer assertions to distinguish Fischer from O'Neill", NOT "address review findings"). - ---- - -## 14. Tooling - -The repo provides: - -- `bash tools/validate_test_structure.sh` -- structural check (marker presence, file naming). -- `python tools/check_test_quality.py --check` -- CI mode: AST scan for the forbidden patterns in Section 1 and the marker requirement in Section 7. Fails the PR if violations exceed the baseline. -- `python tools/check_test_quality.py --baseline` -- after a deliberate sweep, regenerates `tools/test_quality_baseline.json`. Only run when you have intentionally reduced violations. -- `python tools/check_test_quality.py --reference-pinned-status` -- prints physics source files missing a `reference_pinned` test. -- `python tools/update_coverage_threshold.py` -- ratchet the fast PR gate upward when measured coverage exceeds the current `fail_under`. Capped at the 90% ecosystem ceiling. -- `ruff check src/ tests/` and `ruff format src/ tests/` -- run before commit. - -The lint script is wired into PR CI (`tests.yaml`). The step runs in **blocking** mode: any regression above the baseline fails the PR. - ---- - -## 15. Coverage strategy (operator's view) - -CALLIOPE uses two coverage gates with explicit sub-targets. The fast gate is for PR cycle time; the full nightly gate is the long-running KPI. - -| Gate | Tests | Target | When | -|---|---|---|---| -| Fast gate (`tool.calliope.coverage_fast`) | unit + smoke | ratcheting toward **90%** (PROTEUS-ecosystem ceiling) | Every PR | -| Full gate (`tool.coverage.report`) | unit + smoke + integration + slow | **90%** | Nightly | - -The ratchet is one-way (`tools/update_coverage_threshold.py`), capped at 90%. Never manually decrease the threshold. The CI guard in `.github/workflows/tests.yaml` rejects any PR that lowers `[tool.coverage.report].fail_under` below `min(base_ref, 90.0)`. - -What this means for adding tests: - -- A new closed-form helper in a utility module: a unit test is sufficient. -- A new function in a physics source: a unit test (counts toward both gates), plus a `physics_invariant` tag if it qualifies. If the function feeds a published benchmark, a `reference_pinned` test goes with it (counts toward the per-source-file inventory in `docs/Validation/.md`). -- A new cross-backend comparison: a slow-tier test that calls atmodeller via `pytest.importorskip` and pins against a `scripts/cross_backend/` fixture. - ---- - -## 16. Failure modes to recognize on review - -These are real patterns that have shipped in the past. The lint script catches some of them mechanically; reviewers catch the rest. - -| Pattern | Example | Why it slipped | Fix | -|---|---|---|---| -| **Buffer-default flip propagation** | A test pins `EARTH_VOLATILE_O_REF_KG = 1.241e22` against the legacy O'Neill default. The default flips to Fischer in `oxygen_fugacity.py`; the constant becomes 1.260e22. The test still passes against an outdated reference, silently. | The reference value is tied to the buffer choice but the test doesn't name the buffer in its docstring or assert against a buffer-discriminating value. | Discrimination guard (Section 2 rule 4) must include the alternative-buffer value so a regression that silently dispatches to the wrong buffer fails the test. Cite the buffer name AND the reference paper in the test docstring. | -| **Hypothesis seed and version stability** | `@given(...)` test passes on hypothesis 6.0 with the default seed strategy; on hypothesis 6.100 the strategy produces a different sequence and the test surfaces a previously-hidden flake or stops covering the previously-hidden bug class. | Hypothesis seed semantics changed between minor releases; the test author relied on implicit determinism. | Add `pytest.importorskip('hypothesis')` at module top. Use `@settings(derandomize=True)` or pass `--hypothesis-seed=` in CI. Document the chosen seed in the test docstring. | -| **`solve.py` intermediate-state types** | Solver loop produces a `complex` or `nan` intermediate; the silently-coerced final output is a real float that looks plausible but is wrong. The unit test only checks the final output. | The output check is too late; the bug lives in an intermediate step. | Tests of `solve.py` must assert that intermediate state is real-valued at each step: `assert np.all(np.isreal(intermediate))` and `assert not np.any(np.isnan(intermediate))`. The solver-side defense (`clip` hardening against complex / NaN / inf) belongs in source, but tests verify the defense actually fires. | -| **Silent skip in helper** | `def _enum_for(field): ...; if actual is None: continue` masks broken introspection | Helper hides a real failure as a no-op | Hard assertion: `assert actual is not None, ...` | -| **Log-line-only assertion** | Test captures a log line and asserts on its text; a regression that changes the code path but keeps the log still passes | Logs are not the contract | Capture the call kwarg and assert on the value passed in | -| **Module-level constant patched only via env var** | `monkeypatch.setenv('CALLIOPE_BUFFER', ...)` on a source that read it at import time | Constants are frozen at import; setenv is too late | `monkeypatch.setattr('mod.CONST', ...)` in addition to setenv | -| **Optional dep imported unconditionally** | `import hypothesis` at module top | `pip install --no-deps` build skips the optional install | `pytest.importorskip('hypothesis')` at module top | -| **Stale marker after refactor** | File renamed without re-applying the module-level `pytestmark` | CI marker filter passed because of per-function markers; coverage tier became invisible | Restore module-level `pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)]` | -| **Trivially-true on implicit None** | `def fixture(): pass`; `def test_x(fixture): assert fixture is None` | Fixture returned None implicitly; test passes for the wrong reason | Delete the test | - -When you spot a new variant of these, add it here. - ---- - -## 17. Sister rules (cross-link) - -- `.github/copilot-instructions.md` "Testing Standards" -- the high-level summary readers without `tests/**` context see first. -- `.github/.claude/rules/calliope-code-review.md` "Test marker discipline" -- the review-pass gate that backs up the rules in this file. Also contains domain-aware physics checks (buffer-flip propagation, solver intermediate-state types, PROTEUS-coupling patterns) that apply when reviewing the **source** code that tests cover. - -Any change to the rule set: update both files in the same commit and call out the cross-reference in the commit body. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6260a595..627467dc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,406 +1,29 @@ -# CALLIOPE AI Agent Guidelines +# CALLIOPE short instructions -**Trust these instructions.** Only search if information is incomplete or found to be in error. +The full instructions are in `AGENTS.md` (repository rules and review points) and `tests/AGENTS.md` (test rules). This file repeats what a review or a chat needs when it cannot read them. -**Identity & Mission**: You are an expert Scientific Software Engineer working on the CALLIOPE module of the PROTEUS ecosystem. - -## High-Level Instructions - -> ### Rule files you MUST read on every session -> -> CALLIOPE keeps its Claude-Code rule files under `.github/.claude/rules/` (NOT the conventional repo-root `.claude/`, which is gitignored and so cannot be shared with collaborators). Claude Code does NOT auto-discover the rules at this unusual path. Read them explicitly at the start of every session and any time you open a related file: -> -> - [`.github/.claude/rules/calliope-tests.md`](.claude/rules/calliope-tests.md) -- test quality deep-dive: anti-happy-path patterns, discriminating-value guards, physics-invariant tiering, validation certification markers, adversarial-review trigger, buffer-flip propagation, hypothesis seed stability, solver intermediate-state assertions. **Required reading before editing any file under `tests/**` or `src/calliope/**`.** -> - [`.github/.claude/rules/calliope-code-review.md`](.claude/rules/calliope-code-review.md) -- review-pass gate, domain-aware physics review (buffer-flip safety, solver intermediate-state types, four PROTEUS-coupling patterns). **Required reading before any code review pass.** -> -> These two files plus this one are the canonical sources of truth for testing rigor and review criteria. Together they enforce CALLIOPE's extreme-rigor stance on physics validity, anti-happy-path testing, and validation certification. - -1. **Always** read the two rule files above plus the Testing Standards section below before any code change. -2. **Always** inform the user that you are reading in this file by printing a message at the start of your response: "(Read in copilot-instructions.md...)" -3. When creating a PR, **always** follow the PR template (`.github/pull_request_template.md`) and ensure all sections are filled out with relevant information. -4. **Claude-specific**: `CLAUDE.md` is a symlink to this file. Session learnings, plans, and memories live in `~/.claude/projects//memory/`; they do NOT live in this repository. - -## Ecosystem Context - -CALLIOPE is the volatile in-/outgassing and thermodynamics module of the PROTEUS ecosystem. It is called by the main [PROTEUS](https://github.com/FormingWorlds/PROTEUS) coupled atmosphere-interior framework during the chemistry / outgassing step. CALLIOPE is also usable standalone for equilibrium-chemistry studies. - -Sister modules in the ecosystem: AGNI (atmospheric radiative transfer), SOCRATES (spectral radiative transfer), JANUS (1D convective atmosphere), MORS (stellar evolution), ARAGOG / SPIDER (interior thermal evolution), VULCAN (atmospheric chemistry), ZEPHYRUS / BOREAS (atmospheric escape), Obliqua (tidal evolution). - -**Project Type**: Scientific simulation module (Python). - -**Languages**: Python 3.12+. - -**Size**: 8 source files in `src/calliope/`, ~1.7k LOC. - -**Target Runtime**: Python 3.12+ on Linux / macOS. - -## Build & Validation - -### Environment Setup - -**Prerequisites**: - -1. Python 3.12 or 3.13 (via conda / miniforge or system). -2. Git. - -**Developer Install**: - -```bash -git clone git@github.com:FormingWorlds/CALLIOPE.git -cd CALLIOPE -pip install -e ".[develop]" -pre-commit install -f -``` - -CALLIOPE has no compiled dependencies: a plain `pip install` is sufficient. When working alongside PROTEUS, the recommended pattern is editable-install via `pip install -e CALLIOPE/.` from the PROTEUS tree so changes propagate without re-install. - -### Test Commands - -**Run all tests**: - -```bash -pytest -``` - -**Run by category** (matches CI): - -```bash -pytest -m "(unit or smoke) and not skip" # What PR checks run -pytest -m unit # Fast unit tests (<100ms each) -pytest -m smoke # Real-solver minimal-composition runs -pytest -m integration # Multi-species CHNS solves (nightly) -pytest -m slow # Full physics validation (nightly) -``` - -**With coverage** (matches nightly CI): - -```bash -coverage run -m pytest -m "(unit or smoke or integration or slow) and not skip" -coverage report -coverage html -``` - -**Coverage thresholds** (in `pyproject.toml`; auto-ratcheting, never manually decreased, capped at 90 by `tools/update_coverage_threshold.py`): - -- Fast gate (`[tool.calliope.coverage_fast]`, unit + smoke, every PR): ratcheting toward **90%** (the PROTEUS-ecosystem ceiling). -- Full gate (`[tool.coverage.report]`, unit + smoke + integration + slow, nightly): **90%**. - -**Validate test structure**: - -```bash -bash tools/validate_test_structure.sh -``` - -**Test quality lint** (blocking on PRs): - -```bash -python tools/check_test_quality.py --check -``` - -### Lint Commands - -**Always run before committing**: - -```bash -ruff check src/ tests/ tools/ scripts/ # Check for issues -ruff check --fix src/ tests/ tools/ scripts/ # Auto-fix issues -ruff format src/ tests/ tools/ scripts/ # Format code -``` - -**Pre-commit hook** (runs automatically on commit): - -```bash -pre-commit install -f -``` - -### Validation Pipeline - -**CI runs on PRs** (`.github/workflows/tests.yaml`): - -1. **Unit + smoke tests**: `pytest -m "(unit or smoke) and not skip" --cov=calliope`. -2. **Fast coverage gate**: `[tool.calliope.coverage_fast].fail_under` checked against the unit + smoke coverage. -3. **Test structure**: `bash tools/validate_test_structure.sh`. -4. **Test quality**: `python tools/check_test_quality.py --check` (blocking). -5. **Coverage ratchet guard**: rejects any PR that lowers `[tool.coverage.report].fail_under` below `min(base_ref, 90.0)`. -6. **Lint**: `ruff check src/ tests/ tools/ scripts/` and `ruff format --check src/ tests/ tools/ scripts/`. - -**All must pass** before merge. Coverage thresholds auto-ratchet upward (never decrease). - -**Nightly CI** (`.github/workflows/nightly.yml`): - -- Full suite: `pytest -m "(unit or smoke or integration or slow) and not skip"`. -- Coverage uploaded to Codecov. -- `--cov-fail-under=90` enforced. - -## Project Layout - -### Key Directories - -- `src/calliope/` - Main Python source code (flat layout, 8 files) - - `__init__.py` - Re-exports (utility) - - `_version.py` - Auto-generated by setuptools-scm (utility) - - `constants.py` - Physical constants (utility) - - `chemistry.py` - Equilibrium chemistry, modified Keq formulation (physics) - - `oxygen_fugacity.py` - IW buffer formulas: Hirschmann 2021 (default), Fischer 2011, O'Neill & Eggins 2002 (physics) - - `solubility.py` - Henry's-law solubility models (Dasgupta 2013, Gaillard 2003, Iacono-Marziano, etc.) (physics) - - `solve.py` - Root-finder, equilibrium-atmosphere solver, authoritative-O entry (physics) - - `structure.py` - Interior structure: mantle-mass closure from Zeng+2016 core fraction (physics) - -- `tests/` - Test suite. Each physics source has a 1:1 test file at `tests/test_.py`. Cross-cutting tests (`test_invariants_hypothesis.py`, `test_init.py`) and the repository-configuration test (`test_precommit_config.py`) are the exception. - -- `tools/` - Build / utility scripts - - `check_test_quality.py` - AST linter (blocking on PRs) - - `update_coverage_threshold.py` - One-way coverage ratchet (capped at 90) - - `check_file_sizes.sh` - Line-cap hook on this file - - `validate_test_structure.sh` - Module-level marker validator - -- `docs/` - Documentation (Zensical; Diátaxis structure) - - `Explanations/` - Concept pages (oxygen_fugacity.md, solubility.md, equilibrium_chemistry.md, cross_backend_comparison.md, etc.) - - `How-to/` - Task guides (installation.md, build_tests.md, configuration.md, usage.md, authoritative_oxygen.md, etc.) - - `Tutorials/` - Worked examples (earth_fiducial.md, mars_fiducial.md, coupled_loop.md, phase_diagram.md) - - `Reference/` - API + publications - - `Validation/.md` - Per-source-file inventory of `@pytest.mark.reference_pinned` tests (created when the first such test lands) - -- `scripts/cross_backend/` - Standalone harness comparing CALLIOPE vs atmodeller (deterministic, 5 figure modules + shared infra; `run_calliope(buffer=...)` passes the IW buffer as `fO2_model`). Not part of CI. - -### Configuration Files - -- `pyproject.toml` - Package metadata, pytest config, coverage thresholds (fast + full gates), ruff rules. -- `mkdocs.yml` - Documentation configuration (used by Zensical). -- `.github/workflows/` - CI / CD pipelines - - `tests.yaml` - PR validation (unit + smoke + lint + test-quality + ratchet guard) - - `nightly.yml` - Full suite with coverage upload - - `docs.yaml` - Documentation build - - `publish.yaml` - PyPI release on tag - -### Entry Points - -- **Python API**: `from calliope.solve import equilibrium_atmosphere, equilibrium_atmosphere_authoritative_O`. -- **No CLI**: CALLIOPE is library-only; PROTEUS provides the simulator CLI that calls CALLIOPE. - -## Testing Standards - -CALLIOPE is scientific simulation code, so the test suite is held to physics-grade rigor. The rules below are the contract; the deep-dive (anti-happy-path patterns, discriminating-value guards, certification markers, adversarial-review trigger, buffer-flip propagation, hypothesis seed stability, solver intermediate-state assertions) lives in [`.github/.claude/rules/calliope-tests.md`](.claude/rules/calliope-tests.md). Read that file before editing any test file or any source file under `src/calliope/**`. The two files must be kept in sync; if you change one, mirror the change in the other. - -### Structure - -- Tests mirror source 1:1: `src/calliope/.py` -> `tests/test_.py`. Cross-cutting tests (`test_invariants_hypothesis.py`, `test_init.py`) and the repository-configuration test (`test_precommit_config.py`) are the exception, not the rule. -- Framework: `pytest` exclusively in the `tests/` directory. - -### Markers and the module-level marker rule - -Tier markers, with their CI surface and per-test wall-time budgets: - -| Marker | What it tests | Speed budget | When CI runs it | -|---|---|---|---| -| `@pytest.mark.unit` | Python logic, heavy physics mocked | < 100 ms per test | Every PR (`unit and not skip`) | -| `@pytest.mark.smoke` | Real solver, minimal composition | < 30 s per test | Every PR (`smoke and not skip`) | -| `@pytest.mark.integration` | Multi-species CHNS coupling | Minutes per test | Nightly only | -| `@pytest.mark.slow` | Full physics validation | Up to hours per test | Nightly only | -| `@pytest.mark.skip` | Placeholder, deliberately disabled | n/a | Never | - -**Mandatory module-level marker** (no exceptions): every test file begins with - -```python -pytestmark = [pytest.mark., pytest.mark.timeout()] -``` - -with timeouts: 30 s for unit, 60 s for smoke, 300 s for integration, 3600 s for slow, or four times the slowest CI duration rounded up to a full minute when that test takes 7.5 s or more. Per-function markers are additive but do not replace the module-level marker. CI runs `pytest -m "(unit or smoke) and not skip"`; tests without a tier marker are invisible to CI. The `pytest-timeout` ceiling is a defensive net against future regressions that introduce a hang. A collection hook in `tests/conftest.py` stops the run when the timeout mark that applies to a test has no positive budget. - -### Physics validity - -Every unit test on a **physics source** (`chemistry.py`, `oxygen_fugacity.py`, `solubility.py`, `solve.py`, `structure.py`) must assert at least one of: - -- **Conservation**: per-species mass closure (`kg_atm + kg_liquid + kg_solid ≈ kg_total`), stoichiometric closure (`sum(mole_fractions) == 1.0`), element closure. -- **Positivity / boundedness**: T > 0, P > 0, mole fractions in [0, 1], `log10(fO2)` finite, partial pressures non-negative. -- **Monotonicity or symmetry**: `log10(fO2)` decreasing with `1/T` along an isobaric buffer; CO2 solubility increasing with P at fixed T; swapping two non-reacting species unchanged outputs. -- **Pinned numeric value with a discrimination guard**: a closed-form value or published table entry pinned via `pytest.approx`, accompanied by explicit assertions that wrong-formula / wrong-buffer / wrong-law results would differ from the correct one by more than the tolerance. - -Utility sources (`__init__.py`, `_version.py`, `constants.py`) are **exempt** from the physics-invariant requirement but still subject to the anti-happy-path rules. - -Tag every test that asserts a physical invariant with `@pytest.mark.physics_invariant`. Per-source-file granularity: each of the five physics files needs at least one such test in `tests/test_.py`. - -### Reference-pinned validation - -Tag tests that pin against a published benchmark, an analytical limit, or a cross-implementation cross-check with `@pytest.mark.reference_pinned`. Each of the five physics files must have at least one such test. The specific anchor is chosen by the test author and recorded in `docs/Validation/.md` (created when the first reference_pinned test for that source lands). The `--reference-pinned-status` mode of the linter reports the punch list of physics sources still missing a reference_pinned test. - -### Anti-happy-path rules (every new test) - -Every new test function MUST include: - -1. **At least one edge case** (boundary value, empty input, extreme physical parameter). -2. **At least one path that exercises the error contract** (documented exception, guard return, graceful clamp). If the function under test has no validation, exercise the limit-input behavior and assert the mathematical invariant. -3. **Assertion values that are NOT trivially derivable from the implementation**: discriminating numeric pins or property-based assertions (monotonicity, conservation) preferred over point checks. - -**Forbidden patterns** (flagged by `tools/check_test_quality.py`): - -- Single-assert test functions. -- Standalone weak assertions (`assert result is not None`, `assert result > 0`, `assert len(result) > 0`, `assert isinstance(result, dict)`) as the only meaningful check. -- Tests with no function-level docstring. -- Tests using `==` adjacent to float literals. -- Tests asserting on a fixture's implicit default. - -### Float and numerical comparison - -NEVER use `==` for floats. Use `pytest.approx(val, rel=1e-5)` or `np.testing.assert_allclose(actual, expected, rtol=..., atol=...)`. For pinned numeric values, include a **discrimination guard**: a follow-up `assert` showing the wrong-formula / wrong-buffer / wrong-law value would differ from the correct one by more than the tolerance. See `calliope-tests.md` Section 2 for the canonical pattern. - -### Mocking discipline - -- Default to `unittest.mock` for ALL external calls in unit tests: atmodeller, file I/O, network. -- Mock at the narrowest scope: a specific function, not a whole module. -- A mocked physics function must return **physically plausible** values; a mock that returns `0.0` or `1.0` for everything can mask real bugs. -- NEVER mock the function under test. -- Smoke / integration / slow tiers use the real solver. - -### Optional-dependency imports - -Any test that imports an optional dependency (`hypothesis`, `atmodeller`) MUST call `pytest.importorskip('')` at module top. The `pip install --no-deps` CI image will otherwise fail to collect. - -### Voice rule for test artifacts - -The repo-wide voice rule (zero AI-process disclosure in any public artifact) applies to test code with the same strictness as to source. Scope: test-skip reasons, test-file / function docstrings, test-function / class names, parametrize ids, log-capture assertions, **commit messages on test-touching commits, pull-request titles and bodies on test-touching PRs**, GitHub Actions job / step names, inline `src/calliope/**` comments, and shipped log strings. Out of scope: the rule documents themselves (this file, `calliope-tests.md`, `calliope-code-review.md`, `docs/How-to/build_tests.md`) may legitimately name the procedures they define. - -Banned phrases inside in-scope artifacts: "audit", "review pass", "adversarial review", AI-roadmap labels (`Phase X`, `Stage X.Y`, `Iteration N`, `T1.x`, `Group A/B/C/D` when AI-organized), `claude-config/...` paths, "Generated with Claude", AI-tool names, em-dashes, en-dashes (except bibliographic page ranges). - -Write the OUTCOME, never the PROCESS. - -### Speed and determinism - -- Unit tests: < 100 ms wall-time each. -- Aggressively mock heavy solver calls in unit tests. -- Set seeds for any randomness: `np.random.seed(42)`, `random.seed(42)`. Hypothesis tests use `@settings(derandomize=True)` or an explicit `--hypothesis-seed` (see `calliope-tests.md` Section 16). -- Use `tmp_path` (pytest fixture) for temporary files. - -### Documentation per test - -- File-level docstring: name the source under test, list the invariants and contract clauses the file exercises. -- Function-level docstring: state the physical scenario or contract clause being verified. Required (lint-enforced). -- Inline comments: explain **why** a specific input range was chosen. - -### Independent review trigger - -A pull request that adds or substantially modifies > 50 lines of test code across all its commits triggers an independent review pass before merge. The denominator is PR-level (`git diff origin/main...HEAD -- 'tests/**'`); splitting into many sub-50-line commits does not dodge the trigger. The reviewer cites the anti-happy-path rule, the discrimination-guard requirement, and the physics-invariant tier. - -### Tooling - -- Validate test structure: `bash tools/validate_test_structure.sh` -- Test-quality lint: `python tools/check_test_quality.py --check` -- Baseline regeneration (after a deliberate sweep): `python tools/check_test_quality.py --baseline` -- Reference-pinned audit: `python tools/check_test_quality.py --reference-pinned-status` -- Coverage ratchet (one-way, capped at 90): `python tools/update_coverage_threshold.py` -- Format: `ruff format src/ tests/ tools/ scripts/` -- Lint: `ruff check src/ tests/ tools/ scripts/` - -### Coverage architecture - -CALLIOPE uses two gates with explicit sub-targets: - -| Gate | Tests included | Target | Enforced | -|---|---|---|---| -| Fast gate (`tool.calliope.coverage_fast.fail_under`) | unit + smoke | Ratcheting toward **90%** | Every PR | -| Full gate (`tool.coverage.report.fail_under`) | unit + smoke + integration + slow | **90%** | Nightly | - -Both gates ratchet toward 90, capped at 90 (`tools/update_coverage_threshold.py` enforces `ECOSYSTEM_CEILING = 90.0`); neither may be manually decreased. The CI guard in `tests.yaml` rejects any PR that lowers `[tool.coverage.report].fail_under` below `min(base_ref, 90.0)`. - -## Safety & Determinism - -- **Randomness**: explicitly set seeds in tests. -- **Files**: do not generate tests that produce large output files; use `tempfile` or mocks. - -## Code Quality - -**Style** (enforced by ruff): - -- Line length < 96 chars. -- Variables / functions: `snake_case`. -- Constants: `UPPER_CASE`. -- Type hints: standard Python. -- Docstrings: brief descriptions of physical scenarios. - -**Pre-commit**: runs `ruff check --fix` automatically. Fix issues before committing. - -## Common Workflows - -### Making a Code Change - -1. **Create branch**: `git checkout -b /`. -2. **Make changes** in `src/calliope/`. -3. **Write / update tests** in `tests/test_.py` (mirror structure). -4. **Run tests locally**: `pytest -m "(unit or smoke) and not skip"`. -5. **Check coverage**: `pytest --cov=calliope --cov-report=html`. -6. **Lint**: `ruff check --fix src/ tests/ tools/ scripts/ && ruff format src/ tests/ tools/ scripts/`. -7. **Validate structure**: `bash tools/validate_test_structure.sh`. -8. **Test quality**: `python tools/check_test_quality.py --check`. -9. **Commit**: plain-language subject, first-person voice, no AI-process disclosure. -10. **Push**: CI runs automatically on PR. - -### Adding a New Physics Source - -1. Create `src/calliope/.py`. -2. Create `tests/test_.py` with module-level `pytestmark`. -3. Add at least one `@pytest.mark.physics_invariant` test asserting one of the four invariant families. -4. Plan a `@pytest.mark.reference_pinned` test (anchor: paper, analytical limit, or cross-check); create `docs/Validation/.md` when it lands. -5. Run the full PR checks locally. - -### Debugging Test Failures - -```bash -pytest -v --showlocals # Verbose with local variables -pytest -x # Stop at first failure -pytest tests/test_.py::test_function # Run specific test -pytest --pdb # Drop into debugger on failure -``` - -## Documentation References - -- **Testing rules**: `.github/.claude/rules/calliope-tests.md`, `.github/.claude/rules/calliope-code-review.md` -- **Test how-to**: `docs/How-to/build_tests.md` -- **Installation**: `docs/How-to/installation.md` -- **Concepts**: `docs/Explanations/oxygen_fugacity.md`, `docs/Explanations/solubility.md`, `docs/Explanations/equilibrium_chemistry.md`, `docs/Explanations/cross_backend_comparison.md` -- **PROTEUS coupling**: `docs/How-to/proteus_coupling.md`, `docs/Explanations/proteus_coupling.md` - -## Project memory and session learnings - -Session-specific knowledge (debugging logs, design rationale, sprint focus, ADR drafts) lives outside this repository, in the Claude memory tree under `~/.claude/projects//memory/`. The memory tree is per-user, sync-ready across machines, and not exposed in public commit history. - -What still lives in this repository: - -- Architectural decisions that affect every contributor: this file (`.github/copilot-instructions.md`). -- Test and review rules: `.github/.claude/rules/calliope-tests.md` and `.github/.claude/rules/calliope-code-review.md`. -- Per-PR rationale: PR descriptions. -- Per-commit rationale: commit messages. -- Module-level scientific validation: `docs/Validation/.md` (created when the first `@pytest.mark.reference_pinned` test for that source lands). - -Do not introduce a new in-repo "memory" or "decisions log" file. The four channels above are the contract. - ---- - -## Quick Reference +## Commands ```bash -# Setup pip install -e ".[develop]" -pre-commit install -f - -# Test pytest -m "(unit or smoke) and not skip" -pytest --cov=calliope --cov-report=html - -# Lint -ruff check --fix src/ tests/ tools/ scripts/ -ruff format src/ tests/ tools/ scripts/ - -# Validate +ruff check --fix src/ tests/ tools/ scripts/ && ruff format src/ tests/ tools/ scripts/ bash tools/validate_test_structure.sh python tools/check_test_quality.py --check - -# Serve docs locally -pip install -e '.[docs]' -zensical serve +python tools/agents/check_agents_md.py +pre-commit run --all-files ``` -**Remember**: Trust these instructions. Only search if information is incomplete or found to be in error. +## Review checklist ---- +- T stays positive; partial pressures and solubilities stay non-negative and finite, mole fractions sum to 1, `log10(fO2)` is finite, and the mantle mass stays positive. +- Mass closure: each species total is its atmosphere plus melt mass; each `_kg_total` is gas plus melt, with the metal in `_kg_core`; O carried by H2O, CO2 and SO2 stays in those totals. +- Units: T in K, partial pressures in bar, masses in kg, `fO2_shift_IW` in dex, solubilities in ppmw of the melt, `P_eq` in GPa; constants from `calliope.constants`, never a retyped literal. +- A changed default (IW buffer `DEFAULT_FO2_MODEL`, modified-Keq form, solubility law) comes with its updated reference values, docs and sibling-path test guards. +- New solver code checks `np.isreal` and `np.isfinite` on its intermediates. +- Code that builds `OxygenFugacity`, `ModifiedKeq` or `SolubilityS2` in a solve passes `ddict['fO2_model']`, and a solve that calls itself again carries the buffer. +- PROTEUS calls `equilibrium_atmosphere`, `equilibrium_atmosphere_authoritative_O`, `get_target_from_params` and `get_target_from_pressures` (`src/proteus/outgas/calliope.py`) and reads `O_kg_total` and `fO2_shift_derived`; a change of a signature, a key or a unit comes with the PROTEUS change. +- Tests: `src/calliope/.py` is tested in `tests/test_.py`; each file has `pytestmark = [pytest.mark., pytest.mark.timeout()]`; each test has a docstring, at least 2 assertions, an edge case and the error path; floats are compared with a tolerance; a test that asserts a physical invariant carries `physics_invariant`, a test against a published or analytical value also carries `reference_pinned`, and pinned values have sign, scale and wrong-formula guards. +- Commit messages, pull-request text, comments, test names and CI job names describe the change: no tool attribution, no internal plan labels, no em or en dashes; an inline comment block has at most 4 lines. -> **⚠️ FILE SIZE LIMIT: This file must stay below 500 lines.** Enforced by pre-commit hook (`tools/check_file_sizes.sh`). File located at `.github/copilot-instructions.md`. +This checklist is a subset of the review points of `AGENTS.md` (its Review section and its physics and coupling contract) and `tests/AGENTS.md`; change them together. diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index edec1d27..9c104f8d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -66,6 +66,10 @@ jobs: if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' }} run: python tools/check_test_quality.py --check + - name: Check agent instruction files + if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' }} + run: python tools/agents/check_agents_md.py + - name: Pre-flight fail_under ratchet check if: ${{ matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' }} env: diff --git a/.gitignore b/.gitignore index 98828aa7..b285deba 100644 --- a/.gitignore +++ b/.gitignore @@ -174,11 +174,7 @@ cython_debug/ # setuptools-scm generated version file src/calliope/_version.py -# Claude AI files -# Note: the repo-root CLAUDE.md is a tracked symlink to .github/copilot-instructions.md; -# the .github/.claude/rules/ directory is tracked and shared across collaborators. -# Personal session state (anything under the top-level .claude/ that is not the tracked -# rules tree) stays out of git. +# Per-user Claude Code session state; the shared agent rules are AGENTS.md. /.claude/ .mcp.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c5fb62a7..c1b56e48 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,9 @@ # See https://pre-commit.com for more information # See https://pre-commit.com/hooks.html for more hooks --- +# Matplotlib SVGs carry trailing spaces in their text; the rewriting hooks would +# change files the validation plots write. +exclude: \.svg$ repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 @@ -29,9 +32,9 @@ repos: # types_or: [python, pyi] - repo: local hooks: - - id: check-file-sizes - name: Check copilot-instructions.md line limit - entry: bash tools/check_file_sizes.sh + - id: check-agents-md + name: Check agent instruction files + entry: python tools/agents/check_agents_md.py language: system - files: \.github/copilot-instructions\.md$ + files: (AGENTS\.md|CLAUDE\.md|copilot-instructions\.md)$ pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4c5243cf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,115 @@ +# CALLIOPE agent instructions + +CALLIOPE computes the equilibrium between a magma ocean and its atmosphere: the partial pressures of H2O, CO2, O2, H2, CO, CH4, N2, S2, SO2, H2S, NH3 and the noble gases, the volatiles dissolved in the melt, and optionally a metal reservoir in equilibrium with the melt. PROTEUS calls it in the outgassing step (`outgas.module = 'calliope'`); it also runs standalone for equilibrium-chemistry studies. Before a first edit: + +- Tests for `src/calliope/.py` go in `tests/test_.py`; the test rules are in `tests/AGENTS.md`. +- Physics sources: `chemistry.py`, `metal.py`, `oxygen_fugacity.py`, `solubility.py`, `solve.py`, `structure.py`. Utility sources: `__init__.py`, `_version.py`, `constants.py`. The [API reference](docs/Reference/api/index.md) describes each module. +- These commands decide whether a change is ready (CI runs the tests, the structure check, the test-quality lint, the agent-file check and pre-commit): + +```bash +pytest -m "(unit or smoke) and not skip" +ruff check --fix src/ tests/ tools/ scripts/ && ruff format src/ tests/ tools/ scripts/ +bash tools/validate_test_structure.sh +python tools/check_test_quality.py --check +python tools/agents/check_agents_md.py +pre-commit run --all-files +zensical build --clean # when a change touches docs/ or mkdocs.yml +``` + +Coverage as the nightly measures it, and one test with its locals on failure: + +```bash +coverage run -m pytest -m "(unit or smoke or integration or slow) and not skip" && coverage report && coverage html +pytest -x -v --showlocals tests/test_.py::test_function # add --pdb for the debugger +``` + +Entry points: the Python API (`from calliope.solve import equilibrium_atmosphere, equilibrium_atmosphere_authoritative_O`). CALLIOPE has no command-line interface. + + +## PROTEUS ecosystem rules + +PROTEUS couples separate module repositories into one model of the evolution of rocky planets and their atmospheres. Its config selects one module per process; the config values are the lowercase names: + +- `interior_energetics.module`: `aragog`, `spider` (or the built-in `boundary`); `interior_struct.module`: `zalmoxis`, `spider` +- `atmos_clim.module`: `agni`, `janus`, both with SOCRATES spectral radiative transfer +- `outgas.module`: `calliope`, `atmodeller`; `atmos_chem.module`: `vulcan` +- `star.module`: `mors`; `escape.module`: `zephyrus`, `boreas`; `orbit.module` (tides): `obliqua`, `lovepy` +- fwl-io downloads the reference data the modules use + +A module change to anything PROTEUS calls or reads (a function signature, a config key, an output column, a unit) can break the coupled model while the module's own tests pass. Search `src/proteus/` in PROTEUS for the name before you change it, and name the affected PROTEUS call sites in the pull request. + +### Physics and numerics + +- Units differ between and within modules: MORS and VULCAN use cgs; CALLIOPE and atmodeller work with pressures in bar; AGNI takes surface pressure in bar at setup and holds Pa inside; PROTEUS states the unit of every `hf_row` key in `GetHelpfileKeys` (`src/proteus/utils/coupler.py`: atmospheric pressures in bar, interior pressures in Pa, model time in years, orbital periods in s), and its config reference pages state each key's unit (for example stellar mass in M_sun, initial partial surface pressures in bar, stellar age in Gyr). State the unit of every physical quantity in its docstring and convert explicitly at the boundary: a unit mismatch between two modules passes the tests of both and shows only in the coupled run. +- A physics test must fail for the most plausible wrong formula: check a conservation law, a bound, a monotonicity, or a published or analytical value, and assert that the wrong result falls outside the tolerance. +- Take each physical constant from one source per repository (in Python `scipy.constants` or the module's constants file). Two retyped values of one constant differ at round-off and hide real differences between code paths. +- Do not loosen a solver tolerance, a conservation check or a clamp to make a run or a test pass. Find the cause; a loosened check also hides the next defect. + +### Validation + +A new or changed physics routine gets the validation cases named in its confirmed plan: an analytical law, a published figure or table, or a cross-code check. Each case is a `reference_pinned` test that runs the real code on real data, with no mocks, asserts the tolerance, and writes its light and dark plot through the `validation_plot` fixture of proteus-mpl. `pytest -m reference_pinned --validation-plots` makes all plots again. Line plots are SVG, maps are AVIF; each file is at most 60 kB, under `docs/assets/validation/`. Each case has a subsection on its page in the Validation tab, with the reference, the tolerance, the test id and the command. Data read from a published figure comes from 2 independent figlab reads that agree within the stated error, as a CSV with its provenance under `tests/validation/data/`. + +### Code + +- Keep an inline comment block to 2 lines or fewer, and never more than 4, because a long block drifts from the code it describes. An explanation that needs more goes in the docstring, the commit message or the pull-request description. + +### Branches and pull requests + +- Work on a feature branch `/`; `main` changes only through a reviewed pull request. +- Fill every section of the repository's pull-request template, where it has one. +- Before you push, run the checks listed at the top of this `AGENTS.md` and in `tests/AGENTS.md`. + +### Where knowledge goes + +Rules for every contributor go in the `AGENTS.md` files, the reason for a change in its commit message and pull-request description, and the scientific validation of a module in its `docs/Validation/` pages, where the repository has them. Do not add memory, notes or decision-log files to the repository: nobody maintains them, and they go stale. + + + +### Commit messages and public text + +Commit messages, pull-request text, code comments, docstrings, test names, test skip reasons, parametrize ids, log strings that ship with the code, and CI job and step names describe the change and the current state of the code. They name no tool used to write the change, carry no tool-attribution trailer, use no internal plan, phase or work-group labels, and use no em or en dashes (a page range in a citation is the exception). + + +## Environment + +Python 3.12 or 3.13 and git. `pip install -e ".[develop]"` and `pre-commit install -f`; there is nothing to compile (`docs/How-to/installation.md`). Next to PROTEUS, install CALLIOPE editable from the PROTEUS tree (`pip install -e CALLIOPE/.`) so a change takes effect without a reinstall. Serve the docs with `zensical serve` after `pip install -e '.[docs]'`. `scripts/cross_backend/` compares CALLIOPE with atmodeller outside CI. + +Style: lines of at most 96 characters (ruff `line-length`); `snake_case` names, constants in `UPPER_CASE`; standard type hints; a docstring states the physical scenario. The pre-commit hook runs `ruff check --fix` on the files a commit touches; no CI job checks `ruff format`, so run it before a commit. + +## CI + +- `tests.yaml` (each pull request to `main`, each push to `main`, and on dispatch; Ubuntu and macOS with Python 3.12 and 3.13, only Ubuntu with 3.12 for a draft pull request): `pytest -m "(unit or smoke) and not skip"` with coverage against the fast gate, and on one job `validate_test_structure.sh`, `check_test_quality.py --check` (blocking), the agent-file check, and a guard that rejects a pull request that lowers `[tool.coverage.report].fail_under` below `min(base, 90)` or raises it above 90. +- `code-style.yaml` runs pre-commit on the changed files; `docs.yaml` builds and deploys the docs on a push to `main`, so build them locally when a change touches `docs/` or `mkdocs.yml`. +- `nightly.yml`: all tiers, coverage to Codecov, the full gate enforced, and a job that installs atmodeller and runs `tests/test_cross_code_consistency.py`. +- `publish-test-badges.yml` refreshes the test-count badges on the `badges` branch; `publish.yaml` releases to PyPI on a tag. + +## Physics and coupling contract + +- Units: temperature in K, partial pressures in bar, masses in kg, mole fractions dimensionless; `fO2_shift_IW` in dex relative to the IW buffer; solubility laws take a partial pressure in bar and return ppmw of the melt; the metal reservoir takes `P_eq` in GPa and `T_eq` in K. Literature fits are in bar, SI bookkeeping is in Pa: confirm the unit at every conversion site. +- Constants (`R_gas`, `M_earth`, `molar_mass`, `ocean_moles`) come from `calliope.constants`; a bare literal in a function body is a defect. +- Bounds on every result: T > 0; total pressure positive; partial pressures non-negative and summing to the total within the solver tolerance; mole fractions summing to 1; solubilities non-negative and finite; `log10(fO2)` finite; equilibrium constants positive (the `ModifiedKeq` methods return log10 K and its call returns `10 ** (log10 K - nu log10 fO2)`, so check the sign in that exponent); the mantle mass `M_planet - M_core` positive. +- Mass closure: each `_kg_total` is gas plus melt (the metal is in `_kg_core`), and each species total is the sum of its atmosphere and melt masses. A new species updates every total it contributes to, and O carried by H2O, CO2 and SO2 stays in those totals. PROTEUS writes the element totals to `hf_row` and builds its next target from them. +- Solver intermediates: `solve.py` iterates on vectors that can turn complex, NaN or infinite. New solver code checks `np.isreal` and `np.isfinite` before the next step, keeps a numpy `RuntimeWarning` visible in the log, and does not drop an imaginary part without asserting that it is negligible. +- IW buffer: a solve takes it from `fO2_model=` or `ddict['fO2_model']`, else `DEFAULT_FO2_MODEL` (Hirschmann 2021), and returns its name as `fO2_model`. The metal dIW takes no absolute fO2; the buffer reaches it and the D only through the solved composition. +- Defaults selected by name (the IW buffer `DEFAULT_FO2_MODEL`, the modified-Keq form, the solubility laws): a change of a default changes the pinned reference values, the docs that quote them (`docs/Explanations/cross_backend_comparison.md`, the tutorials, the `docs/Validation/` pages), the frozen fixtures of `scripts/cross_backend/`, and PROTEUS tests that pin a CALLIOPE value. `git grep` the old name and update each in the same pull request, add a guard against the sibling path to the tests, state the change in the release notes, and run the PROTEUS unit and smoke tiers against the release before PROTEUS raises its CALLIOPE pin. +- PROTEUS (`src/proteus/outgas/calliope.py`) calls `equilibrium_atmosphere`, `equilibrium_atmosphere_authoritative_O`, `get_target_from_params` and `get_target_from_pressures`, and reads `molar_mass` and `ocean_moles`; `src/proteus/outgas/lavatmos.py` uses `OxygenFugacity`; `docs/How-to/proteus_coupling.md` and `docs/Explanations/proteus_coupling.md` describe the coupling. A change of a signature, a return key or a unit comes with the PROTEUS change. +- Authoritative-O mode (`planet.fO2_source = "from_O_budget"` in PROTEUS): the result carries the solved `O_kg_total` and `fO2_shift_derived`, which PROTEUS writes to `hf_row['fO2_shift_IW_derived']` next to the input `fO2_shift_IW`. In this mode PROTEUS writes the target O back to `hf_row['O_kg_total']`; with `fO2_source = "user_constant"` it compares the buffered `O_kg_total` of the first step with the user budget `O_kg_user_ic` and stops the run above 50 % divergence. Do not replace `O_kg_total` with a clipped or fallback value without a flag in the result, and do not change the input `fO2_shift_IW`. +- A config dictionary or object that carries user input is not changed after initialisation; use local variables. + +## Adding a physics source + +1. Create `src/calliope/.py` and `tests/test_.py` with the module-level `pytestmark`, and add the file to `PHYSICS_SOURCES` in `tools/check_test_quality.py`. +2. Add at least one `physics_invariant` test and one `reference_pinned` test. +3. Add a section per case (reference, tolerance, test id, command) to a page in `docs/Validation/`, a new page in the Validation nav of `mkdocs.yml` for a new process, and list the case in `docs/Validation/index.md` (`tests/test_validation_index.py` checks the list). + +## Review + +Check each change against these points and the physics and coupling contract above. `.github/copilot-instructions.md` repeats this checklist for tools that read only that file; change it together with this section. + +- Bounds and mass closure hold on every returned result, including the metal reservoir and the authoritative-O path. +- Units at each bar and Pa boundary; constants from `calliope.constants`. +- A changed default comes with its updated reference values, docs and sibling-path guards. +- New solver code guards its intermediates against complex, NaN and infinite values. +- Code that builds `OxygenFugacity`, `ModifiedKeq` or `SolubilityS2` in a solve passes `ddict['fO2_model']`, and a solve that calls itself again carries the buffer. +- The return keys PROTEUS reads keep their names and units; `O_kg_total` and `fO2_shift_derived` are the solved values. +- Tests follow `tests/AGENTS.md`. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 02dd1341..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -.github/copilot-instructions.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/README.md b/README.md index 1b1b778f..1d2e8e14 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ Full documentation is at **[proteus-framework.org/CALLIOPE](https://proteus-fram - [Tutorials](https://proteus-framework.org/CALLIOPE/Tutorials/firstrun.html): first run, Earth and Mars fiducials, two-mode round-trip, coupled-loop driver, speciation phase diagram. - [How-to guides](https://proteus-framework.org/CALLIOPE/How-to/installation.html): install, configure, run, couple to PROTEUS, use the authoritative-oxygen mode, test, release. - [Explanations](https://proteus-framework.org/CALLIOPE/Explanations/model.html): model overview, equilibrium chemistry, solubility laws, oxygen fugacity, mass balance, authoritative-oxygen mode, [CALLIOPE-vs-atmodeller cross-backend comparison](https://proteus-framework.org/CALLIOPE/Explanations/cross_backend_comparison.html). -- [API reference](https://proteus-framework.org/CALLIOPE/Reference/api/index.html) and [validation anchors](https://proteus-framework.org/CALLIOPE/Validation/oxygen_fugacity.html): every public function with NumPy-style docstrings, plus the per-source reference-pinned test inventory. +- [Validation](https://proteus-framework.org/CALLIOPE/Validation/index.html): the reference-pinned cases of each process, with their references and tolerances. +- [API reference](https://proteus-framework.org/CALLIOPE/Reference/api/index.html): every public function with NumPy-style docstrings. ## Installation diff --git a/docs/Explanations/code_architecture.md b/docs/Explanations/code_architecture.md index c7ff2664..70507fd6 100644 --- a/docs/Explanations/code_architecture.md +++ b/docs/Explanations/code_architecture.md @@ -1,18 +1,21 @@ # Code architecture -CALLIOPE is a small package: seven Python modules totaling under 1000 raw lines, no compiled extensions, no thread or process pools. This page maps each module to its responsibility and shows how they call each other. +CALLIOPE is a small package of 7 pure Python modules, plus `__init__.py` and a `data/` folder with the placeholder partition file. The build writes `_version.py`. The package has no compiled extensions and no thread or process pools. This page maps each module to its responsibility and shows how they call each other. ## Package layout ``` src/calliope/ ├── __init__.py # version string only +├── _version.py # package version, written by the build ├── constants.py # molar masses, element list, plotting colours ├── oxygen_fugacity.py # IW buffer parameterisations ├── chemistry.py # ModifiedKeq class, six redox couples ├── solubility.py # five solubility classes ├── solve.py # speciation, mass balance, outer solver -└── structure.py # simple core/mantle mass split +├── metal.py # partition file reader, partition laws, metal reservoir masses +├── structure.py # simple core/mantle mass split +└── data/ # partition_coefficients_placeholder.toml ``` Plus the test suite: @@ -76,9 +79,13 @@ Plus the inner-loop residual functions `func` (four-vector for buffered) and `fu `get_partial_pressures` is *the* call that defines what species CALLIOPE knows about. To add a new species, you add an entry in `volatile_species` (constants.py), an entry in `molar_mass` (constants.py), a `ModifiedKeq` model method (chemistry.py) if it is a derived species, a `Solubility` subclass (solubility.py) if it has dissolved-melt physics, and the speciation step in `get_partial_pressures` plus the atomic-mass tally in `atmosphere_mass`. Tests need the analytical-vs-code consistency check in `tests/test_stoichiometry.py`. +### `metal.py` + +The optional metal reservoir ([Metal reservoir](metal.md)). `load_partition_table` reads and checks a partition file; `log10_D` evaluates one law and `mass_D` converts its $D$ to the mass basis; `exchange_metal` gives the O and Si of the metal from their exchange $K_D$; `metal_masses` gives the mass of each element in the metal; `out_of_range` sets the range flags. `solve.py` calls them in 2 places: `_core_masses` (through `metal_masses`) adds the core term to `func` and `func_authoritative_O`, and `_with_metal` runs the fixed point on the metal composition. `equilibrium_atmosphere` and `equilibrium_atmosphere_authoritative_O` call `_with_metal` when `metal_mass` is above zero. The packaged partition file is `src/calliope/data/partition_coefficients_placeholder.toml`. + ### `structure.py` -Single function `calculate_mantle_mass(radius, mass, core_frac)` that returns `M_mantle = M_planet - M_core` assuming Earth-like core density. This is a fallback for use cases where a full structure solve (Zalmoxis, SPIDER) is not warranted. The PROTEUS coupling does *not* go through this function; it gets `M_mantle` directly from Zalmoxis or SPIDER via `hf_row`. +Single function `calculate_mantle_mass(radius, mass, core_frac)` that returns `M_mantle = M_planet - M_core` assuming Earth-like core density. Only standalone runs use it. PROTEUS does not call it. Its outgassing step passes `hf_row['M_mantle']`, which the interior structure and energetics steps of PROTEUS set. The core mass comes from the structure solve; with Zalmoxis it is the mass enclosed at the core-mantle boundary node of the Zalmoxis mesh. PROTEUS passes no `metal_mass`, so a PROTEUS run has no metal reservoir. ## Call graph diff --git a/docs/Explanations/mass_balance.md b/docs/Explanations/mass_balance.md index 6fe7c3e8..1a09469d 100644 --- a/docs/Explanations/mass_balance.md +++ b/docs/Explanations/mass_balance.md @@ -12,7 +12,9 @@ $$ where $\mathbf{p} = (p_\mathrm{H_2O}, p_\mathrm{CO_2}, p_\mathrm{N_2}, p_\mathrm{S_2})$ is the four-vector of primary partial pressures in bar. The seven secondary partial pressures (and $p_\mathrm{O_2}$) are not independent variables: they are algebraic functions of $\mathbf{p}$ via the [equilibrium chemistry](equilibrium_chemistry.md) speciation tree. Oxygen is *not* conserved; it is set by the $f_{\mathrm{O}_2}$ buffer and is allowed to leak in or out of the system as the speciation requires. -The four-vector residual function is `solve.func`: +With a metal reservoir (`metal_mass` above zero), each of the 4 equations gains the mass of the element in the metal, $D_e M_\mathrm{metal} w_e^\mathrm{melt}$, with $D_e$ the partition coefficient on the mass basis and $w_e^\mathrm{melt}$ the mass fraction in the melt. `func` adds this term through `_core_masses`, and `_with_metal` repeats the solve until $D_e$ and the metal composition agree. See [Metal reservoir](metal.md). + +The four-vector residual function is `solve.func` (shown here without the metal term): ```python def func(pin_arr, ddict, mass_target_d): diff --git a/docs/Explanations/metal.md b/docs/Explanations/metal.md new file mode 100644 index 00000000..df2c01a4 --- /dev/null +++ b/docs/Explanations/metal.md @@ -0,0 +1,141 @@ +# Metal reservoir + +**The question.** How much of the volatile elements H, C, N and S goes into the liquid iron that forms a planet's core, and how does that change the atmosphere? + +**The short answer.** When liquid metal is in equilibrium with the silicate melt, each element splits between the two in a ratio, the partition coefficient $D$, that depends on pressure, temperature, oxygen fugacity and composition. CALLIOPE adds the metal as a third reservoir next to the gas and the melt, so an element that goes into the metal is no longer available to the atmosphere. The values of $D$ come from laboratory fits in a partition file. + +The metal reservoir is off by default. It is on when `ddict['metal_mass']` is above zero; `ddict` is the input dictionary of a solve (see [Configuration](../How-to/configuration.md)). The code names the metal reservoir `core` in its keys (`_kg_core`, `w__core`); this page says *metal* for the reservoir, and *core* only for a planet's core or in a key name. The [metal reservoir tutorial](../Tutorials/metal_reservoir.md) runs it step by step, and the [partition file reference](../Reference/partition_file.md) lists every input key, every output key and the file format. + +## Terms used on this page + +- **Metal reservoir.** Liquid iron, with light elements dissolved in it, that is in chemical equilibrium with the silicate melt. It stands for the metal that sinks to form the core. +- **`metal_mass`** (kg), $M_\mathrm{metal}$. The mass of metal that is in equilibrium with the present melt. It is an input; CALLIOPE does not compute it. +- **Melt mass** (kg), $M_\mathrm{melt} = M_\mathrm{mantle}\,\Phi_\mathrm{global}$. The molten part of the silicate mantle: $M_\mathrm{mantle}$ (`M_mantle`, kg) is the mass of the silicate mantle, and $\Phi_\mathrm{global}$ (`Phi_global`, no unit) the fraction of it that is molten. A metal reservoir needs melt: with `Phi_global` = 0 the solve raises `ValueError`. +- **Mass fraction** $w_i$ (kg per kg). The mass of element $i$ in a reservoir divided by the mass of that reservoir. $w_i^\mathrm{melt}$ is the value in the melt, $w_i^\mathrm{metal}$ the value in the metal. +- **Partition coefficient on the mass basis** $D_i = w_i^\mathrm{metal} / w_i^\mathrm{melt}$ (no unit). $D_i = 10$ means that 1 kg of metal holds 10 times as much of element $i$ as 1 kg of melt. +- **Mole basis.** Some laboratory studies fit the ratio of mole fractions, $x_i^\mathrm{metal} / x_i^\mathrm{melt}$, instead of mass fractions. CALLIOPE converts such a $D$ to the mass basis before it uses it (see below). In the melt, CALLIOPE counts moles on the single-cation basis: one unit per cation (SiO$_2$, MgO, AlO$_{1.5}$, ...). +- **$x'$ basis.** The mole fraction of an element over the metal without H and C, $x'_j = N_j / \sum_{k \ne \mathrm{H,C}} N_k$, with $N$ the number of moles. [Tagawa et al. (2021)](https://doi.org/10.1038/s41467-021-22035-0) and [Tsutsumi et al. (2025)](https://doi.org/10.1038/s41467-025-65729-5) use this basis; Tsutsumi et al. (2025, Eq. 10, p. 8) give the reason: H and C atoms fill gaps between the Fe atoms of the metal. +- **$\Delta\mathrm{IW}$ of the metal-melt equilibrium**, `dIW_eq` (log10 units). The oxygen fugacity at which the metal and the melt equilibrate, relative to the iron-wüstite (IW) buffer. It is a separate quantity from `fO2_shift_IW`, the oxygen fugacity of the gas and the melt at the surface. +- **$K_D$, the exchange coefficient.** Some studies fit the equilibrium of a reaction with FeO, for example $\mathrm{FeO} = \mathrm{Fe} + \mathrm{O}$, instead of $D$ itself. $K_D$ is the ratio of the mole fractions of the products to those of the reactants. For O and Si the file stores $K_D$. For H, C, N and S it stores a law for $D$; when a study fits $K_D$, the file stores the converted law. +- **The H law as an example of a converted law.** Its source fits $\log_{10} K_D = \log_{10} D + \tfrac{1}{4}\Delta\mathrm{IW}$. The file stores the law for $\log_{10} D = \log_{10} K_D - \tfrac{1}{4}\Delta\mathrm{IW}$, so its $\Delta\mathrm{IW}$ coefficient is $-0.25$. +- **KLB-1.** A peridotite composition (Takahashi 1986, 7 major oxides in wt%), the default melt composition `silicate_oxides`. +- **Authoritative-O solve.** The solve that takes the O of the gas and the melt as a budget and finds the oxygen fugacity, `equilibrium_atmosphere_authoritative_O` (see [Authoritative-oxygen mode](authoritative_oxygen.md)). +- **Buffered solve.** The solve that takes the oxygen fugacity `fO2_shift_IW` as an input, `equilibrium_atmosphere`. +- **Metal share.** The part of the total mass of an element (gas, melt and metal together, the bulk inventory) that is in the metal: `_kg_core` divided by `_kg_total + _kg_core`. +- **NBO/T** (`silicate_nbo_t`, no unit). The number of non-bridging O atoms per tetrahedral cation of the melt: a measure of how polymerised the melt is. Some laws have a term in it. +- **Fixed point.** A loop in which each pass uses the result of the pass before, until the result stops changing. CALLIOPE uses it because $D$ depends on the metal composition, and the metal composition depends on $D$. +- **`tol_w_core`** (default $10^{-6}$). The stop of that loop: the passes end when each metal mass fraction changes by at most `tol_w_core` of its value between 2 passes. + +## The element balance with a metal term + +Without metal, CALLIOPE finds the partial pressures at which the gas and the melt together hold the budget of each element (see [Mass balance & solver](mass_balance.md)). With metal, the metal holds a third part: + +$$ +m_i^\mathrm{atm} + m_i^\mathrm{melt} + D_i\,M_\mathrm{metal}\,w_i^\mathrm{melt} = m_i^\mathrm{target}, +\qquad i \in \{\mathrm{H}, \mathrm{C}, \mathrm{N}, \mathrm{S}\}. +$$ + +Here $m_i^\mathrm{atm}$ and $m_i^\mathrm{melt}$ are the masses of element $i$ in the gas and in the melt, and $m_i^\mathrm{target}$ is its budget, all in kg. The third term is the mass of the element in the metal: the mass fraction of the element in the metal is $D_i$ times its mass fraction in the melt, and there are $M_\mathrm{metal}$ kg of metal. + +In the code, `calliope.metal.metal_masses` computes this term. `solve._core_masses` calls it, and both residual functions, `solve.func` (buffered solve) and `solve.func_authoritative_O` (authoritative-O solve), add it to the residual of H, C, N and S. The O residual of the authoritative-O solve gets no metal term (see [O and Si](#o-and-si-exchange-with-the-silicate-feo)). + +**What this does to the element.** Take an element that is only in the melt and in the metal, with no gas. The melt holds $w^\mathrm{melt} M_\mathrm{melt}$ of it and the metal $D\,w^\mathrm{melt} M_\mathrm{metal}$. With the mass ratio $r = M_\mathrm{metal}/M_\mathrm{melt}$, the metal then holds the fraction $f = D r/(1 + D r)$ of the element. For Earth's core and a fully molten Earth mantle, $r$ is about 0.48 (1.94 over 4.03). Then $D = 1$ already puts a third of the element into the metal, and $D = 100$ puts 98 % there. + +**Outputs.** `_kg_core` is the mass of the element named in `` in the metal, and `w__core` its mass fraction there. `_kg_total` stays the gas plus the melt. The bulk inventory of an element is `_kg_total + _kg_core`. + +## The partition coefficient + +Each element has one law in the partition file, of the form + +$$ +\log_{10} D = a + \frac{b}{T} + c\,\frac{P}{T} + d \cdot \mathrm{dIW\_factor} \cdot \Delta\mathrm{IW} + e\,(\mathrm{NBO/T}) + \sum_j \varepsilon_j \log_{10}(1 - X_j), +$$ + +with $T$ = `T_eq` in K, $P$ = `P_eq` in GPa and $\Delta\mathrm{IW}$ = `dIW_eq`. $a$, $b$, $c$, $d$ and $e$ are the fitted coefficients of the law, and `dIW_factor` is a fixed multiplier of the $\Delta\mathrm{IW}$ term (1 in the packaged file). $X_j$ is the $x'$ mole fraction of element $j$ in the metal, and $\varepsilon_j$ its fitted coefficient. The last term is the composition term: other light elements in the metal change how much of element $i$ it takes. `calliope.metal.log10_D` evaluates this law. + +The field `basis` of each law says what the law gives: a mass-basis $D$, a mole-basis $D$, or a mole-basis $D$ on the $x'$ basis. `calliope.metal.mass_D` converts a mole-basis $D$ to the mass basis with the moles per kg of metal and of melt: $D_w = D\,N_\mathrm{metal}/N_\mathrm{melt}$. The melt moles come from `silicate_oxides` (the KLB-1 composition by default), plus the H and C dissolved in the melt. With a mole basis, $D_w$ therefore depends on the metal composition, which is one more reason for the fixed point. + +## The oxygen fugacity of the metal-melt equilibrium + +`dIW_eq_mode` selects how CALLIOPE sets $\Delta\mathrm{IW}$ for the laws: + +- `'parameter'` (default): you give `dIW_eq` directly. +- `'from_FeO'`: CALLIOPE computes it from the FeO of the melt and the Fe of the metal, $\Delta\mathrm{IW} = 2\log_{10}(x_\mathrm{FeO}/x'_\mathrm{Fe})$, with ideal activities. With the KLB-1 melt (8.10 wt% FeO) and pure Fe metal this gives $-2.48$ (step 3 of the [metal reservoir tutorial](../Tutorials/metal_reservoir.md#step-3-the-2-modes-of-deltamathrmiw) shows it). More O or Si in the metal lowers $x'_\mathrm{Fe}$ and raises $\Delta\mathrm{IW}$. + +`dIW_eq` and `fO2_shift_IW` describe 2 different equilibria. `fO2_shift_IW` sets the absolute oxygen fugacity of the gas and the melt at the surface, through the IW buffer of the solve (`fO2_model`, see [Oxygen fugacity](oxygen_fugacity.md)). `dIW_eq` enters only the partition laws and the O and Si exchange, and CALLIOPE never converts it to an absolute oxygen fugacity. The buffer reaches the metal only through the composition that the solve returns. In `'from_FeO'` mode, a different buffer can give a different metal, and with it a different `dIW_eq`. With a fixed metal composition, `dIW_eq` is the same on every buffer. The [partition file reference](../Reference/partition_file.md#inputs-in-ddict) states which laws change with the buffer in each mode. + +## The fixed point on the metal composition + +$D$ depends on the metal (through the composition terms, the mole basis and, in `'from_FeO'` mode, through $\Delta\mathrm{IW}$), and the metal depends on $D$. `solve._with_metal` solves this in passes: + +1. Compute $\Delta\mathrm{IW}$ and $D$ of H, C, N and S from the metal and the melt of the previous pass (pure Fe at the start, plus the start O and Si). +2. Solve the gas, melt and metal balance at these fixed $D$. +3. Take the new metal H, C, N and S from the metal masses of that solve. +4. Stop when every metal mass fraction changes by at most `tol_w_core` of its value (plus $10^{-15}$, an absolute floor for fractions near 0); else go to step 1. + +For O and Si in `'partition'` mode (see the next section), a pass can take less than the step that the exchange asks for. The stop test for these 2 elements uses the full step, not the part that was taken. + +At most 30 passes run; then the solve raises `RuntimeError`. `tol_w_core` bounds the last step, not the error of the result. After the loop, a closure check confirms that gas, melt and metal hold the budget of each of H, C, N and S, within the tolerance of the solve. + +## O and Si: exchange with the silicate FeO + +O and Si enter the metal by reactions with the silicate: + +$$ +\mathrm{FeO} = \mathrm{Fe} + \mathrm{O}, \qquad \mathrm{SiO_2} + 2\,\mathrm{Fe} = \mathrm{Si} + 2\,\mathrm{FeO}. +$$ + +`O_mode_core` and `Si_mode_core` select how CALLIOPE sets them: + +- `'fixed'` (default): the metal holds the mass fractions `w_O_core` and `w_Si_core` (default 0). +- `'partition'`: `calliope.metal.exchange_metal` computes the mole fractions from the exchange $K_D$ of the file and the $\Delta\mathrm{IW}$ of each pass, with ideal activities. Each pass takes the whole step, with 2 exceptions. Let $s$ be the secant slope of the exchange value against the metal value over the last 2 passes. When $s$ is negative (the exchange value falls as the metal value rises), the pass takes the part $1/(1 - s)$ of the step: with $\Delta\mathrm{IW}$ from FeO, more metal Si raises $\Delta\mathrm{IW}$ and lowers the Si that the exchange gives, so full steps would swing back and forth about the solution. And while the O and Si moves of a pass together take at least half of the iron left in the metal, they are halved. + +The O of the metal comes from the FeO of the rock, not from the volatile O of the gas and the melt. So `O_kg_core` never enters the O balance: in the authoritative-O solve, the gas and melt O close on the O budget alone. + +CALLIOPE does not hold the FeO of the silicate as a reservoir. It reports what the metal took instead. `n_FeO_exchange` (mol) is $n_\mathrm{O} - 2\,n_\mathrm{Si}$ of the metal: the first reaction takes 1 FeO per O atom, the second gives back 2 FeO per Si atom. A negative value means that the metal gives FeO back to the melt. `n_FeO_silicate` (mol) is the FeO of the melt. When the exchange is more than 10 % of the melt FeO, `FeO_exchange_out_of_range` is 1: the melt FeO and the `dIW_eq` of this solve do not follow the exchange, so the result is not self-consistent. The caller applies the exchange to its own FeO budget. + +## Worked example + +This example puts an Earth-like volatile budget into a fully molten Earth mantle and lets the whole Earth core equilibrate with it once. The inputs: + +- Planet and melt as in the [First run tutorial](../Tutorials/firstrun.md): `M_mantle` $4.03 \times 10^{24}$ kg, `Phi_global` 1, `T_magma` 2500 K, `fO2_shift_IW` +0.5, the H of 1 Earth ocean ($1.55 \times 10^{20}$ kg), C/H 0.1 by mass, N 2 ppmw and S 200 ppmw (parts per million of the mantle mass). +- Metal: `metal_mass` $1.94 \times 10^{24}$ kg (`calliope.constants.M_core_earth`), `P_eq` 50 GPa, `T_eq` 3500 K, `dIW_eq` $-1$ in `'parameter'` mode, no O and Si. + +**Why this metal mass.** `metal_mass` is the metal that equilibrates with the present melt. Here it is the whole core, in equilibrium with the whole mantle at one pressure and temperature. This is the single-stage model of Tsutsumi et al. (2025, p. 4). In their words, it assumes "that the entire core and mantle reached chemical equilibrium under a certain P, T, and fO2 condition". Their list of published single-stage conditions includes 50 GPa and 3500 K, the values used here. It gives the effect of a metal of Earth's core mass that meets the whole molten mantle at once, at these conditions. It is not a model of how a core grows: in a planet, metal arrives in portions over time, and each portion meets the melt of its own time. + +**Why these conditions.** 50 GPa, 3500 K and $\Delta\mathrm{IW} = -1$ lie inside the data ranges of the placeholder H law ([Tagawa et al. 2021](https://doi.org/10.1038/s41467-021-22035-0): 30-60 GPa, 3080-4560 K, $\Delta\mathrm{IW}$ $-1.33$ to $-0.88$) and of the C law ([Fischer et al. 2020](https://doi.org/10.1073/pnas.1919930117): 1-59 GPa, 1618-5200 K, $\Delta\mathrm{IW}$ $-3.9$ to $-0.53$). The surface value `fO2_shift_IW` = +0.5 and `dIW_eq` = $-1$ differ on purpose: they describe the gas at the surface and the metal at depth. + +The result is below (with `np.random.seed(0)`, which sets the random start guesses of the solver). The flag is `_D_out_of_range`: it is 1 when the law of the element runs outside the pressure, temperature or $\Delta\mathrm{IW}$ range of the data it was fitted to; [Limits](#limits) says what the solve does then. + +| Element | $D$ (mass basis) | Metal share | Out of range flag | +|---|---|---|---| +| H | 22.14 | 91.4 % | 0 | +| C | 0.0828 | 0.55 % | 0 | +| N | 39 | 41.7 % | 1 | +| S | 173 | 98.8 % | 1 | + +The surface pressure falls from 8.7 bar without metal to 7.67 bar with metal. The atmosphere here is mostly C species, and almost no C goes into the metal at these conditions, so the pressure changes little. The H in the gas falls by a factor of about 140, from $2.26 \times 10^{17}$ kg to $1.58 \times 10^{15}$ kg. + +**How to read the flags.** N and S have flag 1. Each of their placeholder laws is a straight line in $\Delta\mathrm{IW}$ through 2 values of one column of a table ([Suer et al. 2023](https://doi.org/10.3389/feart.2023.1159412), Table 1). For N this column is at 20 GPa and 3000 K, for S at 60 GPa and 4000 K. Each law is in range only at its one pressure and temperature, so any other `P_eq` or `T_eq` is flagged. No `P_eq` and `T_eq` put N and S in range together. The values of $D_\mathrm{N}$ = 39 and $D_\mathrm{S}$ = 173 here are the table values at $\Delta\mathrm{IW} = -1$, carried to 50 GPa and 3500 K without a pressure or temperature term. Use such a result as an estimate only. + +**O and Si in `'partition'` mode.** The same case with `O_mode_core` and `Si_mode_core` set to `'partition'` gives 13.7 wt% O and 0.27 wt% Si in the metal. Both flags are 1: the placeholder O and Si laws have no known temperature and $\Delta\mathrm{IW}$ range, so every use is flagged. + +The net exchange (O minus 2 Si) takes $1.62 \times 10^{25}$ mol of FeO, 3.56 times the $4.54 \times 10^{24}$ mol of FeO in the melt, so `FeO_exchange_out_of_range` is 1 as well: there is not enough FeO in the melt for this exchange. + +The O also enters the C law through its composition term, and $D_\mathrm{C}$ rises from 0.0828 to 430; 97 % of the C then goes into the metal, and the surface pressure falls to 1.1 bar. This case shows why the flags matter: one flagged input (the metal O) changes a law that is in range (C) by a factor of about 5000. + +Steps 1, 2 and 4 of the [metal reservoir tutorial](../Tutorials/metal_reservoir.md) run this case and print these numbers. + +## Limits + +- **Placeholder laws.** The packaged file has `placeholder = true`, and each solve with it logs one warning per process. The N and S laws have no pressure or temperature term; the O and Si laws have no known temperature and $\Delta\mathrm{IW}$ range. Replace a law with a fitted law from a source you have read: see [Add a new partition law](../Tutorials/partition_law.md). +- **Out-of-range flags.** `_D_out_of_range` is 1 when the law of element `e` runs outside the pressure, temperature or $\Delta\mathrm{IW}$ range of its source data, or when a range is unknown. The solve still returns a result and logs a warning once per process; it does not clamp the law. +- **Ideal activities for O and Si.** `'partition'` mode leaves out the activity coefficients that published fits of these exchanges include. A solve in this mode logs a warning once per process. Treat its O and Si as a first estimate. +- **No FeO reservoir.** The exchange of O and Si does not change the melt FeO, the melt composition or `dIW_eq` of the solve. Above 10 % of the melt FeO, `FeO_exchange_out_of_range` is 1. +- **Cases that do not converge.** With $\Delta\mathrm{IW}$ from FeO and the input of the worked example below: + - O and Si both in `'partition'` mode converge at 5500 K and raise `ValueError` at 6000 to 7500 K: the O and Si mole fractions of the exchange fill the whole metal (no Fe left), and their sum did not fall since the pass before. + - O alone in `'partition'` mode converges up to 8480 K. From 8490 to 9700 K the passes end at the 30-pass cap with `RuntimeError`, and from 9720 K the solve raises the same `ValueError`. For O alone the exchange asks for $x_\mathrm{O} = K_D(\mathrm{O})\,x_\mathrm{FeO}/(1 - x_\mathrm{O})$, which has a root only while $K_D(\mathrm{O})\,x_\mathrm{FeO} \le 1/4$ (here up to 9470 K). The [partition file reference](../Reference/partition_file.md#inputs-in-ddict) gives the exact stop rules. +- **`metal_mass` is held fixed.** Fe that moves into or out of the metal through the O and Si exchange does not change `metal_mass`. +- **PROTEUS does not pass `metal_mass`.** A PROTEUS run has no metal reservoir. The reservoir is available in standalone CALLIOPE runs (see [Coupling to PROTEUS](proteus_coupling.md)). + +**Where this is checked.** The [metal reservoir validation page](../Validation/metal.md) checks the metal term against mass balance, the full solve against a solve with a larger melt, the O bookkeeping of the authoritative-O solve, and the laws against published laboratory values. diff --git a/docs/Explanations/model.md b/docs/Explanations/model.md index c6592887..fe8d3871 100644 --- a/docs/Explanations/model.md +++ b/docs/Explanations/model.md @@ -21,6 +21,7 @@ This page summarises the model assumptions, the variables it solves for, and how - **One oxygen-fugacity buffer**: Hirschmann (2021) [^cite-hirschmann2021] iron-wüstite (default; matches atmodeller's buffer above 1000 K), Fischer et al. (2011) [^cite-fischer2011], or the legacy O'Neill & Eggins (2002) [^cite-oneilleggins2002] IW. The shift $\Delta\mathrm{IW}$ sets $\log_{10} f_{\mathrm{O}_2}$ relative to the buffer; under the buffered mode it is a user-prescribed input, under the authoritative-O mode it is a solver unknown. - **One solubility law per species** with multiple alternative compositions (peridotite, basalt, lunar glass, anorthite-diopside) selectable via constructor argument. +- **An optional metal reservoir**: with `metal_mass` above zero, liquid metal in equilibrium with the melt takes part of the H, C, N and S, and O and Si from the silicate FeO. It is off by default. See [Metal reservoir](metal.md). ## What is *not* in the model @@ -42,6 +43,8 @@ $$ Under the buffered mode the equation set spans $e \in \{\mathrm{H}, \mathrm{C}, \mathrm{N}, \mathrm{S}\}$, the four primary partial pressures are the unknowns, and the $4\times 4$ system is solved with `scipy.optimize.fsolve` (Powell hybrid). Under the authoritative-O mode the equation set spans $e \in \{\mathrm{H}, \mathrm{C}, \mathrm{N}, \mathrm{S}, \mathrm{O}\}$, the unknown vector is extended with $\Delta\mathrm{IW}$, and the $5\times 5$ system is solved with the same outer loop ([Authoritative-oxygen mode](authoritative_oxygen.md)). +With a metal reservoir, each of the H, C, N and S equations gains a third term on the left, the mass of the element in the metal, $D_e M_\mathrm{metal} w_e^\mathrm{melt}$ ([Metal reservoir](metal.md#the-element-balance-with-a-metal-term)). + The seven secondary partial pressures are *not* independent in either mode: they are algebraic functions of the primaries via the six equilibrium constants, evaluated at $T = T_\mathrm{magma}$ and $\log_{10} f_{\mathrm{O}_2} = \log_{10} f_{\mathrm{O}_2}^\mathrm{IW}(T) + \Delta\mathrm{IW}$. The four pieces of physics decompose cleanly: @@ -52,6 +55,7 @@ The four pieces of physics decompose cleanly: | Atmospheric column mass | [Mass balance & solver](mass_balance.md) | `solve.atmosphere_mass` | | Dissolved mass via Henry / power-law / multi-arg solubility | [Solubility laws](solubility.md) | `solubility.SolubilityH2O`, ..., `solve.dissolved_mass` | | Hirschmann IW buffer (default), Fischer IW buffer, O'Neill IW buffer (legacy) | [Oxygen fugacity](oxygen_fugacity.md) | `oxygen_fugacity.OxygenFugacity` | +| Optional metal reservoir (core term, partition laws) | [Metal reservoir](metal.md) | `metal.mass_D`, `metal.metal_masses`, `solve._with_metal` | ## Lineage @@ -70,7 +74,7 @@ CALLIOPE is calibrated for surface temperatures of roughly $1000 \le T_\mathrm{m - The JANAF constants for H$_2$ and CO have a use range of only $1500 \le T \le 3000$ K (named in their `chemistry.py` docstrings), so inside the envelope below 1500 K and above 3000 K they are used outside it; the fits for H$_2$S and NH$_3$ cover $700 \le T \le 5000$ K and log one warning per process below 700 K, but both species also depend on the H$_2$ that `janaf_H2` sets, which the warning does not cover (see [Equilibrium chemistry](equilibrium_chemistry.md)). The PROTEUS wrapper enforces a configurable `T_floor` (default 700 K), which clips temperatures below `T_floor` to this value, since thermochemical equilibrium does not necessarily hold at cooler temperatures. - Above $T \sim 4000$ K the mantle-atmosphere partitioning approximation breaks down; switch to atmodeller (Bower et al. 2025 [^cite-bower2025]). - The Sossi (2023) peridotite and Newcombe (2017) anorthite-diopside / lunar-glass H$_2$O laws are calibrated at 1 atm. The Dixon (1995) basalt fit is calibrated to 717 bar $p_\mathrm{H_2O}$. For surface pressures above $\sim$1 kbar use the Hamilton (1964) basalt fit (1-6 kbar calibration range) or atmodeller for higher-pressure non-ideal behaviour. Applying the Sossi or Newcombe fits at kbar pressures extrapolates the partial-pressure input by 3 orders of magnitude beyond calibration; the resulting dissolved-mass error scales as $p^{0.5}$ for the power-law fits, so the error is a factor of $\sim$30 at 1 kbar. -- Solid-phase partitioning is ignored; CALLIOPE strictly handles melt + gas. Use it only when $\Phi_\mathrm{global} > 0$, or accept that all dissolved masses will be zero. +- Solid-phase partitioning is ignored; CALLIOPE handles melt and gas, plus the optional metal reservoir. Use it only when $\Phi_\mathrm{global} > 0$, or accept that all dissolved masses will be zero. [^cite-bower2019]: D. J. Bower, D. Kitzmann, A. S. Wolf, P. Sanan, C. Dorn, A. V. Oza, *[Linking the evolution of terrestrial interiors and an early outgassed atmosphere to astrophysical observations](https://doi.org/10.1051/0004-6361/201935710)*, Astronomy & Astrophysics, 631, A103, 2019. [SciX](https://scixplorer.org/abs/2019A%26A...631A.103B/abstract). [^cite-bower2022]: D. J. Bower, K. Hakim, P. A. Sossi, P. Sanan, *[Retention of water in terrestrial magma oceans and carbon-rich early atmospheres](https://doi.org/10.3847/PSJ/ac5fb1)*, The Planetary Science Journal, 3(4), 93, 2022. [SciX](https://scixplorer.org/abs/2022PSJ.....3...93B/abstract). diff --git a/docs/Explanations/oxygen_fugacity.md b/docs/Explanations/oxygen_fugacity.md index 7dfb6e17..7f833a00 100644 --- a/docs/Explanations/oxygen_fugacity.md +++ b/docs/Explanations/oxygen_fugacity.md @@ -98,6 +98,10 @@ The Gaillard et al. (2022) [^cite-gaillard2022] sulfide solubility carries an ex Similarly, the Dasgupta et al. (2022) [^cite-dasgupta2022] N$_2$ solubility includes a $-1.6\,\Delta\mathrm{IW}$ term in its exponent, so reducing conditions sharply increase the dissolved-N inventory. `solubility.SolubilityN2.dasgupta` takes the solve's shift as this $\Delta\mathrm{IW}$ directly, on whichever buffer the solve uses. Dasgupta et al. (2022) define $\Delta\mathrm{IW}$ by the Fe-FeO activity ratio of the melt (their Eq. 2) and name no IW buffer curve, so there is no buffer to convert to. This is the one channel that the `fO2_model` setting does not reach: at a fixed absolute $f_{\mathrm{O}_2}$, switching buffers moves the shift and with it the dissolved N. This is one mechanism by which planet-scale N partitioning is tied to mantle redox; see Nicholls et al. (2026) [^cite-nicholls2026] for an application to L 98-59 d, where the inferred H$_2$-dominated atmosphere with photochemical SO$_2$ implies $\Delta\mathrm{IW}$ between IW-4 and IW-1. +## The oxygen fugacity of the metal reservoir + +The optional [metal reservoir](metal.md) has its own $\Delta\mathrm{IW}$, `dIW_eq`: the oxygen fugacity at which the metal and the melt equilibrate, at the pressure `P_eq` and temperature `T_eq` of that equilibrium. It is a separate input from `fO2_shift_IW`, which sets the gas and the melt at the surface. `dIW_eq` enters only the partition laws and the O and Si exchange; CALLIOPE does not convert it to an absolute $f_{\mathrm{O}_2}$, so the IW buffer of the solve does not apply to it. With `dIW_eq_mode = 'from_FeO'` CALLIOPE computes it from the FeO of the melt and the Fe of the metal; the buffer then reaches it only through the composition that the solve returns. See [Metal reservoir](metal.md#the-oxygen-fugacity-of-the-metal-melt-equilibrium). + ## Reference values for $\Delta\mathrm{IW}$ | Reservoir | $\Delta\mathrm{IW}$ | Source | diff --git a/docs/Explanations/proteus_coupling.md b/docs/Explanations/proteus_coupling.md index c61e73a1..11104bf9 100644 --- a/docs/Explanations/proteus_coupling.md +++ b/docs/Explanations/proteus_coupling.md @@ -11,7 +11,7 @@ PROTEUS runs an iterative loop in which structure, outgassing, atmosphere radiat │ while not done: │ │ │ │ # 1. Static structure (Zalmoxis or SPIDER) │ - │ run_structure(config, hf_row) # writes R_int, M_mantle, │ + │ run_structure(config, hf_row) # writes R_int, M_core, │ │ # gravity, R_cmb, P_cmb │ │ │ │ # 2. Outgassing (this is CALLIOPE) │ @@ -27,7 +27,7 @@ PROTEUS runs an iterative loop in which structure, outgassing, atmosphere radiat │ # 4. Interior thermal evolution (Aragog or SPIDER) │ │ run_interior(config, hf_row) # writes T_magma, S_mantle,│ │ # Phi_global, T_core, │ - │ # F_int │ + │ # F_int, M_mantle │ │ │ │ # 5. Atmospheric escape (ZEPHYRUS) │ │ run_escape(dirs, config, hf_row) # writes esc_kg_*, │ @@ -50,7 +50,7 @@ CALLIOPE is the cheapest of the five (a few tens of milliseconds per call vs. se | `solvevol_inp[…]` | Source | |---|---| -| `M_mantle` | `hf_row['M_mantle']` (set by structure module) | +| `M_mantle` | `hf_row['M_mantle']` (set by the interior structure and energetics steps) | | `gravity` | `hf_row['gravity']` | | `radius` | `hf_row['R_int']` | | `Phi_global` | `hf_row['Phi_global']` if `config.outgas.calliope.solubility` else `0.0` | @@ -169,6 +169,10 @@ For each key in `expected_keys()` (defined in `proteus.outgas.common`), the wrap | `/_atm` | Atmospheric elemental mass ratios | | `_res` | Mass-balance residual for element `e`, kg | +### No metal reservoir in PROTEUS + +The wrapper does not pass `metal_mass`, so a PROTEUS run solves gas and melt only: the [metal reservoir](metal.md) is off, and all of the H, C, N and S stays in the gas and the melt. The metal keys of CALLIOPE (`_kg_core`, `w__core` and the others) are not in `expected_keys()`, so `hf_row` has none of them. The metal reservoir is available in standalone CALLIOPE runs ([tutorial](../Tutorials/metal_reservoir.md)). + After the writeback, `wrapper.run_outgassing` recomputes `M_atm` from the per-species `_kg_atm` sum (defensive: catches any internal inconsistency in the result dict). ## Where the binodal lives diff --git a/docs/Explanations/testing.md b/docs/Explanations/testing.md index 468d4fd5..953aa77d 100644 --- a/docs/Explanations/testing.md +++ b/docs/Explanations/testing.md @@ -77,7 +77,7 @@ A unit test on any physics source must assert at least one of four invariant fam Tests that meet one or more of these are tagged `@pytest.mark.physics_invariant`. The marker is per-function, not module-level: structural tests in the same file (for example, an ordering or pass-through-assignment check) should not carry it. -Physics sources in CALLIOPE are the five files `chemistry.py`, `oxygen_fugacity.py`, `solubility.py`, `solve.py`, `structure.py`. +Physics sources in CALLIOPE are the six files `chemistry.py`, `metal.py`, `oxygen_fugacity.py`, `solubility.py`, `solve.py`, `structure.py`. Utility sources (`__init__.py`, `_version.py`, `constants.py`) are exempt from the invariant requirement but remain subject to the anti-happy-path rules below. ## Reference-pinned validation @@ -89,12 +89,13 @@ The anchor is one of: - an **analytical limit** (for example, the ideal-gas limit at low pressure, or the Stefan-Boltzmann black-body limit), - a **cross-implementation check** (CALLIOPE vs atmodeller at a shared Earth fiducial). -Each of the five physics sources carries at least one reference-pinned test, recorded on the matching `docs/Validation/.md` page. +Each physics source carries at least one reference-pinned test, recorded in a section on a page in `docs/Validation/`; the [Validation overview](../Validation/index.md) lists every case. The current anchors: | Source | Anchor | Test | |---|---|---| | `chemistry.py` | JANAF Thermochemical Tables [^cite-chase1998] (4th ed.), $K_{eq}$ for $\mathrm{H_2O} \to \mathrm{H_2} + 0.5\,\mathrm{O_2}$ at 2000 K with the O'Neill & Eggins 2002 [^cite-oneilleggins2002] buffer | `tests/test_chemistry.py::test_modified_keq_janaf_H2_matches_closed_form_at_2000K_with_oneill` | +| `metal.py` | [Tagawa et al. 2021](https://doi.org/10.1038/s41467-021-22035-0): the H law of their Eq. 2 against the 6 runs of their SI Table 1 | `tests/test_metal.py::test_tagawa_runs_lie_within_one_sigma_of_eq2` | | `oxygen_fugacity.py` | Cross-implementation check of the coded Fischer et al. 2011 [^cite-fischer2011] (EPSL 304, 496) IW expression against the O'Neill & Eggins 2002 [^cite-oneilleggins2002] buffer at 2000 K; no independent pin of the Fischer 1-bar fit exists | `tests/test_oxygen_fugacity.py::test_oxygen_fugacity_fischer_value_at_2000K_cross_check_and_regression` | | `solubility.py` | Sossi et al. 2023 [^cite-sossi2023] peridotite H$_2$O fit; Gaillard et al. 2022 [^cite-gaillard2022] (EPSL 117255) S$_2$ fit | `tests/test_solubility.py::TestSolubilityH2O::test_peridotite_default_matches_sossi_2023_fit` and `TestSolubilityS2_xFeO::test_default_call_matches_gaillard_2022_earth_mantle_value` | | `solve.py` | Self-consistency between `equilibrium_atmosphere` (buffered) and `equilibrium_atmosphere_authoritative_O` (the authoritative-O entry point) at the Earth fiducial | `tests/test_solve.py::test_round_trip_self_consistency_at_earth_fiducial` | @@ -109,6 +110,7 @@ Each source file in `src/calliope/` has a same-named companion in `tests/`: | Source | Test | |---|---| | `src/calliope/chemistry.py` | `tests/test_chemistry.py` | +| `src/calliope/metal.py` | `tests/test_metal.py` | | `src/calliope/oxygen_fugacity.py` | `tests/test_oxygen_fugacity.py` | | `src/calliope/solubility.py` | `tests/test_solubility.py` | | `src/calliope/solve.py` | `tests/test_solve.py` | @@ -121,8 +123,9 @@ Cross-cutting tests are the documented exception, not the rule: - `tests/test_invariants.py`, `tests/test_invariants_hypothesis.py`: contract clauses that span multiple sources (mass closure end-to-end, partial-species behaviour, stoichiometry of an arbitrary recipe). - `tests/test_authoritative_O.py`, `tests/test_authoritative_O_validation.py`, `tests/test_authoritative_O_monotonicity.py`: the authoritative-O entry point, which touches `solve.py`, `chemistry.py`, and `oxygen_fugacity.py` together. - `tests/test_equilibrium_paths.py`, `tests/test_partial_species.py`, `tests/test_stoichiometry.py`, `tests/test_targets.py`: solver-architecture tests that span the same three files. +- `tests/test_metal_solve.py`, `tests/test_metal_fields.py`: the metal reservoir in the full solve and the checks of its `ddict` keys, which span `metal.py` and `solve.py`. -When a new physics source is added, its 1:1 test file is created at the same time; the matching `docs/Validation/.md` page is added when the first reference-pinned test for that source lands. +When a new physics source is added, its 1:1 test file is created at the same time; its cases get a section on a page of the Validation tab when the first reference-pinned test for that source lands. ## AST test-quality linter diff --git a/docs/How-to/build_tests.md b/docs/How-to/build_tests.md index 315fbf06..d9ca9f38 100644 --- a/docs/How-to/build_tests.md +++ b/docs/How-to/build_tests.md @@ -149,7 +149,7 @@ The four families with one-line examples: | Monotonicity / symmetry | `assert log10_fO2(T=2000) < log10_fO2(T=1500)` along an isobaric buffer | | Pinned value with discrimination guard | (see the example above) | -The five physics sources (`chemistry.py`, `oxygen_fugacity.py`, `solubility.py`, `solve.py`, `structure.py`) must each carry at least one such test in the matching `tests/test_.py`. +The six physics sources (`chemistry.py`, `metal.py`, `oxygen_fugacity.py`, `solubility.py`, `solve.py`, `structure.py`) must each carry at least one such test in the matching `tests/test_.py`. The utility sources (`__init__.py`, `_version.py`, `constants.py`) are exempt from the invariant requirement but still subject to the anti-happy-path rules above. Structural tests (ordering, autonomy, mutation-in-place, pass-through assignment) in a physics-source test file should not carry the marker. @@ -163,16 +163,18 @@ The anchor is one of: - an **analytical limit** (the ideal-gas limit at low pressure, the Stefan-Boltzmann black-body limit), - a **cross-implementation check** (CALLIOPE vs atmodeller at a shared fiducial). -Each of the five physics sources carries at least one reference-pinned test. -The current anchor list is in the [testing suite](../Explanations/testing.md#reference-pinned-validation) explainer. +Each physics source carries at least one reference-pinned test. +The [Validation overview](../Validation/index.md) lists every case. -When the first reference-pinned test for a new source lands, create the matching `docs/Validation/.md` page with: +When the first reference-pinned test for a new source lands, add a section for each case to a page in `docs/Validation/` (a new page for a new process) with: - the source under test, - the cited paper or analytical limit, - the closed-form re-derivation of the pinned value (no skipped algebra), - the wrong-formula or wrong-buffer discrimination number. +Add a new page to the Validation tab in `mkdocs.yml` and each new case to the table of `docs/Validation/index.md`; `tests/test_validation_index.py` fails when a reference-pinned test is missing from that table or the table names a test that is not reference-pinned. + Tests carrying `reference_pinned` typically also carry `physics_invariant` (the published-value pin is itself the invariant). ## Optional-dependency imports @@ -310,7 +312,7 @@ When a new `src/calliope/.py` lands: 1. Create the matching `tests/test_.py` with the module-level `pytestmark` (typically `unit` with a 30 s timeout). 2. Write at least one test that asserts one of the four invariant families and tag it `@pytest.mark.physics_invariant`. 3. Plan a `@pytest.mark.reference_pinned` test against a published benchmark, analytical limit, or cross-implementation check. -4. When that test lands, create `docs/Validation/.md` with the anchor, the re-derivation, and the discrimination number. Link it from `mkdocs.yml` if the docs site should surface it. +4. When that test lands, add a section for each case to a page in `docs/Validation/` with the anchor, the re-derivation, and the discrimination number, and add the cases to the table of `docs/Validation/index.md`. 5. Update `PHYSICS_SOURCES` in `tools/check_test_quality.py` to include the new file name. 6. Run the full PR checks locally: diff --git a/docs/How-to/configuration.md b/docs/How-to/configuration.md index 45e825ca..8162e96c 100644 --- a/docs/How-to/configuration.md +++ b/docs/How-to/configuration.md @@ -49,6 +49,23 @@ If the calling code uses `calliope.solve.get_target_from_params(ddict)` to build When CALLIOPE is called from PROTEUS, the wrapper in `proteus.outgas.calliope` sets these four fields automatically based on the `[planet.elements]` block of the PROTEUS config. +### Optional: metal reservoir + +These keys add a metal reservoir in equilibrium with the melt ([Metal reservoir](../Explanations/metal.md)). Without the key `metal_mass`, the solve has no metal and does not read the other keys. With `metal_mass` present, also with 0, the solve checks every metal key that is present, except `partition_table`, and raises `ValueError` for a bad value; with 0 it adds no metal. The [partition file reference](../Reference/partition_file.md#inputs-in-ddict) gives the default, the allowed range and the rules of each key. + +| Key | Type | Units | Meaning | +|---|---|---|---| +| `metal_mass` | float | kg | Mass of the metal in equilibrium with the present melt. Above 0 turns the reservoir on and needs `Phi_global` above 0. | +| `P_eq`, `T_eq` | float | GPa, K | Pressure and temperature of the metal-melt equilibrium. Required with metal. | +| `dIW_eq_mode` | str | n/a | `'parameter'` (default) or `'from_FeO'`. | +| `dIW_eq` | float | $\log_{10}$ units | $\Delta\mathrm{IW}$ of the metal-melt equilibrium. Required in `'parameter'` mode. | +| `O_mode_core`, `Si_mode_core` | str | n/a | `'fixed'` (default) or `'partition'`. | +| `w_O_core`, `w_Si_core` | float | mass fraction | O and Si of the metal in `'fixed'` mode, and the start metal in `'partition'` mode. Default 0. | +| `silicate_oxides` | dict | wt% | Melt composition by oxide. Default KLB-1. | +| `silicate_nbo_t` | float | n/a | NBO/T of the melt. Default 2.6. | +| `partition_table` | dict | n/a | A table from `calliope.metal.load_partition_table`. Default: the packaged placeholder file. | +| `tol_w_core` | float | n/a | Relative change of each metal mass fraction between 2 passes at which the loop on the metal composition stops. Default 1e-6. | + ## The `target` dictionary `target` carries the elemental conservation constraints, in kilograms. For the buffered-mode entry point [`equilibrium_atmosphere`](usage.md), the dict has four keys: diff --git a/docs/How-to/proteus_coupling.md b/docs/How-to/proteus_coupling.md index 6e7c2696..f61ff4dc 100644 --- a/docs/How-to/proteus_coupling.md +++ b/docs/How-to/proteus_coupling.md @@ -4,6 +4,9 @@ This page is the **practical recipe** for using CALLIOPE inside a PROTEUS couple For the underlying control flow (per-iteration sequence diagram, what the wrapper passes to `equilibrium_atmosphere()`, how warm-starts are constructed, what gets written back into `hf_row`), see the [theory page](../Explanations/proteus_coupling.md). +!!! note "Metal reservoir" + The [metal reservoir](../Explanations/metal.md) of CALLIOPE is available in standalone runs only. PROTEUS does not pass `metal_mass`, and no PROTEUS config key turns it on, so a coupled run keeps all of the H, C, N and S in the gas and the melt. + ## Minimal `[outgas]` block ```toml diff --git a/docs/How-to/usage.md b/docs/How-to/usage.md index 0525d717..4dba6b0f 100644 --- a/docs/How-to/usage.md +++ b/docs/How-to/usage.md @@ -52,9 +52,9 @@ The `result` dictionary contains, for every species `s` in `volatile_species`: - `s_kg_atm`: atmospheric column mass, kg - `s_kg_liquid`: dissolved mass, kg - `s_kg_solid`: 0.0 (solid partitioning is handled by the caller, not CALLIOPE) -- `s_kg_total`: sum of atmosphere + liquid +- `s_kg_total`: sum of atmosphere + liquid (it excludes the core when a metal reservoir is on) -and per-element totals `H_kg_atm`, `C_kg_atm`, ..., `S_res` (mass-balance residual in kg). `fallback` (a bool) is True when the returned state misses the tolerance of an element and a warning was logged. `fO2_model` names the IW buffer the solve used (a string); set it as `ddict['fO2_model']` to evaluate a state with `get_partial_pressures`, `atmosphere_mass` or `dissolved_mass` on the same buffer. +and per-element totals `H_kg_atm`, `C_kg_atm`, ..., `S_res` (mass-balance residual in kg). `fallback` (a bool) is True when the returned state misses the tolerance of an element and a warning was logged. `fO2_model` names the IW buffer the solve used (a string); set it as `ddict['fO2_model']` to evaluate a state with `get_partial_pressures`, `atmosphere_mass` or `dissolved_mass` on the same buffer. With `metal_mass` above zero in `ddict` the result also carries the metal reservoir keys `_kg_core`, `w__core`, `_D_metal`, `_D_out_of_range` and `dIW_eq` ([metal reservoir](../Reference/partition_file.md)). ## Pattern 2 - solve from initial partial pressures @@ -88,6 +88,26 @@ result = equilibrium_atmosphere(target, ddict, print_result=True) `get_target_from_pressures()` computes the implied total elemental masses by summing atmospheric column mass (Bower et al. (2019) [^cite-bower2019] Eq. 2) and dissolved mass (Henry's law) across every included species at the prescribed initial pressures. +## Pattern 3 - add a metal reservoir + +Start from the `ddict` and `target` of Pattern 1. Add `metal_mass` (kg) above zero and the conditions of the metal-melt equilibrium, and the same call solves gas, melt and metal together: + +```python +ddict_metal = { + **ddict, + 'metal_mass': 1.94e24, # kg of metal in equilibrium with the present melt + 'P_eq': 50.0, # GPa + 'T_eq': 3500.0, # K + 'dIW_eq': -1.0, # log10 units relative to the IW buffer +} +result = equilibrium_atmosphere(target, ddict_metal, print_result=False) +print(f"H in the metal: {result['H_kg_core']:.3e} kg, D_H = {result['H_D_metal']:.3g}") +``` + +With the input of Pattern 1 this prints `H in the metal: 1.417e+20 kg, D_H = 22.1`, together with the warnings that the [metal reservoir tutorial](../Tutorials/metal_reservoir.md#step-5-read-the-warnings-and-the-flags) explains. + +The result adds, per element `e`, the mass in the metal `_kg_core` (kg), the mass fraction `w__core`, the partition coefficient `_D_metal` and the range flag `_D_out_of_range`, plus `dIW_eq` and the FeO exchange keys. `_kg_total` stays the gas plus the melt; the bulk inventory is `_kg_total + _kg_core`. The [partition file reference](../Reference/partition_file.md) lists every key, the [metal reservoir tutorial](../Tutorials/metal_reservoir.md) runs the options step by step, and the [explanation page](../Explanations/metal.md) gives the physics. + ## Warm-starting from a previous solve For repeated calls (e.g. inside a time-evolution loop), pass the previous solution as the initial guess to dramatically reduce the number of `fsolve` iterations: diff --git a/docs/Reference/api/calliope.metal.md b/docs/Reference/api/calliope.metal.md new file mode 100644 index 00000000..855a1218 --- /dev/null +++ b/docs/Reference/api/calliope.metal.md @@ -0,0 +1,4 @@ +::: calliope.metal + options: + inherited_members: false + show_source: true diff --git a/docs/Reference/api/index.md b/docs/Reference/api/index.md index 5306fde8..f6f8d824 100644 --- a/docs/Reference/api/index.md +++ b/docs/Reference/api/index.md @@ -12,6 +12,7 @@ src/calliope/ ├── chemistry.py # ModifiedKeq (six redox couples) ├── solubility.py # Solubility{H2O,CO2,CO,CH4,N2,S2} ├── solve.py # equilibrium_atmosphere() and supporting functions +├── metal.py # partition file reader, partition laws, metal reservoir masses └── structure.py # calculate_mantle_mass() simple core/mantle split ``` @@ -39,7 +40,7 @@ src/calliope/ ### Structure -- [`calliope.structure`](calliope.structure.md) - `calculate_mantle_mass(radius, mass, core_frac)` simple Earth-density core/mantle split. Provided as a fallback; PROTEUS-coupled runs source `M_mantle` from Zalmoxis or SPIDER instead. +- [`calliope.structure`](calliope.structure.md) - `calculate_mantle_mass(radius, mass, core_frac)` simple Earth-density core/mantle split. Provided for standalone runs; PROTEUS does not call it and passes the mantle mass of its interior steps ([code architecture](../../Explanations/code_architecture.md#structurepy)). ### Constants diff --git a/docs/Reference/partition_file.md b/docs/Reference/partition_file.md new file mode 100644 index 00000000..dc6f35c8 --- /dev/null +++ b/docs/Reference/partition_file.md @@ -0,0 +1,81 @@ +# Metal reservoir and partition file + +With `metal_mass` above zero, `equilibrium_atmosphere` and `equilibrium_atmosphere_authoritative_O` add a metal reservoir in equilibrium with the melt. The element balance of H, C, N and S gains a core term $D_i M_\mathrm{metal} w_i^\mathrm{melt}$, with $D_i$ the mass-basis metal over melt partition coefficient. O and Si in the metal are set by a mass fraction or by their exchange reaction. The partition laws come from a TOML file; CALLIOPE ships a placeholder file. The validation cases are on the [metal reservoir validation page](../Validation/metal.md). + +## Inputs in `ddict` + +| Key | Unit | Default | Meaning | +|---|---|---|---| +| `metal_mass` | kg | absent (no metal) | Mass of the metal that equilibrates with the present melt; 0 is the solve without metal; above 0 needs melt (`Phi_global` above 0) | +| `P_eq`, `T_eq` | GPa, K | required | Pressure and temperature of the metal-melt equilibrium | +| `dIW_eq_mode` | | `'parameter'` | `'parameter'` reads `dIW_eq`; `'from_FeO'` uses $2\log_{10}(x_\mathrm{FeO}/x'_\mathrm{Fe})$ with ideal activities, $x_\mathrm{FeO}$ on the single-cation basis of the melt and $x'_\mathrm{Fe}$ over the metal without H and C (needs FeO above 0) | +| `dIW_eq` | log10 units | required for `'parameter'` | Oxygen fugacity of the equilibrium relative to the iron-wustite buffer | +| `O_mode_core`, `Si_mode_core` | | `'fixed'` | `'fixed'` reads `w_O_core`, `w_Si_core`; `'partition'` uses the exchange $K_D$ of the file at the dIW of each pass | +| `w_O_core`, `w_Si_core` | mass fraction | 0 | Metal O and Si in `'fixed'` mode, and the start metal of the passes in `'partition'` mode; at least 0, each and their sum below 1 | +| `silicate_oxides` | wt% | KLB-1 (7 major oxides, Takahashi 1986) | Melt composition for the mole basis of $D$; sums to 95-101 wt% and is used as given, not normalised; Si `'partition'` needs SiO2 above 0 | +| `silicate_nbo_t` | | 2.6 | NBO/T of the melt | +| `partition_table` | | packaged placeholder | A table from `calliope.metal.load_partition_table` | +| `tol_w_core` | | 1e-6 | Relative stop of the fixed point on each metal mass fraction, above 0 and below 1 | + +Every key except `partition_table` is checked before the first solve: a value of the wrong type, NaN, infinity or a value out of range raises `ValueError` naming the key, and with `metal_mass` above zero `P_eq`, `T_eq` and, in `'parameter'` mode, `dIW_eq` are required. The solve runs a fixed point: it evaluates $D$ on the metal and melt of the previous pass with the O and Si of the present pass, solves at fixed $D$, and stops when each metal mass fraction moves by at most `tol_w_core` of its value plus 1e-15 (at most 30 passes). A partitioned O or Si moves by $1/(1 - s)$ of its exchange step, with $s$ the secant slope of the exchange value against the metal value of the last 2 passes when $s < 0$, and 1 otherwise: with dIW from FeO, more metal Si raises dIW and lowers the exchange Si, and whole steps alternate about the solution; with O and Si both partitioned each secant also holds the move of the other element. The moves of a pass are halved until they take at most half of the iron left in the metal. A first exchange from pure Fe metal may leave no iron (Si at 4800 K with dIW from FeO, x_Si 1.10); the pass then takes its step, and with O and Si at 5000 K the halving keeps the metal in the range of the laws. An exchange that leaves no iron and does not fall from the previous pass raises `ValueError` naming the partitioned elements and their mode fields: no fixed point was reached from the start metal (`w_O_core`, `w_Si_core`), and another start metal can reach one; with Si from FeO at 6500 K the sum stays above 1 for 2 passes while it falls to its fixed point. This refusal is a stop rule, not a proof that no metal with iron solves the exchange; with dIW from FeO and the input of the worked example on the [metal reservoir page](../Explanations/metal.md#worked-example) (`metal_mass` 1.94e24 kg, P_eq 50 GPa, KLB-1 melt), O and Si both partitioned converge at 5500 K and raise this `ValueError` at 6000, 6500, 7000 and 7500 K. O alone partitioned converges up to 8480 K, ends at the 30-pass cap with `RuntimeError` from 8490 to 9700 K, and raises the `ValueError` from 9720 K (at 9720, 9740, 9780 and 9800-10000 K; other solves between 9710 and 9790 K ran longer than 300 s). For O alone the exchange asks for $x_\mathrm{O} = K_D(\mathrm{O})\,x_\mathrm{FeO}/(1 - x_\mathrm{O})$, which has a root only while $K_D(\mathrm{O})\,x_\mathrm{FeO} \le 1/4$ (here up to 9470 K); the passes stop converging already at $K_D(\mathrm{O})\,x_\mathrm{FeO} = 0.228$. For these elements the stop reads the whole step. A law whose $D$ or $K_D$ is beyond the float range raises `ValueError` that names the law and gives its P, T and dIW; an O and Si exchange that is not finite raises its own `ValueError`. The dIW term of each pass uses the metal before the O and Si of that pass move, one pass behind, which is below `tol_w_core` at convergence. `tol_w_core` bounds the last step of the passes, not the error of the result; when the passes contract slowly the error can be larger than the last step. The metal takes the H, C, N and S of the melt in proportion to `metal_mass`, so the caller sets `metal_mass` to the metal that equilibrates with the present melt. + +`dIW_eq` and the `'from_FeO'` dIW enter only the partition laws and the O and Si exchange, and neither is converted to an absolute fO2. The IW buffer of the solve (`fO2_model`) turns `fO2_shift_IW` into the absolute fO2 of the gas and melt chemistry. It reaches the metal through the metal and melt composition that the solve returns, so a solved `'from_FeO'` dIW changes with the buffer, and with it the $D$ of each law with a dIW term; the $D$ of a law with a composition term or a mole basis changes with the buffer in either mode. With a fixed composition the dIW is the same on every buffer. + +The metal O comes from the silicate FeO, not from the volatile O budget: in the authoritative-O solve gas and melt O close on the O budget, and `O_kg_core` is reported apart. CALLIOPE does not hold the silicate FeO reservoir; the caller applies the exchange from `n_FeO_exchange`. `metal_mass` is held fixed while Fe moves into or out of the metal. + +`M_mantle` and `metal_mass` come from the caller. Inside PROTEUS `M_mantle` is the mantle mass that its interior steps set ([code architecture](../Explanations/code_architecture.md#structurepy)). PROTEUS does not pass `metal_mass`, so its runs have no metal reservoir. A caller that takes `M_mantle` from a structure model takes the metal from the same model: `metal_mass` is the part of its core that equilibrates with the present melt. + +## Outputs + +| Key | Unit | Meaning | +|---|---|---| +| `_kg_core` | kg | Element mass in the metal, for H, C, N, S, O and Si | +| `w__core` | mass fraction | Element mass fraction of the metal | +| `_D_metal` | | Mass-basis $D$ of H, C, N and S in the last pass | +| `_D_out_of_range` | | 1 when the law of the element runs outside its (P, T, dIW) range or on an unknown range; O and Si only in `'partition'` mode | +| `dIW_eq` | log10 units | dIW of the equilibrium in the last pass | +| `n_FeO_exchange`, `FeO_kg_exchange` | mol, kg | Silicate FeO taken by the metal, $n_\mathrm{O} - 2 n_\mathrm{Si}$ of the metal (FeO = Fe + O takes 1 FeO per O; SiO$_2$ + 2 Fe = Si + 2 FeO gives back 2 per Si); negative when the metal returns FeO | +| `n_FeO_silicate` | mol | FeO of the melt, from `M_mantle * Phi_global` and the FeO of `silicate_oxides` | +| `FeO_exchange_out_of_range` | | 1 when the exchange is more than 10 % of the melt FeO, in either sign; the melt FeO and `dIW_eq` of the solve do not follow the exchange | + +With `metal_mass` equal to 0 the result carries only `_kg_core` = 0 for H, C, N and S; the other keys above come with `metal_mass` above zero. `_kg_total` stays gas plus melt and excludes the core; the bulk inventory of an element is `_kg_total + _kg_core`. A law outside its range, the `'partition'` mode and an exchange above 10 % of the melt FeO each log a warning once per process. + +## File schema + +The file has `schema_version = 1`, a `[meta]` table with a non-empty `name` and a boolean `placeholder`, and one table per element of H, C, N, S, O and Si. A law is + +$$ +\log_{10} D = a + \frac{b}{T} + c\,\frac{P}{T} + d \cdot \mathrm{dIW\_factor} \cdot \Delta\mathrm{IW} + e\,(\mathrm{NBO/T}) + \sum_j \varepsilon_j \log_{10}(1 - X_j) +$$ + +with $T$ in K, $P$ in GPa, NBO/T the ratio of non-bridging O to tetrahedral cations of the melt, and $X_j$ the mole fraction of element $j$ over the metal without H and C, $x'_j = N_j / \sum_{k \ne \mathrm{H,C}} N_k$ (Tagawa et al. 2021, Methods Eq. 4, p. 5). Fischer et al. (2020, p. 8744) fit $X_\mathrm{O}$ and $X_\mathrm{S}$ as mole fractions in metal without H and do not name the elements of the denominator; $x'$ is the choice of CALLIOPE, the metal basis of the H law. The O and Si exchange uses the same $x'$ in the metal and the single-cation basis in the melt, as the fO2 path of the Tagawa et al. (2021) multi-stage model (p. 6). + +| Field | Values | Meaning | +|---|---|---| +| `form` | `log10_linear` for H, C, N, S; `exchange_KD` for O, Si | The law above, or the exchange $K_D$ of O and Si | +| `quantity` | `D` or `K_D` for `log10_linear`; `K_D` for `exchange_KD` | What the source fits; a `log10_linear` law always gives $D$ in `basis` (the dIW term of a $K_D$ fit carries the conversion) | +| `basis` | `mass`, `mole`, `mole_xprime_noHC`; `mole` for `exchange_KD` | Metal basis of $D$; `mole_xprime_noHC` is the mole fraction over the metal without H and C | +| `silicate_basis` | `single_cation` | Melt basis of a mole-basis law (not read for `mass`) | +| `a`, `b`, `c`, `d`, `e`, `dIW_factor` | finite numbers | Coefficients of the law; an `exchange_KD` law uses a, b and c only, with d and e 0 and `dIW_factor` 1 | +| `eps` | table of element to number | The $\varepsilon_j$ terms; empty for `exchange_KD` | +| `covariance` | empty, or a square matrix | Covariance of a, b, c, d, e and the eps terms in name order; symmetric and positive semidefinite; stored, not used by the solve | +| `P_range_GPa`, `T_range_K`, `dIW_range` | `[low, high]` | Range of the source data, P and T at 0 or above; an empty T or dIW range means unknown, and every use is flagged out of range | +| `P_unit` | `GPa` | Pressure unit | +| `source`, `doi` | non-empty strings | Origin of the law | + +`load_partition_table` raises `PartitionTableError` (a `ValueError`) that names the element and field of a value that breaks the schema or that the evaluator of the element does not read, and the keys of an element table outside the fields above. + +## Placeholder laws + +The packaged file `src/calliope/data/partition_coefficients_placeholder.toml` has `placeholder = true`; a solve with a table marked placeholder logs one warning per process. + +| Element | Law | Range of the source data | Note | +|---|---|---|---| +| H | [Tagawa et al. (2021)](https://doi.org/10.1038/s41467-021-22035-0) Eq. 2, $K_D$ on the metal without H and C | 30-60 GPa, 3080-4560 K, dIW -1.33 to -0.88 | | +| C | [Fischer et al. (2020)](https://doi.org/10.1073/pnas.1919930117) Eq. 1, mass basis, with O and S interaction terms | 1-59 GPa, 1618-5200 K, dIW -3.9 to -0.53 | | +| N | Line in dIW through $D_N$ = 1 at dIW -4 and 39 at dIW -1, the 20 GPa, 3000 K column of [Suer et al. (2023)](https://doi.org/10.3389/feart.2023.1159412) Table 1 | 20 GPa, 3000 K, dIW -4 to -1 | The 5 GPa, 2000 K column gives 5 and 265 at the same dIW; Table 1 has no N value at 60 GPa | +| S | Line in dIW through $D_S$ = 10 at dIW -4 and 173 at dIW -1, the 60 GPa, 4000 K column of Suer et al. (2023) Table 1 | 60 GPa, 4000 K, dIW -4 to -1 | The oxidized column spans dIW -1 to about +1.3; the line uses -1 | +| O | Exchange $K_D$ of Tagawa et al. (2021) SI Table 3 for the data of Siebert et al. (2012, 2013) | 35-74 GPa; T and dIW unknown | Always flagged out of range; SI Table 3 states no basis, and the Siebert et al. papers are not read for it | +| Si | As O | 35-74 GPa; T and dIW unknown | Always flagged out of range | + +Suer et al. (2023) give an uncertainty of up to 20 % on their Table 1 values. In `'partition'` mode O and Si use ideal activities in metal and melt. Badro et al. (2015) fit their Si law together with activity coefficients from interaction parameters; leaving those terms out changes $D_\mathrm{Si}$ by about an order of magnitude at the end of their accretion paths, so the Si mode gives a first estimate, not a calibrated value. diff --git a/docs/Tutorials/metal_reservoir.md b/docs/Tutorials/metal_reservoir.md new file mode 100644 index 00000000..ab473495 --- /dev/null +++ b/docs/Tutorials/metal_reservoir.md @@ -0,0 +1,294 @@ +# Metal reservoir: partition volatiles into the core + +This tutorial adds a metal reservoir to a CALLIOPE solve. The metal stands for the liquid iron that forms a planet's core. Some of the H, C, N and S of the melt goes into this metal, so less of it is left for the atmosphere. + +By the end of it you will: + +- have run the same planet without metal and with metal, and compared the results; +- know the 2 ways to set the oxygen fugacity of the metal-melt equilibrium (`dIW_eq_mode`); +- know the 2 ways to set the O and Si of the metal (`O_mode_core`, `Si_mode_core`), and how to read the FeO exchange; +- know what the out-of-range flags and the placeholder warning mean, and what to do about them; +- have made a plot of the metal share of each element against $\Delta\mathrm{IW}$; +- have run the authoritative-O solve with metal. + +You should already have done the [First run](firstrun.md) tutorial. The physics is on the [Metal reservoir](../Explanations/metal.md) explanation page, and every input and output key is on the [partition file reference](../Reference/partition_file.md). Run the steps in order in one Python session: each step uses the variables of the steps before it, and the printed numbers below come from that order, with the random start guesses of the solver set once in step 2 (`np.random.seed(0)`). The whole tutorial runs in less than a minute. + +## Step 1: the input + +Start from the input of the First run tutorial and add the metal keys: + +```python +import logging + +import matplotlib.pyplot as plt +import numpy as np + +from calliope.constants import M_core_earth, volatile_species +from calliope.solve import ( + equilibrium_atmosphere, + equilibrium_atmosphere_authoritative_O, + get_target_from_params, +) + +logging.basicConfig(level=logging.WARNING, format='%(levelname)s %(message)s') + +ddict = { + # planet and melt, as in the First run tutorial + 'M_mantle': 4.03e24, # kg + 'gravity': 9.81, # m s-2 + 'radius': 6.371e6, # m + 'T_magma': 2500.0, # K + 'Phi_global': 1.0, # fully molten + 'fO2_shift_IW': 0.5, # surface gas and melt, relative to the IW buffer + 'hydrogen_earth_oceans': 1.0, + 'CH_ratio': 0.1, + 'nitrogen_ppmw': 2.0, + 'sulfur_ppmw': 200.0, +} +for sp in volatile_species: + ddict[f'{sp}_included'] = 1 + ddict[f'{sp}_initial_bar'] = 0.0 + +metal = { + 'metal_mass': M_core_earth, # kg, the whole Earth core (1.94e24 kg) + 'P_eq': 50.0, # GPa + 'T_eq': 3500.0, # K + 'dIW_eq_mode': 'parameter', + 'dIW_eq': -1.0, # log10 units relative to the IW buffer +} +target = get_target_from_params(ddict) +``` + +The metal keys: + +- `metal_mass` (kg) is the metal that is in equilibrium with the present melt. Here it is the whole Earth core, in equilibrium with the whole mantle at one pressure and temperature (the single-stage case; the [explanation page](../Explanations/metal.md#worked-example) says why). Any value above 0 turns the metal reservoir on. +- `P_eq` (GPa) and `T_eq` (K) are the pressure and temperature of the metal-melt equilibrium. They are not the surface values: the metal equilibrates deep in the magma ocean. +- `dIW_eq` (log10 units) is the oxygen fugacity of that equilibrium relative to the iron-wüstite (IW) buffer. It is a separate input from `fO2_shift_IW`, which sets the gas and the melt at the surface. + +The logging line makes the warnings of CALLIOPE visible. The solves of steps 2 to 4 log warnings; step 5 explains each of them. + +## Step 2: solve without metal and with metal + +```python +np.random.seed(0) +no_metal = equilibrium_atmosphere(target, {**ddict, 'metal_mass': 0.0}, print_result=False) +with_metal = equilibrium_atmosphere(target, {**ddict, **metal}, print_result=False) + +print(f"P_surf: {no_metal['P_surf']:.3g} bar without metal, {with_metal['P_surf']:.3g} bar with metal") +print('el D_metal w_core kg_core kg_total bulk metal share') +for el in ('H', 'C', 'N', 'S'): + core, total = with_metal[f'{el}_kg_core'], with_metal[f'{el}_kg_total'] + print( + f"{el:2s} {with_metal[f'{el}_D_metal']:<7.4g} {with_metal[f'w_{el}_core']:.3e} " + f'{core:.3e} {total:.3e} {core + total:.3e} {core / (core + total):.4f}' + ) +print('budget: ' + ', '.join(f'{el} {target[el]:.3e}' for el in ('H', 'C', 'N', 'S'))) +``` + +The output: + +```text +P_surf: 8.7 bar without metal, 7.67 bar with metal +el D_metal w_core kg_core kg_total bulk metal share +H 22.14 7.304e-05 1.417e+20 1.330e+19 1.550e+20 0.9142 +C 0.08284 4.384e-08 8.505e+16 1.541e+19 1.550e+19 0.0055 +N 39 1.733e-06 3.362e+18 4.698e+18 8.060e+18 0.4172 +S 173 4.105e-04 7.964e+20 9.565e+18 8.060e+20 0.9881 +budget: H 1.550e+20, C 1.550e+19, N 8.060e+18, S 8.060e+20 +``` + +How to read the columns: + +- `_D_metal` is the partition coefficient $D$ on the mass basis: the mass fraction of the element in the metal divided by that in the melt. +- `w__core` is the mass fraction of the element in the metal, and `_kg_core` its mass there, in kg. +- `_kg_total` is the gas plus the melt. It does not include the metal. +- The bulk inventory of an element is `_kg_total + _kg_core`. It equals the budget in `target` (the last line): no mass is lost or made. +- The metal share is the bulk inventory in the metal, `_kg_core` over the bulk. The keys say `core` for the metal reservoir. + +Most of the H and S goes into the metal, and almost no C. The atmosphere of this planet is mostly CO and CO$_2$, so the surface pressure falls only from 8.7 to 7.67 bar. + +A solve with `metal_mass` = 0 is the solve without metal. Its result holds `_kg_core` = 0 for H, C, N and S and none of the other metal keys: + +```python +for el in ('H', 'C', 'N', 'S'): + print(f"{el}: core {no_metal[f'{el}_kg_core']}, D_metal in result: {f'{el}_D_metal' in no_metal}") +``` + +```text +H: core 0.0, D_metal in result: False +C: core 0.0, D_metal in result: False +N: core 0.0, D_metal in result: False +S: core 0.0, D_metal in result: False +``` + +## Step 3: the 2 modes of $\Delta\mathrm{IW}$ + +`dIW_eq_mode` selects where the $\Delta\mathrm{IW}$ of the partition laws comes from: + +- `'parameter'`: from `dIW_eq`, as in step 2. +- `'from_FeO'`: from the FeO of the melt and the Fe of the metal, $\Delta\mathrm{IW} = 2\log_{10}(x_\mathrm{FeO}/x'_\mathrm{Fe})$. Here $x_\mathrm{FeO}$ is the mole fraction of FeO in the melt on the single-cation basis, and $x'_\mathrm{Fe}$ the mole fraction of Fe in the metal without H and C. The melt composition is `silicate_oxides`, KLB-1 by default. + +```python +from_feo = equilibrium_atmosphere( + target, {**ddict, **metal, 'dIW_eq_mode': 'from_FeO'}, print_result=False +) +for name, res in (('parameter', with_metal), ('from_FeO', from_feo)): + d_values = ', '.join(f"{el} {res[f'{el}_D_metal']:.3g}" for el in ('H', 'C', 'N', 'S')) + flags = ''.join(el for el in ('H', 'C', 'N', 'S') if res[f'{el}_D_out_of_range']) + print(f"{name:9s} dIW_eq {res['dIW_eq']:+.3f} D: {d_values} flagged: {flags}") +``` + +```text +parameter dIW_eq -1.000 D: H 22.1, C 0.0828, N 39, S 173 flagged: NS +from_FeO dIW_eq -2.484 D: H 52, C 0.187, N 6.37, S 42.2 flagged: HNS +``` + +The result gives the $\Delta\mathrm{IW}$ it used as `dIW_eq`. With the KLB-1 melt (8.10 wt% FeO) and metal without O and Si, `'from_FeO'` gives $-2.48$. This is outside the range of the H law ($-1.33$ to $-0.88$), so H is flagged as well. + +## Step 4: O and Si in the metal + +O and Si enter the metal by exchange with the silicate: $\mathrm{FeO} = \mathrm{Fe} + \mathrm{O}$ and $\mathrm{SiO_2} + 2\,\mathrm{Fe} = \mathrm{Si} + 2\,\mathrm{FeO}$. `O_mode_core` and `Si_mode_core` select how CALLIOPE sets them: + +- `'fixed'` (default): the metal holds `w_O_core` and `w_Si_core` (mass fractions, default 0). +- `'partition'`: the exchange coefficients of the partition file and the $\Delta\mathrm{IW}$ of each pass set them. + +```python +fixed = equilibrium_atmosphere( + target, {**ddict, **metal, 'w_O_core': 0.02, 'w_Si_core': 0.05}, print_result=False +) # example values, not a model of the Earth's core +partition = equilibrium_atmosphere( + target, + {**ddict, **metal, 'O_mode_core': 'partition', 'Si_mode_core': 'partition'}, + print_result=False, +) +for name, res in (('fixed', fixed), ('partition', partition)): + ratio = res['n_FeO_exchange'] / res['n_FeO_silicate'] + print( + f"{name:9s} w_O {res['w_O_core']:.4f} w_Si {res['w_Si_core']:.4f} " + f"n_FeO_exchange {res['n_FeO_exchange']:+.3e} mol ({ratio:+.2f} of the melt FeO) " + f"FeO_exchange_out_of_range {res['FeO_exchange_out_of_range']} " + f"D_C {res['C_D_metal']:.3g}" + ) +print('O and Si flags in partition mode:', partition['O_D_out_of_range'], partition['Si_D_out_of_range']) +``` + +```text +fixed w_O 0.0200 w_Si 0.0500 n_FeO_exchange -4.482e+24 mol (-0.99 of the melt FeO) FeO_exchange_out_of_range 1 D_C 0.298 +partition w_O 0.1366 w_Si 0.0027 n_FeO_exchange +1.619e+25 mol (+3.56 of the melt FeO) FeO_exchange_out_of_range 1 D_C 430 +O and Si flags in partition mode: 1 1 +``` + +How to read the exchange: + +- `n_FeO_exchange` (mol) is the FeO that the metal took from the melt: 1 FeO per O atom, minus 2 FeO per Si atom (the Si reaction gives FeO back). A negative value means that the metal gave FeO to the melt. +- `n_FeO_silicate` (mol) is the FeO of the melt. +- `FeO_exchange_out_of_range` is 1 when the exchange is more than 10 % of the melt FeO. CALLIOPE does not change the melt FeO, so such a result is not self-consistent. With the whole core as metal, a few wt% of O or Si already moves a large part of the melt FeO. + +In `'partition'` mode the metal takes 13.7 wt% O. Net of the Si reaction, the exchange needs 3.56 times the FeO that the melt has, which is not possible. The metal O also enters the C law through its composition term, and $D_\mathrm{C}$ rises from 0.0828 to 430. The O and Si of the metal come from the FeO of the rock, so they never enter the volatile O budget (see step 7). + +## Step 5: read the warnings and the flags + +The first solves of this tutorial logged these warnings (each one once per process): + +```text +WARNING the metal partition laws are placeholders +WARNING N partition law evaluated outside its range or on an unknown range: P_eq 50 GPa, T_eq 3500 K, dIW_eq -1 +WARNING S partition law evaluated outside its range or on an unknown range: P_eq 50 GPa, T_eq 3500 K, dIW_eq -1 +WARNING H partition law evaluated outside its range or on an unknown range: P_eq 50 GPa, T_eq 3500 K, dIW_eq -2.48 +WARNING the metal exchange takes more than 10 % of the silicate FeO (0.987); the melt FeO and dIW_eq of this solve do not follow it +WARNING O_mode_core 'partition' uses ideal activities in metal and melt; activity coefficients are not included +WARNING Si_mode_core 'partition' uses ideal activities in metal and melt; activity coefficients are not included +WARNING O partition law evaluated outside its range or on an unknown range: P_eq 50 GPa, T_eq 3500 K, dIW_eq -1 +WARNING Si partition law evaluated outside its range or on an unknown range: P_eq 50 GPa, T_eq 3500 K, dIW_eq -1 +``` + +What each one means, and what to do: + +- **The laws are placeholders.** The packaged partition file has `placeholder = true`. Its laws come from published values, but some are simple lines through 2 table values. For a result you want to publish, put a fitted law from a source you have read into your own file: see [Add a new partition law](partition_law.md). +- **A law outside its range.** `_D_out_of_range` is 1 and the warning names the conditions. The pressure, temperature or $\Delta\mathrm{IW}$ is outside the data that the law was fitted to, or the range is unknown. The placeholder N and S laws are in range only at one pressure and temperature (N: 20 GPa, 3000 K; S: 60 GPa, 4000 K). The placeholder O and Si laws have no known temperature and $\Delta\mathrm{IW}$ range, so they are always flagged. With the packaged file, no `P_eq` and `T_eq` put N and S in range together, so every result with this file has at least one flag. Use a law whose range covers your conditions ([Add a new partition law](partition_law.md)), or move the conditions into the range of the law you have. If you keep a flagged result, say in your text that the law was extrapolated. +- **The FeO exchange.** More than 10 % of the melt FeO moved. Use less metal O and Si, or apply the exchange to the FeO budget of your own model. +- **Ideal activities.** `'partition'` mode leaves out the activity coefficients of the published O and Si fits. Treat its O and Si as a first estimate. + +The solve does not stop or clamp a value for any of these: it returns the result with the flags set. The flags are in the result dictionary, so a script can check them; the warnings appear only once per process. + +## Step 6: a plot of the metal share against $\Delta\mathrm{IW}$ + +Scan `dIW_eq` from $-3$ to 0 and plot the metal share of each element. Each solve starts from the partial pressures of the solve before (`p_guess`), which makes the scan faster: + +```python +d_iw_grid = np.linspace(-3.0, 0.0, 13) +share = {el: [] for el in ('H', 'C', 'N', 'S')} +p_guess = None +for d_iw in d_iw_grid: + res = equilibrium_atmosphere( + target, {**ddict, **metal, 'dIW_eq': float(d_iw)}, p_guess=p_guess, print_result=False + ) + p_guess = {s: res[f'{s}_bar'] for s in ('H2O', 'CO2', 'N2', 'S2')} + for el in share: + core = res[f'{el}_kg_core'] + share[el].append(core / (core + res[f'{el}_kg_total'])) + +fig, ax = plt.subplots(figsize=(6, 4)) +for el, style in zip(share, ('o-', 's-', '^--', 'v--')): + ax.plot(d_iw_grid, share[el], style, label=el) +ax.axvspan(-1.33, -0.88, color='0.85', label='range of the H law') +ax.set_xlabel(r'$\Delta\mathrm{IW}$ of the metal-melt equilibrium (dIW_eq)') +ax.set_ylabel('share of the bulk inventory in the metal') +ax.set_yscale('log') +ax.legend() +fig.tight_layout() +fig.savefig('metal_core_share.png', dpi=150) +for i in (0, 4, 8, 12): + print(f"dIW_eq {d_iw_grid[i]:+.2f}: " + ', '.join(f'{el} {share[el][i]:.3g}' for el in share)) +``` + +```text +dIW_eq -3.00: H 0.971, C 0.0162, N 0.057, S 0.926 +dIW_eq -2.00: H 0.95, C 0.00942, N 0.171, S 0.97 +dIW_eq -1.00: H 0.914, C 0.00549, N 0.417, S 0.988 +dIW_eq +0.00: H 0.857, C 0.0032, N 0.718, S 0.995 +``` + +![Log plot of the metal share of H, C, N and S against dIW_eq from -3 to 0: H and S near 1, N rising from 0.06 to 0.7, C falling from 0.016 to 0.003](../assets/figures/tutorials/metal_core_share.png) + +*Metal share of the bulk H, C, N and S inventory against `dIW_eq`, for the whole Earth core at 50 GPa and 3500 K. The y axis is logarithmic. The grey band is the $\Delta\mathrm{IW}$ range of the placeholder H law; outside it, the H law is out of range. The C law is out of range above $-0.53$. The N and S laws (dashed) are out of range at every point, because each is in range only at one other pressure and temperature. A solid line does not mean that the law is in range.* + +How to read the plot: + +- N goes into the metal more at higher $\Delta\mathrm{IW}$, because its placeholder law rises with $\Delta\mathrm{IW}$. +- C goes into the metal less at higher $\Delta\mathrm{IW}$: the C law has a negative $\Delta\mathrm{IW}$ term. Above $-0.53$ the C law is out of range too, and a warning names it. +- H and S stay above 85 % over the whole scan. + +## Step 7: the authoritative-O solve with metal + +The [authoritative-O solve](../Explanations/authoritative_oxygen.md) takes the O of the gas and the melt as a budget and finds the oxygen fugacity. With metal, the metal O is not part of this budget, because it comes from the FeO of the rock. Give the metal 0.1 wt% O, solve with the buffer, and give the gas and melt O of that solve back as a budget: + +```python +metal_o = {**metal, 'w_O_core': 0.001} # 0.1 wt% O in the metal +buffered = equilibrium_atmosphere(target, {**ddict, **metal_o}, print_result=False) +o_budget = buffered['O_kg_total'] # gas and melt O + +guess = {s: buffered[f'{s}_bar'] for s in ('H2O', 'CO2', 'N2', 'S2')} +guess['fO2_shift_IW'] = ddict['fO2_shift_IW'] +auth = equilibrium_atmosphere_authoritative_O( + {**target, 'O': o_budget}, {**ddict, **metal_o}, p_guess=guess, print_result=False +) +print(f"fO2_shift_derived {auth['fO2_shift_derived']:+.6f} (buffered input {ddict['fO2_shift_IW']:+.1f})") +print(f"O budget {o_budget:.4e} kg, gas + melt O {auth['O_kg_atm'] + auth['O_kg_liquid']:.4e} kg") +print(f"O_kg_core {auth['O_kg_core']:.3e} kg") +``` + +```text +fO2_shift_derived +0.500000 (buffered input +0.5) +O budget 1.3129e+20 kg, gas + melt O 1.3129e+20 kg +O_kg_core 1.940e+21 kg +``` + +This is a round trip: the O of a buffered solve, given back as a budget, must give back the $\Delta\mathrm{IW}$ of that buffered solve. The solve returns +0.5, and the gas and melt O equal the budget. The metal holds about 15 times more O than the gas and the melt, and none of it is counted in the budget. The guess `p_guess` holds the 4 primary partial pressures and `fO2_shift_IW`; without it, the solve starts from random guesses and took 41 to 63 s here in 3 runs with different random starts, instead of a fraction of a second. + +## Where to go next + +- [Metal reservoir](../Explanations/metal.md): the physics, a worked example and the limits. +- [Add a new partition law](partition_law.md): replace a placeholder law with a law from a paper. +- [Partition file reference](../Reference/partition_file.md): every input and output key and the file format. +- [Metal reservoir validation](../Validation/metal.md): how the tests check the metal reservoir. diff --git a/docs/Tutorials/partition_law.md b/docs/Tutorials/partition_law.md new file mode 100644 index 00000000..d60b4b3a --- /dev/null +++ b/docs/Tutorials/partition_law.md @@ -0,0 +1,248 @@ +# Add a new partition law + +The [metal reservoir](../Explanations/metal.md) reads its partition laws from a TOML file. CALLIOPE comes with a file of placeholder laws. This tutorial puts a published law into your own copy of that file, checks it, and uses it in a solve (Part 1). Part 2 explains the covariance, range and basis fields. Part 3 is for developers: it says what to change in the code to add a law in a new form or for a new element. + +By the end of it you will: + +- have mapped each coefficient of a published equation onto the fields of the file; +- have written your own partition file and loaded it with `load_partition_table`; +- know the error that a typical mistake in the file gives; +- have compared $D$ and the metal share of the new law with the placeholder law; +- know what to fill in the covariance, range and basis fields. + +You should already have done the [metal reservoir tutorial](metal_reservoir.md). The file format is on the [partition file reference](../Reference/partition_file.md#file-schema). The whole tutorial runs in a few seconds. + +## Part 1: replace the law of one element + +### Step 1: the published law + +The example law is the H law of [Tsutsumi et al. (2025)](https://doi.org/10.1038/s41467-025-65729-5), *Nature Communications* 16, 10038. They fit their own diamond anvil cell runs together with those of Tagawa et al. (2021), the source of the placeholder H law. Their law adds a term for the C in the metal, which lowers $D_\mathrm{H}$. + +Their Eq. 3 (PDF p. 3) is + +$$ +\log_{10} K_D = \log_{10} D_\mathrm{H}^\mathrm{metal/silicate} + \frac{1}{4}\Delta\mathrm{IW} = a + \frac{b}{T} + c\,\frac{P}{T} + d \cdot \log_{10}\left(1 - x'^\mathrm{metal}_\mathrm{C}\right), +$$ + +with $a = 2.31(20)$, $b = -1542(500)$, $c = -38.9(78)$ and $d = 6.69(30)$ (p. 3; the numbers in brackets are the 1-sigma errors of the last digits). $T$ is in K and $P$ in GPa. $K_D$ is the exchange coefficient of the reaction of H with FeO (see [Metal reservoir](../Explanations/metal.md)). The other definitions: + +- $D_\mathrm{H} = x'^\mathrm{metal}_\mathrm{H} / x^\mathrm{silicate}_\mathrm{HO_{0.5}}$, a ratio of mole fractions (Eq. 1, p. 2). The note under their Table 1 (p. 2) says: "D$_\mathrm{H}$ and D$_\mathrm{C}$ are based on molar concentrations". +- $x'_i = N_i / \sum_{k \ne \mathrm{H,C}} N_k$, the mole fraction over the metal without H and C (Eq. 10, p. 8). + +### Step 2: map the coefficients onto the file + +The file needs $\log_{10} D$ alone on the left. Move the term $\frac{1}{4}\Delta\mathrm{IW}$ to the right: + +$$ +\log_{10} D_\mathrm{H} = 2.31 - \frac{1542}{T} - 38.9\,\frac{P}{T} - 0.25\,\Delta\mathrm{IW} + 6.69\,\log_{10}(1 - x'_\mathrm{C}). +$$ + +The letters of the paper are not the field names of the file: the paper's $d$ multiplies $\log_{10}(1 - x'_\mathrm{C})$ and goes into the file field `eps`, while the file field `d` multiplies $\Delta\mathrm{IW}$. Compare the law term by term with the law of the file, $\log_{10} D = a + b/T + c\,P/T + d \cdot \mathrm{dIW\_factor} \cdot \Delta\mathrm{IW} + e\,(\mathrm{NBO/T}) + \sum_j \varepsilon_j \log_{10}(1 - X_j)$: + +| Paper (Eq. 3) | File field | Value | Why | +|---|---|---|---| +| paper $a$ | `a` | 2.31 | same term | +| paper $b$ | `b` | -1542.0 | same term, $T$ in K | +| paper $c$ | `c` | -38.9 | same term, $P$ in GPa | +| $\frac{1}{4}\Delta\mathrm{IW}$ on the left | `d`, `dIW_factor` | -0.25, 1.0 | moved to the right, so the sign changes | +| no NBO/T term (NBO/T: how polymerised the melt is) | `e` | 0.0 | | +| paper $d \cdot \log_{10}(1 - x'_\mathrm{C})$ | `eps` | `{ C = 6.69 }` | CALLIOPE takes $X_j$ on the same $x'$ basis (Eq. 10) | +| fits $K_D$ | `quantity` | `"K_D"` | what the source fits; the law still gives $D$, because the $\Delta\mathrm{IW}$ term holds the conversion | +| $D$ over $x'$ in the metal | `basis` | `"mole_xprime_noHC"` | Eq. 1 and Eq. 10 | +| H in the melt as HO$_{0.5}$ | `silicate_basis` | `"single_cation"` | see below | + + +`silicate_basis`: Tsutsumi et al. count H in the melt as HO$_{0.5}$ units. CALLIOPE counts the melt on the single-cation basis, one unit per cation, and H as HO$_{0.5}$. This is the basis with which CALLIOPE reproduces the single-stage core H and C of Tsutsumi et al. (2025, SI Table 1) on the [validation page](../Validation/metal.md#tsutsumi-et-al-2025-single-stage-cores). + +The ranges come from the runs of the fit: + +- Tsutsumi et al. (2025) Table 1 (p. 2), the 5 runs with a $D_\mathrm{H}$ (runs 1 to 5): 42-56 GPa, 3630-4760 K, $\Delta\mathrm{IW}$ $-1.78$ to $-1.31$. +- The runs of Tagawa et al. (2021), the other data of the fit: 30-60 GPa, 3080-4560 K, $\Delta\mathrm{IW}$ $-1.33$ to $-0.88$ (the ranges of the placeholder H law). +- Together: 30-60 GPa, 3080-4760 K, $\Delta\mathrm{IW}$ $-1.78$ to $-0.88$. + +Tsutsumi et al. give the 1-sigma error of each coefficient, but not the covariance between them. The covariance cannot be built from the errors alone, so `covariance` stays empty (see [Part 2](#part-2-covariance-ranges-and-basis)). + +### Step 3: write your own file + +Copy the packaged file and replace its `[H]` table. The other laws stay the placeholders, so `placeholder` stays `true`: + +```python +import logging +from importlib import resources + +import numpy as np + +from calliope.constants import M_core_earth, volatile_species +from calliope.metal import PartitionTableError, load_partition_table +from calliope.solve import equilibrium_atmosphere, get_target_from_params + +logging.basicConfig(level=logging.WARNING, format='%(levelname)s %(message)s') + +packaged = resources.files('calliope.data').joinpath('partition_coefficients_placeholder.toml') +text = packaged.read_text() + +h_law = '''[H] +form = "log10_linear" +quantity = "K_D" +basis = "mole_xprime_noHC" +silicate_basis = "single_cation" +a = 2.31 +b = -1542.0 +c = -38.9 +d = -0.25 +e = 0.0 +dIW_factor = 1.0 +eps = { C = 6.69 } +covariance = [] +P_range_GPa = [30.0, 60.0] +T_range_K = [3080.0, 4760.0] +dIW_range = [-1.78, -0.88] +P_unit = "GPa" +source = "Tsutsumi et al. 2025, Nat. Commun. 16, 10038, Eq. 3 (p. 3); ranges of runs 1-5 of their Table 1 and of the Tagawa et al. 2021 runs" +doi = "10.1038/s41467-025-65729-5" + +''' +start, end = text.index('[H]\n'), text.index('[C]\n') +mine = text[:start] + h_law + text[end:] +packaged_name = load_partition_table()['meta']['name'] +mine = mine.replace(f'name = "{packaged_name}"', 'name = "tsutsumi2025-H"') +with open('my_partition_laws.toml', 'w') as f: + f.write(mine) +``` + +The cut from `[H]` to `[C]` works because the packaged file has the H table first and the C table second. If you edit the file by hand, the result is the same. + +### Step 4: load and check the file + +`load_partition_table` reads the file and checks every field: + +```python +table = load_partition_table('my_partition_laws.toml') +print(table['meta']) +h = table['H'] +print({k: h[k] for k in ('a', 'b', 'c', 'd', 'eps', 'basis', 'P_range_GPa', 'T_range_K', 'dIW_range')}) +``` + +```text +{'name': 'tsutsumi2025-H', 'placeholder': True} +{'a': 2.31, 'b': -1542.0, 'c': -38.9, 'd': -0.25, 'eps': {'C': 6.69}, 'basis': 'mole_xprime_noHC', 'P_range_GPa': (30.0, 60.0), 'T_range_K': (3080.0, 4760.0), 'dIW_range': (-1.78, -0.88)} +``` + +### Step 5: 2 typical mistakes + +A field that breaks the file format raises `PartitionTableError`, a `ValueError` that names the element and the field. 2 mistakes that are easy to make: + +- an `eps` key that is not an element name, for example `xC` for the $x'_\mathrm{C}$ of the paper; +- a basis name that the file does not know, for example `"molar"`. + +```python +for wrong, right in (('eps = { xC = 6.69 }', 'eps = { C = 6.69 }'), ('basis = "molar"', 'basis = "mole_xprime_noHC"')): + with open('broken.toml', 'w') as f: + f.write(mine.replace(right, wrong, 1)) + try: + load_partition_table('broken.toml') + except PartitionTableError as err: + print(f'{wrong}: {err}') +``` + +```text +eps = { xC = 6.69 }: 'H': 'eps' keys must be elements of ('H', 'C', 'N', 'S', 'O', 'Si') +basis = "molar": 'H': 'basis' must be one of ('mass', 'mole', 'mole_xprime_noHC'), got 'molar' +``` + +A mistake in a number is not a format error: a wrong sign of `d` loads without error and gives a wrong $D$. Check each coefficient against the paper. Where the paper gives $D$ at stated conditions, evaluate the law there with `calliope.metal.log10_D` and compare. The call takes the pressure (GPa), the temperature (K), $\Delta\mathrm{IW}$ and the $x'$ mole fractions of the `eps` elements in the metal: + +```python +from calliope.metal import log10_D + +value = log10_D(table['H'], 50.0, 3500.0, -1.0, x_metal={'C': 0.0}) +print(f'log10 D_H = {value:.4f}, D_H = {10**value:.2f} (mole basis on x\', metal without C)') +``` + +```text +log10 D_H = 1.5637, D_H = 36.62 (mole basis on x', metal without C) +``` + +This $D_\mathrm{H}$ is on the basis of the law (here `mole_xprime_noHC`). The solve converts it to the mass basis with `calliope.metal.mass_D`, so `H_D_metal` in step 6 differs from it. + +### Step 6: use the law in a solve + +Pass the table as `ddict['partition_table']`, and solve the case of the metal reservoir tutorial with the placeholder law and with the new law: + +```python +ddict = { + 'M_mantle': 4.03e24, 'gravity': 9.81, 'radius': 6.371e6, + 'T_magma': 2500.0, 'Phi_global': 1.0, 'fO2_shift_IW': 0.5, + 'hydrogen_earth_oceans': 1.0, 'CH_ratio': 0.1, 'nitrogen_ppmw': 2.0, 'sulfur_ppmw': 200.0, +} +for sp in volatile_species: + ddict[f'{sp}_included'] = 1 + ddict[f'{sp}_initial_bar'] = 0.0 +metal = {'metal_mass': M_core_earth, 'P_eq': 50.0, 'T_eq': 3500.0, 'dIW_eq': -1.0} +target = get_target_from_params(ddict) + +np.random.seed(0) +for name, tab in (('placeholder', load_partition_table()), ('Tsutsumi 2025', table)): + res = equilibrium_atmosphere( + target, {**ddict, **metal, 'partition_table': tab}, print_result=False + ) + share = res['H_kg_core'] / (res['H_kg_core'] + res['H_kg_total']) + print( + f"{name:13s} D_H {res['H_D_metal']:.4g} H metal share {share:.4f} " + f"H in gas {res['H_kg_atm']:.3e} kg flag {res['H_D_out_of_range']}" + ) +``` + +```text +placeholder D_H 22.14 H metal share 0.9142 H in gas 1.582e+15 kg flag 0 +Tsutsumi 2025 D_H 33.3 H metal share 0.9413 H in gas 7.413e+14 kg flag 0 +``` + +The new law gives $D_\mathrm{H}$ = 33.3, 1.5 times the placeholder value, and halves the H that is left in the gas. At these conditions the metal holds almost no C (the C law gives $D_\mathrm{C}$ = 0.08), so the C term of the new law is close to 0, and the difference comes from the new $a$, $b$ and $c$. In a metal with more C, the C term lowers $D_\mathrm{H}$. Both laws are in range here (flag 0), but the file is still marked `placeholder`, so the placeholder warning stays: the N, S, O and Si laws of the file have not changed. + +## Part 2: covariance, ranges and basis + +**Covariance.** `covariance` is the covariance matrix of the coefficients, in the order a, b, c, d, e and then the `eps` terms by element name, so its size is 5 plus the number of `eps` terms. It must be symmetric and positive semidefinite. Fill it only when the source gives it, as Fischer et al. (2020) do for the placeholder C law (their SI Appendix Table S3). Otherwise leave it as `[]`. CALLIOPE stores the covariance with the law and does not use it in the solve. + +**Ranges.** `P_range_GPa`, `T_range_K` and `dIW_range` are the `[low, high]` ranges of the data that the law was fitted to, not the range where you want to use the law. Take them from the table of runs of the source. For a fit to several data sets, take the range over all of them. When the source does not give a temperature or $\Delta\mathrm{IW}$ range, write `[]`: the law is then flagged out of range at every use, and the result says so with `_D_out_of_range` = 1. The pressure range cannot be empty. + +**Basis.** `basis` must match the definition of $D$ in the source: + +- `"mass"`: $D$ is a ratio of mass fractions (wt% or ppm by mass). +- `"mole"`: $D$ is a ratio of mole fractions, with the mole fraction over the whole metal. +- `"mole_xprime_noHC"`: a ratio of mole fractions, with the mole fraction over the metal without H and C. + +For the 2 mole bases, `silicate_basis` must be `"single_cation"`. If the source counts the melt on another basis (for example per O atom), convert its law before you put it in the file. + +**What CALLIOPE checks and what it does not.** `load_partition_table` checks the format: the field names, the types, the choices, `low <= high`, the covariance shape. It cannot check that a number is the number of the paper, or that the basis is the basis of the paper. Write the source, the equation and the page into the `source` field, so that the next reader can check them. + +## Part 3: a law in a new form, or for a new element + +This part is for developers. A law in the `log10_linear` form for one of H, C, N and S needs no code change: Part 1 is all. A law in a new functional form, or a law for an element that CALLIOPE does not partition yet, needs code. The checklist: + +1. **`src/calliope/metal.py`**: + - `FORMS`: add the name of the new form. + - `KEYS` and `_entry`: add and check each new field of the form. + - `_check_read_values`: state which forms and fields the evaluator of each element reads, so that a file with a value the code ignores is refused. + - The evaluator: a function like `log10_D` for the new form, and the conversion to the mass basis in `mass_D` (or a new function like `exchange_metal` for an exchange reaction). + - `BASES`, `QUANTITIES` and `SILICATE_BASES`: add a new basis, fitted quantity or melt basis; `_entry` refuses a value outside these tuples. + - `COEFFICIENTS`: add a new numeric coefficient; `KEYS`, the field names that `_entry` accepts, includes it. + - `ELEMENTS` (and `EXCHANGE_ELEMENTS` for an exchange with FeO): add a new element. +2. **`src/calliope/solve.py`**, for a new volatile element: + - `func`, `func_authoritative_O` and `_with_metal`: a budget, a residual and a metal term for the element. + - The element lists of `_with_metal`: the loops over H, C, N and S, and over O and Si. + - The loops of `equilibrium_atmosphere` and `equilibrium_atmosphere_authoritative_O` that write `_kg_core`: `_with_metal` reads those keys from each inner solve. +3. **`src/calliope/solve.py`**, for a law in a new form for an element that CALLIOPE already partitions: `_with_metal` must call the new evaluator. It calls `mass_D` for H, C, N and S and `exchange_metal` for O and Si by name, and `mass_D` refuses a form other than `log10_linear`. +4. **The packaged file** `src/calliope/data/partition_coefficients_placeholder.toml`: a law for each new element, so the default table stays complete. +5. **The docs**: the field table and the placeholder table of [Reference/partition_file.md](../Reference/partition_file.md), and the [explanation page](../Explanations/metal.md) if the physics changes. +6. **The tests** (`tests/test_metal.py` for `metal.py`; `tests/test_metal_solve.py` and `tests/test_metal_fields.py` for `solve.py`; rules in `tests/AGENTS.md`): + - a `reference_pinned` test (a test that runs the real code and compares it with a published value): evaluate the law at the conditions of a value that the source gives (a run of its table, or a value in its text) and assert that value within the error of the source; + - a format test for each new field: a wrong type, NaN, a missing value and a value out of its range raise `PartitionTableError`; + - a mutation check, which shows that the test can fail: change one coefficient sign or the basis in the code, run the test, confirm that it fails, and undo the change; + - a case on the [validation page](../Validation/metal.md) and a row in `docs/Validation/index.md`, which the test `tests/test_validation_index.py` compares with the list of validation tests. + +## Where to go next + +- [Partition file reference](../Reference/partition_file.md): every field of the file and every input and output key. +- [Metal reservoir](../Explanations/metal.md): the physics and the limits. +- [Metal reservoir validation](../Validation/metal.md): the published values that the placeholder laws are checked against. diff --git a/docs/Validation/index.md b/docs/Validation/index.md new file mode 100644 index 00000000..81a36539 --- /dev/null +++ b/docs/Validation/index.md @@ -0,0 +1,59 @@ +# Validation + +## What this tab checks + +Validation means that we run the code on a case where another source gives the right answer, and check that the code gives that answer within a stated tolerance. +The other source is a published equation, table or measurement, an analytical result, or a second, independent code. +This tab has one page per process of CALLIOPE, in the menu on the left: chemistry, oxygen fugacity, solubility, the equilibrium solve, the metal reservoir and the mantle mass. +The table below lists every case once, so you can find a case and open its page. + +**How to read the table.** Each row is one case. "Process" links to the page of the case, "Case" says what is checked, and "Test id" names the test that runs it. "Reference" is the source of the right answer, and "Status" says which test tier runs the case and whether it has a plot. + +**How the case pages are laid out.** On the metal reservoir page each case says why the check exists, what it tests, how we know the right answer and why it matters, with the test details in a box. The other pages list their cases in a table, with notes that derive the expected values. + +Each case is a test that has the `reference_pinned` marker, so the CI fails when the code leaves its tolerance. Each page names the test ids and the references of its process. + +This command runs every case: + +```console +pytest -m reference_pinned tests +``` + +## Cases by process + +| Process | Case | Test id | Reference | Status | +|---|---|---|---|---| +| [Chemistry](chemistry.md) | H2O = H2 + 0.5 O2 at 2000 K | `tests/test_chemistry.py::test_modified_keq_janaf_H2_matches_closed_form_at_2000K_with_oneill` | NIST-JANAF tables, closed-form constant | unit tier | +| [Chemistry](chemistry.md) | H2S and NH3 constants over 700-5000 K | `tests/test_chemistry.py::test_janaf_constant_matches_janaf_rows_over_fit_range` | NIST-JANAF rows | unit tier | +| [Chemistry](chemistry.md) | H2S and NH3 fit coefficients | `tests/test_chemistry.py::test_janaf_coefficients_are_least_squares_fit_of_janaf_rows` | least-squares fit of the NIST-JANAF rows | unit tier | +| [Chemistry](chemistry.md) | Equilibrium constants and reaction quotients | `tests/test_cross_code_consistency.py::test_calliope_equilibrium_constants_and_reaction_quotients_vs_nasa_glenn` | NASA Glenn thermodynamic data (cross-code check) | integration tier, nightly cross-code job; needs atmodeller | +| [Oxygen fugacity](oxygen_fugacity.md#hirschmann-2021) | Hirschmann IW buffer at 1473 K and 1 bar | `tests/test_oxygen_fugacity.py::test_oxygen_fugacity_hirschmann_value_at_1473K_matches_published_figure` | Hirschmann (2021), Table 1 and Fig. 5 | unit tier | +| [Oxygen fugacity](oxygen_fugacity.md) | Low-pressure slope of the Hirschmann buffer | `tests/test_oxygen_fugacity.py::test_oxygen_fugacity_hirschmann_low_pressure_slope_matches_volume_change` | volume change of Fe + 1/2 O2 = FeO (analytical) | unit tier | +| [Oxygen fugacity](oxygen_fugacity.md) | Fischer IW buffer at 2000 K | `tests/test_oxygen_fugacity.py::test_oxygen_fugacity_fischer_value_at_2000K_cross_check_and_regression` | Fischer et al. (2011); O'Neill and Eggins (2002) | unit tier | +| [Solubility](solubility.md) | H2O in peridotite | `tests/test_solubility.py::TestSolubilityH2O::test_peridotite_default_matches_sossi_2023_fit` | Sossi et al. (2023) | unit tier | +| [Solubility](solubility.md) | S2 with the Earth-mantle FeO | `tests/test_solubility.py::TestSolubilityS2_xFeO::test_default_call_matches_gaillard_2022_earth_mantle_value` | Gaillard et al. (2022) | unit tier | +| [Solubility](solubility.md) | Noble gas Henry constants | `tests/test_solubility.py::TestSolubilityNobleGas::test_every_gas_constant_pinned_to_independent_literal`, `::test_helium_constant_matches_jambon_1986` | Jambon et al. (1986) | unit tier | +| [Solubility](solubility.md) | Noble gas constants in atmodeller | `tests/test_solubility.py::TestSolubilityNobleGas::test_noble_constants_cross_check_against_atmodeller` | atmodeller (cross-code check) | unit tier; skips without atmodeller | +| [Solve](solve.md) | Forward and authoritative-O round trip | `tests/test_solve.py::test_round_trip_self_consistency_at_earth_fiducial` | self-consistency of the 2 entry points | smoke tier | +| [Solve](solve.md) | Noble gas partition | `tests/test_noble_gases.py::test_partition_ratio_matches_henry_versus_hydrostatic_column` | Henry constant against the hydrostatic column (analytical) | smoke tier | +| [Solve](solve.md) | Gas-only grid of 72 cases | `tests/test_cross_code_consistency.py::test_calliope_vs_atmodeller_72_case_gas_only_grid` | atmodeller (cross-code check) | integration tier, nightly cross-code job; needs atmodeller | +| [Metal reservoir](metal.md#gas-free-core-fraction) | Gas-free core fraction | `tests/test_metal.py::test_gas_free_core_fraction_curve`, `tests/test_metal.py::TestMetalReservoir::test_gas_free_core_fraction_is_dx_over_one_plus_dx` | mass balance of melt and metal (analytical) | unit tier; plot pair | +| [Metal reservoir](metal.md#equal-d-is-a-larger-melt) | Equal D is a larger melt | `tests/test_metal_solve.py::test_metal_with_equal_d_is_a_larger_melt`, `::test_equal_d_identity_plot` | identity of the element balance (analytical) | smoke tier; plot pair | +| [Metal reservoir](metal.md#o-budget-with-metal-o) | O budget with metal O | `tests/test_metal_solve.py::test_authoritative_o_budget_excludes_the_metal_o` | round trip of the buffered and the authoritative-O solve | smoke tier | +| [Metal reservoir](metal.md#equal-d-is-a-larger-melt-with-an-o-budget) | Equal D with an O budget | `tests/test_metal_solve.py::test_authoritative_o_with_equal_d_is_a_larger_melt` | identity of the element balance (analytical) | smoke tier | +| [Metal reservoir](metal.md#tagawa-et-al-2021-h-runs) | H runs of Tagawa et al. (2021) | `tests/test_metal.py::test_tagawa_runs_lie_within_one_sigma_of_eq2` | Tagawa et al. (2021), SI Table 1 | unit tier; plot pair | +| [Metal reservoir](metal.md#tsutsumi-et-al-2025-single-stage-cores) | Single-stage cores of Tsutsumi et al. (2025) | `tests/test_metal.py::TestBasis::test_tsutsumi_single_stage_core`, `tests/test_metal.py::test_tsutsumi_core_values_plot` | Tsutsumi et al. (2025), SI Table 1 | unit tier; plot pair | +| [Metal reservoir](metal.md#published-law-values) | Published law values | `tests/test_metal.py::TestLaw::test_tagawa_eq2_values`, `::test_fischer_eq1_single_stage_value`, `::test_suer_lines`, `tests/test_metal.py::TestExchange::test_ideal_exchange_mole_fractions` | Tagawa et al. (2021), Fischer et al. (2020), Suer et al. (2023) | unit tier | +| [Structure](structure.md) | Earth core mass fraction | `tests/test_structure.py::test_calculate_mantle_mass_recovers_wang_2018_earth_core_fraction` | Wang et al. (2018) | unit tier | + +The CI job of the pull requests runs the unit and smoke tiers. The nightly workflow runs all tiers, and its cross-code job installs atmodeller and runs `tests/test_cross_code_consistency.py`. That file also holds `test_required_env_guards`, which has the marker of the file but checks only the `CHEM_CROSS_CODE_REQUIRED` setting. + +## Plots written by the tests + +A case with a plot pair shows the tested values against their reference in a light and a dark version. The test writes the pair with the same numbers it asserts, through the `validation_plot` fixture of proteus-mpl. This command writes all pairs to `docs/assets/validation/`: + +```console +pytest -m reference_pinned tests --validation-plots +``` + +Without `--validation-plots` the tests write nothing: each compares the data of its plot with the committed pair, and a missing or stale file fails the test. diff --git a/docs/Validation/metal.md b/docs/Validation/metal.md new file mode 100644 index 00000000..3abd91ea --- /dev/null +++ b/docs/Validation/metal.md @@ -0,0 +1,160 @@ +# Validation: metal reservoir + +## What this page checks + +This page checks how CALLIOPE splits the volatile elements H, C, N and S between gas, silicate melt and metal. +The metal reservoir stands for the iron that forms a planet's core. +Validation here means that we run the code on a case where another source gives the right answer. +The test then checks that the code gives that answer within a stated tolerance. +The right answer comes from mass balance alone, from a second CALLIOPE solve that must agree, or from laboratory measurements and tables in published papers. +If this part of the code is wrong, the metal takes the wrong amount of each element. +The atmosphere of the model then gets the wrong amount too. +Each case below says why the check exists, what it tests, how we know the right answer and why it matters. +The box "Test details" at the end of each case gives the reference, the tolerance, the test id and the command. + +**How the metal enters the calculation.** +A metal reservoir is on when `metal_mass` is above zero. +The partition coefficient $D_i$ of element $i$ is the ratio of its mass fraction in the metal to its mass fraction in the melt. +The element balance of H, C, N and S then gains a core term $D_i M_\mathrm{metal} w_i^\mathrm{melt}$. +Here $M_\mathrm{metal}$ is the metal mass and $w_i^\mathrm{melt}$ the mass fraction of the element in the melt. +The values of $D_i$ come from the partition file (`src/calliope/metal.py`); the packaged laws are placeholders. +The metal O comes from the FeO of the silicate and stays out of the volatile O budget. +`_kg_total` stays gas plus melt and excludes the core; the bulk inventory of an element is `_kg_total + _kg_core`. +Inputs, outputs and the placeholder laws are on the [metal reservoir page](../Reference/partition_file.md). + +## Gas-free core fraction + +**Why this check exists.** When metal takes an element from the melt, the melt has less of it left. A code that forgets this loss puts too much of the element into the core. For a large $D$ it can put more than the whole budget there. + +**What it tests.** A melt and a metal with no gas. The metal to melt mass ratio is $x = M_\mathrm{metal}/M_\mathrm{melt}$, here 0.325/0.675 as for the Earth's core and mantle. The core must then hold the fraction $f = Dx/(1 + Dx)$ of the element. + +**How we know the right answer.** This result follows from mass balance alone. The element is either in the melt or in the metal, and $D$ sets their ratio. + +**Why it matters.** The core fraction sets how much H, C, N and S stays in the silicate and can reach the atmosphere. + +![Core fraction of an element against D](../assets/validation/metal_gas_free_core_fraction.svg#only-light){ width="80%" } +![Core fraction of an element against D](../assets/validation/metal_gas_free_core_fraction_dark.svg#only-dark){ width="80%" } + +**How to read the plot.** The x-axis is $D$ on a log scale and the y-axis is the fraction of the element in the core. The solid line is $Dx/(1 + Dx)$, the dashed line is $Dx$ without the loss from the melt, and the points are the values of the code. A pass means that the points lie on the solid line. + +??? info "Test details" + - Reference: the mass balance of melt and metal (analytical). + - Tolerance: relative 1e-12; $f = Dx$, the law without the melt back-reaction, lies more than 10 % above it from $D = 0.3$ on. + - Test id: `tests/test_metal.py::test_gas_free_core_fraction_curve`, `tests/test_metal.py::TestMetalReservoir` (unit tier) + - Command: `pytest tests/test_metal.py -k "gas_free"` + +## Equal D is a larger melt + +**Why this check exists.** The metal term enters the full solve of gas, melt and metal. A wrong factor in that term, or an element left out of it, changes the partial pressures of the gas. + +**What it tests.** H, C, N and S all get the same $D$, the metal holds no O or Si, and $\Delta\mathrm{IW}$ is a fixed parameter. $\Delta\mathrm{IW}$ is the oxygen fugacity of the metal-melt equilibrium in log10 units relative to the iron-wüstite (IW) buffer. In this case the metal acts exactly like extra melt of mass $D M_\mathrm{metal}$. So the solve with metal must give the same result as the solve without metal and a melt that is larger by $D M_\mathrm{metal}$. The test uses $D$ = 1 and 30. The gas holds the rest of each budget, so the core mass is $(M - M_\mathrm{gas})\,Dx/(1 + Dx)$. Here $M$ is the budget of the element and $M_\mathrm{gas}$ its mass in the gas. + +**How we know the right answer.** The two solves have the same element balance, term by term, so they must give the same answer. + +**Why it matters.** This case checks the metal term inside the full solve, with the gas, the solubility laws and the chemistry all active. + +![Partial pressures with metal against a melt larger by D M_metal, buffered at D = 1 and 30 and with an O budget at D = 30](../assets/validation/metal_equal_d_larger_melt.svg#only-light){ width="80%" } +![Partial pressures with metal against a melt larger by D M_metal, buffered at D = 1 and 30 and with an O budget at D = 30](../assets/validation/metal_equal_d_larger_melt_dark.svg#only-dark){ width="80%" } + +**How to read the plot.** Each point is the partial pressure of one gas species. The x-axis is the solve with the larger melt and the y-axis is the solve with metal, both on log scales. The 3 marker types are $D$ = 1, $D$ = 30, and $D$ = 30 with an O budget. The last one is the case [Equal D is a larger melt with an O budget](#equal-d-is-a-larger-melt-with-an-o-budget). A pass means that every point lies on the dashed 1:1 line. + +??? info "Test details" + - Reference: the identity of the element balance (analytical). + - Tolerance: partial pressures and melt plus core masses relative 1e-6, with both solves from the same random state and the root accepted at relative 1e-10 plus 1 kg; core over gas-free core equals $1 - M_\mathrm{gas}/M$ to 2e-5. + - Test id: `tests/test_metal_solve.py::test_metal_with_equal_d_is_a_larger_melt`, `tests/test_metal_solve.py::test_equal_d_identity_plot` (smoke tier) + - Command: `pytest tests/test_metal_solve.py -k "larger_melt or identity_plot"` + +## O budget with metal O + +**Why this check exists.** The metal O comes from the FeO of the rock, not from the volatile O of the gas and the melt. A code that counts the metal O as volatile O gets the oxygen fugacity of the planet wrong. + +**What it tests.** CALLIOPE can solve in 2 ways. The buffered solve takes the oxygen fugacity as an input, written as an offset from the IW buffer. The authoritative-O solve takes the total O of the gas and the melt as an input and finds the oxygen fugacity. First, a buffered solve at IW+2 with 1.9e24 kg of metal gives the O of its gas and melt. Its metal has $D$ = 30 for H, C, N and S and a metal-melt $\Delta\mathrm{IW}$ of -2. Then an authoritative-O solve gets that O as its budget and 0.1 wt% O in the metal. It must return IW+2, and the O of its gas and melt must equal the budget. + +**How we know the right answer.** The second solve must recover the input of the first one (a round trip). The metal O (1.9e21 kg) is 22 times the gas and melt O (8.76e19 kg). If the code counted it in the budget, the result would be far from IW+2. + +**Why it matters.** PROTEUS can use the authoritative-O solve to follow the oxygen of a planet over time (`planet.fO2_source = "from_O_budget"`). The metal must not change that bookkeeping. + +??? info "Test details" + - Reference: round trip between the buffered and the authoritative-O solve (self-consistency). + - Tolerance: derived $\Delta\mathrm{IW}$ +2 to 1e-6; partial pressures relative 1e-6; gas and melt O equal the budget, and gas, melt and core of each of H, C, N and S equal theirs, to relative 1e-10 plus 10 kg. The metal O (1.9e21 kg) is 22 times the gas and melt O (8.76e19 kg), so charging it to the budget fails the test; partition-mode O (3.6 wt% in the metal, more than 100 times the budget) solves as well. + - Test id: `tests/test_metal_solve.py::test_authoritative_o_budget_excludes_the_metal_o`, `tests/test_metal_solve.py::test_partition_o_far_above_the_o_budget_solves` (smoke tier) + - Command: `pytest tests/test_metal_solve.py -k "excludes_the_metal_o or partition_o_far"` + +## Equal D is a larger melt with an O budget + +**Why this check exists.** The authoritative-O solve has its own path through the code. If that path leaves out the metal term or gives it a wrong factor, its result differs from the solve with the larger melt. + +**What it tests.** The same comparison as in [Equal D is a larger melt](#equal-d-is-a-larger-melt), now with the authoritative-O solve, $D$ = 30 and no O in the metal. The larger melt also holds the O of its dissolved species. So its O budget is larger by $D M_\mathrm{metal}$ times the O mass fraction of the melt in the solve with metal. + +**How we know the right answer.** As before, the two solves have the same element balance. So they must give the same partial pressures and the same oxygen fugacity. + +**Why it matters.** With this case, both solve modes that PROTEUS can use are checked with a metal reservoir. + +??? info "Test details" + - Reference: the identity of the element balance (analytical). + - Tolerance: partial pressures and melt plus core masses relative 1e-6, derived $\Delta\mathrm{IW}$ to 1e-6, roots accepted at relative 1e-10. + - Test id: `tests/test_metal_solve.py::test_authoritative_o_with_equal_d_is_a_larger_melt` (smoke tier) + - Command: `pytest tests/test_metal_solve.py -k authoritative_o_with_equal_d` + +## Tagawa et al. (2021) H runs + +**Why this check exists.** The placeholder law for H is a fit to laboratory data, and the paper gives more than one fit. Using the wrong one, for example their Eq. 10 instead of Eq. 2, gives a wrong $D$ for H with no other sign. + +**What it tests.** The H law is Eq. 2 of [Tagawa et al. (2021)](https://doi.org/10.1038/s41467-021-22035-0). It gives $K_D$, the exchange coefficient of H between metal and silicate, from pressure and temperature. The test compares it with the 6 runs of their SI Table 1. Each run is an experiment in a laser-heated diamond anvil cell, with a measured $\log_{10} K_D$ and its 1 sigma error. + +**How we know the right answer.** Tagawa et al. (2021) measured these 6 values in the laboratory. + +**Why it matters.** The H law sets how much hydrogen the metal takes. Hydrogen in the metal is H that the atmosphere and the water inventory do not get. + +![Measured against fitted log10 K_D of the Tagawa runs](../assets/validation/metal_tagawa2021_h_runs.svg#only-light){ width="80%" } +![Measured against fitted log10 K_D of the Tagawa runs](../assets/validation/metal_tagawa2021_h_runs_dark.svg#only-dark){ width="80%" } + +**How to read the plot.** The x-axis is $\log_{10} K_D$ from Eq. 2 and the y-axis is the measured value, with its 1 sigma error bar. A pass means that every error bar crosses the dashed 1:1 line. + +??? info "Test details" + - Reference: Tagawa et al. (2021), SI Table 1 (published table). + - Tolerance: each run inside its 1 sigma (largest 0.61 sigma); their Eq. 10 misses every run by more than 2 sigma. + - Test id: `tests/test_metal.py::test_tagawa_runs_lie_within_one_sigma_of_eq2` (unit tier) + - Command: `pytest tests/test_metal.py -k tagawa` + +## Tsutsumi et al. (2025) single-stage cores + +**Why this check exists.** Papers give $D$ in different forms, for example per mole and over a metal without H and C. CALLIOPE must convert each form to its own mass basis. A wrong conversion changes the H and C content of the core. + +**What it tests.** [Tsutsumi et al. (2025)](https://doi.org/10.1038/s41467-025-65729-5) give $D$ per mole over the metal without H and C. CALLIOPE converts it with the KLB-1 melt composition of Takahashi (1986). The test uses their 3 single-stage cases: 40 GPa and 3750 K, 50 GPa and 3500 K, 54 GPa and 3350 K. In each case the bulk silicate Earth holds 710 ppm H$_2$O and 120 ppm C. The code must give the core H and C of their SI Table 1. + +**How we know the right answer.** Tsutsumi et al. (2025) list the core H and C of each case in their SI Table 1. + +**Why it matters.** This case checks the conversion of $D$ with a full set of published inputs and outputs. The same conversion applies to each law in the partition file that gives $D$ per mole, such as the H law. + +![Core H and C of the Tsutsumi single-stage cases](../assets/validation/metal_tsutsumi2025_core_h_c.svg#only-light){ width="80%" } +![Core H and C of the Tsutsumi single-stage cases](../assets/validation/metal_tsutsumi2025_core_h_c_dark.svg#only-dark){ width="80%" } + +**How to read the plot.** The x-axis has the 3 cases and the y-axis is the mass fraction of H and C in the core in wt%. The open markers are the table values and the filled markers are the values of the code. A pass means that each filled marker lies inside its open marker. + +??? info "Test details" + - Reference: Tsutsumi et al. (2025), SI Table 1 (published table). + - Tolerance: core H and C equal to the table at its rounding, 0.005 wt%; D times the melt mass fraction without the basis conversion misses the 40 GPa case. + - Test id: `tests/test_metal.py::TestBasis`, `tests/test_metal.py::test_tsutsumi_core_values_plot` (unit tier) + - Command: `pytest tests/test_metal.py -k "tsutsumi or Basis"` + +## Published law values + +**Why this check exists.** Each placeholder law is a formula with coefficients from a paper. A wrong sign or a wrong coefficient gives a law that looks reasonable but is not the published one. + +**What it tests.** Each law is evaluated at points where its source prints the result: + +- Tagawa et al. (2021) Eq. 2 at 3 pressure and temperature points. +- Fischer et al. (2020) Eq. 1, $D_\mathrm{C}$ = 0.550 for their single-stage case (54 GPa, 4100 K, NBO/T 2.6, IW-2.2, $X_\mathrm{O}$ 0.06, $X_\mathrm{S}$ 0.03; p. 8745). NBO/T is the number of non-bridging oxygens per tetrahedral cation, a measure of how polymerised the melt is. $X_\mathrm{O}$ and $X_\mathrm{S}$ are the mole fractions of O and S in the metal. +- The lines through the values of Suer et al. (2023) Table 1: $D_\mathrm{N}$ 1 and 39 and $D_\mathrm{S}$ 10 and 173 at dIW -4 and -1. +- The ideal exchange of O and Si between metal and melt with the $K_D$ of Tagawa et al. (2021) SI Table 3. + +**How we know the right answer.** The papers print these values. + +**Why it matters.** These laws set $D$ for every element in the metal reservoir, so every other case on this page depends on them. + +??? info "Test details" + - Reference: the published equations and tables (published values). + - Tolerance: Eq. 2 at its 4 printed decimals; $D_\mathrm{C}$ 5e-4; the Suer lines relative 1e-5; the O exchange relative 1e-12 and the Si exchange relative 1e-3 (the KLB-1 single-cation units by hand). + - Test id: `tests/test_metal.py::TestLaw::test_tagawa_eq2_values`, `tests/test_metal.py::TestLaw::test_fischer_eq1_single_stage_value`, `tests/test_metal.py::TestLaw::test_suer_lines`, `tests/test_metal.py::TestExchange::test_ideal_exchange_mole_fractions` (unit tier) + - Command: `pytest tests/test_metal.py -k "eq2_values or eq1_single or suer_lines or ideal_exchange"` diff --git a/docs/Validation/solve.md b/docs/Validation/solve.md index 59da02ee..4028b54b 100644 --- a/docs/Validation/solve.md +++ b/docs/Validation/solve.md @@ -1,5 +1,16 @@ # Validation: `src/calliope/solve.py` +## What this page checks + +This page checks the equilibrium solve in `src/calliope/solve.py`. +The solve finds the partial pressures of the gas and the dissolved masses for a given inventory of H, C, N, S and O. +Validation here means that we run the code on a case where another source gives the right answer. +The test then checks that the code gives that answer within a stated tolerance. +On this page the right answer comes from a second CALLIOPE solve that must agree with the first, and from a closed-form result for the noble gases. +If the solve is wrong, every outgassing step of a PROTEUS run gets the wrong atmosphere. +The table below lists each case with its reference, and the sections after it explain how the expected values follow. +The cases of the solve with a metal reservoir are on the [metal reservoir page](metal.md). + This page tracks the `@pytest.mark.reference_pinned` tests that anchor the behaviour of `calliope.solve` against published or self-consistency sources. @@ -65,6 +76,10 @@ This is a documented exception to the strict 1:1 source-to-test mirroring rule for sources >500 LOC where the test topics are independent enough that consolidation would hurt readability. +## Metal reservoir + +The cases of the solve with a metal reservoir (`metal_mass` above zero) are on the [metal reservoir validation page](metal.md). + ## Anchor types - Self-consistency cross-implementation (round-trip forward vs inverse). diff --git a/docs/Validation/structure.md b/docs/Validation/structure.md index b7e7f2ba..45844cf3 100644 --- a/docs/Validation/structure.md +++ b/docs/Validation/structure.md @@ -1,5 +1,15 @@ # Validation: `src/calliope/structure.py` +## What this page checks + +This page checks `calculate_mantle_mass` in `src/calliope/structure.py`. +It splits the mass of a planet into a core and a mantle, from the planet radius, the planet mass and the core radius as a fraction of the planet radius. +Validation here means that we run the code on a case where another source gives the right answer. +The test then checks that the code gives that answer within a stated tolerance. +Here the right answer is the core mass fraction of the Earth from Wang et al. (2018). +The mantle mass sets how much melt can hold dissolved volatiles in a standalone CALLIOPE run. +The table below lists the case, and the sections after it derive the expected value. + This page tracks the `@pytest.mark.reference_pinned` tests that anchor the behaviour of `calliope.structure` against a published source. @@ -25,6 +35,10 @@ land at ~3e18 kg, six orders of magnitude below the correct value. The test's scale guard `1e24 < mantle < 1e25` brackets the correct order and fails on any factor-of-10 unit slip. +Only standalone runs use `calculate_mantle_mass`. PROTEUS does not call it: it passes the +mantle mass of its own interior model, as the +[code architecture](../Explanations/code_architecture.md#structurepy) page explains. + ## Anchor type Published benchmark + analytical limit. The Wang+2018 cite is the diff --git a/docs/assets/figures/tutorials/metal_core_share.png b/docs/assets/figures/tutorials/metal_core_share.png new file mode 100644 index 00000000..b20357fb Binary files /dev/null and b/docs/assets/figures/tutorials/metal_core_share.png differ diff --git a/docs/assets/validation/metal_equal_d_larger_melt.svg b/docs/assets/validation/metal_equal_d_larger_melt.svg new file mode 100644 index 00000000..e6b5ded4 --- /dev/null +++ b/docs/assets/validation/metal_equal_d_larger_melt.svg @@ -0,0 +1,1190 @@ + + + + + + + + test: tests/test_metal_solve.py::test_equal_d_identity_plot; command: pytest tests/test_metal_solve.py::test_equal_d_identity_plot --validation-plots; summary: {"v": 1, "axes": [{"scales": ["log", "log"], "categories": [null, null], "lines": [[7, -10.712304603780836, 0.6587026841535166, -2.0859475467995505, -1.559172697024898, -10.712304603780833, 0.658702684153519, -2.0859475467995505, -1.5591726970248971], [7, -15.32964533205033, 0.09063839566088834, -3.804064692110574, -2.016474735070287, -15.329645332050319, 0.09063839566089812, -3.804064692110569, -2.0164747350702856], [7, -15.329645332050532, 0.09063839566090877, -3.8040646921105976, -2.0164747350703243, -15.32964533205041, 0.09063839566090345, -3.8040646921105807, -2.016474735070302], [2, -16.0, 1.0, -7.5, 8.5, -16.0, 1.0, -7.5, 8.5]], "collections": [], "values": [], "value_scales": [], "patches": [], "images": [], "image_scales": [], "extents": []}]} + image/svg+xml + + + proteus-mpl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/validation/metal_equal_d_larger_melt_dark.svg b/docs/assets/validation/metal_equal_d_larger_melt_dark.svg new file mode 100644 index 00000000..d3f47592 --- /dev/null +++ b/docs/assets/validation/metal_equal_d_larger_melt_dark.svg @@ -0,0 +1,1190 @@ + + + + + + + + test: tests/test_metal_solve.py::test_equal_d_identity_plot; command: pytest tests/test_metal_solve.py::test_equal_d_identity_plot --validation-plots; summary: {"v": 1, "axes": [{"scales": ["log", "log"], "categories": [null, null], "lines": [[7, -10.712304603780836, 0.6587026841535166, -2.0859475467995505, -1.559172697024898, -10.712304603780833, 0.658702684153519, -2.0859475467995505, -1.5591726970248971], [7, -15.32964533205033, 0.09063839566088834, -3.804064692110574, -2.016474735070287, -15.329645332050319, 0.09063839566089812, -3.804064692110569, -2.0164747350702856], [7, -15.329645332050532, 0.09063839566090877, -3.8040646921105976, -2.0164747350703243, -15.32964533205041, 0.09063839566090345, -3.8040646921105807, -2.016474735070302], [2, -16.0, 1.0, -7.5, 8.5, -16.0, 1.0, -7.5, 8.5]], "collections": [], "values": [], "value_scales": [], "patches": [], "images": [], "image_scales": [], "extents": []}]} + image/svg+xml + + + proteus-mpl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/validation/metal_gas_free_core_fraction.svg b/docs/assets/validation/metal_gas_free_core_fraction.svg new file mode 100644 index 00000000..9f38f532 --- /dev/null +++ b/docs/assets/validation/metal_gas_free_core_fraction.svg @@ -0,0 +1,1302 @@ + + + + + + + + test: tests/test_metal.py::test_gas_free_core_fraction_curve; command: pytest tests/test_metal.py::test_gas_free_core_fraction_curve --validation-plots; summary: {"v": 1, "axes": [{"scales": ["log", "linear"], "categories": [null, null], "lines": [[71, -1.25, 2.25, 0.5, 0.5999999999999998, 0.02636192600813604, 0.9884554367802413, 0.5496855321000492, 0.2065271911872722], [71, -1.25, 2.25, 0.5, 0.5999999999999998, 0.02707569343509088, 1.0, 0.6705920281587118, 0.21169933390507276], [7, -1.0, 2.0, 0.49019482345128385, 0.6666666666666666, 0.045936395759717315, 0.9796533534287868, 0.5472842440855374, 0.2343929887319284]], "collections": [], "values": [], "value_scales": [], "patches": [], "images": [], "image_scales": [], "extents": []}]} + image/svg+xml + + + proteus-mpl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/validation/metal_gas_free_core_fraction_dark.svg b/docs/assets/validation/metal_gas_free_core_fraction_dark.svg new file mode 100644 index 00000000..6fb59ea7 --- /dev/null +++ b/docs/assets/validation/metal_gas_free_core_fraction_dark.svg @@ -0,0 +1,1302 @@ + + + + + + + + test: tests/test_metal.py::test_gas_free_core_fraction_curve; command: pytest tests/test_metal.py::test_gas_free_core_fraction_curve --validation-plots; summary: {"v": 1, "axes": [{"scales": ["log", "linear"], "categories": [null, null], "lines": [[71, -1.25, 2.25, 0.5, 0.5999999999999998, 0.02636192600813604, 0.9884554367802413, 0.5496855321000492, 0.2065271911872722], [71, -1.25, 2.25, 0.5, 0.5999999999999998, 0.02707569343509088, 1.0, 0.6705920281587118, 0.21169933390507276], [7, -1.0, 2.0, 0.49019482345128385, 0.6666666666666666, 0.045936395759717315, 0.9796533534287868, 0.5472842440855374, 0.2343929887319284]], "collections": [], "values": [], "value_scales": [], "patches": [], "images": [], "image_scales": [], "extents": []}]} + image/svg+xml + + + proteus-mpl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/validation/metal_tagawa2021_h_runs.svg b/docs/assets/validation/metal_tagawa2021_h_runs.svg new file mode 100644 index 00000000..389305ec --- /dev/null +++ b/docs/assets/validation/metal_tagawa2021_h_runs.svg @@ -0,0 +1,1126 @@ + + + + + + + + test: tests/test_metal.py::test_tagawa_runs_lie_within_one_sigma_of_eq2; command: pytest tests/test_metal.py::test_tagawa_runs_lie_within_one_sigma_of_eq2 --validation-plots; summary: {"v": 1, "axes": [{"scales": ["linear", "linear"], "categories": [null, null], "lines": [[6, 1.1365217391304347, 1.380756501182033, 1.259389794602474, 0.007076085455221288, 1.1, 1.37, 1.2566666666666668, 0.00666666666666676], [2, 1.0, 1.5, 1.25, 0.25, 1.0, 1.5, 1.25, 0.25]], "collections": [[12, 1.1365217391304347, 1.380756501182033, 1.259389794602474, 0.006432804959292071, 1.04, 1.4200000000000002, 1.2566666666666666, 0.011212121212121175]], "values": [], "value_scales": [], "patches": [], "images": [], "image_scales": [], "extents": []}]} + image/svg+xml + + + proteus-mpl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/validation/metal_tagawa2021_h_runs_dark.svg b/docs/assets/validation/metal_tagawa2021_h_runs_dark.svg new file mode 100644 index 00000000..c3f1fde2 --- /dev/null +++ b/docs/assets/validation/metal_tagawa2021_h_runs_dark.svg @@ -0,0 +1,1126 @@ + + + + + + + + test: tests/test_metal.py::test_tagawa_runs_lie_within_one_sigma_of_eq2; command: pytest tests/test_metal.py::test_tagawa_runs_lie_within_one_sigma_of_eq2 --validation-plots; summary: {"v": 1, "axes": [{"scales": ["linear", "linear"], "categories": [null, null], "lines": [[6, 1.1365217391304347, 1.380756501182033, 1.259389794602474, 0.007076085455221288, 1.1, 1.37, 1.2566666666666668, 0.00666666666666676], [2, 1.0, 1.5, 1.25, 0.25, 1.0, 1.5, 1.25, 0.25]], "collections": [[12, 1.1365217391304347, 1.380756501182033, 1.259389794602474, 0.006432804959292071, 1.04, 1.4200000000000002, 1.2566666666666666, 0.011212121212121175]], "values": [], "value_scales": [], "patches": [], "images": [], "image_scales": [], "extents": []}]} + image/svg+xml + + + proteus-mpl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/validation/metal_tsutsumi2025_core_h_c.svg b/docs/assets/validation/metal_tsutsumi2025_core_h_c.svg new file mode 100644 index 00000000..53e81f61 --- /dev/null +++ b/docs/assets/validation/metal_tsutsumi2025_core_h_c.svg @@ -0,0 +1,1337 @@ + + + + + + + + test: tests/test_metal.py::test_tsutsumi_core_values_plot; command: pytest tests/test_metal.py::test_tsutsumi_core_values_plot --validation-plots; summary: {"v": 1, "axes": [{"scales": ["linear", "linear"], "categories": [["40 GPa", "50 GPa", "54 GPa"], null], "lines": [[3, 0.0, 2.0, 1.0, 0.6666666666666666, 0.35, 0.68, 0.49333333333333335, -0.11000000000000001], [3, 0.0, 2.0, 1.0, 0.6666666666666666, 0.35196856589210024, 0.6843126464347964, 0.4937957344132509, -0.11078136018089872], [3, 0.0, 2.0, 1.0, 0.6666666666666666, 0.75, 1.29, 1.0633333333333332, 0.18], [3, 0.0, 2.0, 1.0, 0.6666666666666666, 0.7526878877570481, 1.292731602559821, 1.0659546742166013, 0.18001457160092432]], "collections": [], "values": [], "value_scales": [], "patches": [], "images": [], "image_scales": [], "extents": []}]} + image/svg+xml + + + proteus-mpl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/assets/validation/metal_tsutsumi2025_core_h_c_dark.svg b/docs/assets/validation/metal_tsutsumi2025_core_h_c_dark.svg new file mode 100644 index 00000000..35d9b3e5 --- /dev/null +++ b/docs/assets/validation/metal_tsutsumi2025_core_h_c_dark.svg @@ -0,0 +1,1337 @@ + + + + + + + + test: tests/test_metal.py::test_tsutsumi_core_values_plot; command: pytest tests/test_metal.py::test_tsutsumi_core_values_plot --validation-plots; summary: {"v": 1, "axes": [{"scales": ["linear", "linear"], "categories": [["40 GPa", "50 GPa", "54 GPa"], null], "lines": [[3, 0.0, 2.0, 1.0, 0.6666666666666666, 0.35, 0.68, 0.49333333333333335, -0.11000000000000001], [3, 0.0, 2.0, 1.0, 0.6666666666666666, 0.35196856589210024, 0.6843126464347964, 0.4937957344132509, -0.11078136018089872], [3, 0.0, 2.0, 1.0, 0.6666666666666666, 0.75, 1.29, 1.0633333333333332, 0.18], [3, 0.0, 2.0, 1.0, 0.6666666666666666, 0.7526878877570481, 1.292731602559821, 1.0659546742166013, 0.18001457160092432]], "collections": [], "values": [], "value_scales": [], "patches": [], "images": [], "image_scales": [], "extents": []}]} + image/svg+xml + + + proteus-mpl + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/index.md b/docs/index.md index ca3e3238..2086ae4a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,7 @@ Named after the [Greek muse of eloquence and epic poetry](https://en.wikipedia.o - **Configurable redox state**: Hirschmann (2021) [^cite-hirschmann2021] iron-wüstite (IW) buffer with arbitrary $\Delta\mathrm{IW}$ shift (default; accounts for non-stoichiometric wüstite and matches atmodeller's buffer above 1000 K), or the Fischer et al. (2011) [^cite-fischer2011] and legacy O'Neill & Eggins (2002) [^cite-oneilleggins2002] IW - **Calibrated equilibrium constants**: JANAF [^cite-chase1998] and Schaefer & Fegley (2017) [^cite-schaeferfegley2017] fits for the H$_2$O-H$_2$, CO$_2$-CO, CO$_2$+H$_2$-CH$_4$, S$_2$-SO$_2$, S$_2$+H$_2$-H$_2$S, and N$_2$+H$_2$-NH$_3$ couples - **Multiple solubility laws per species**: peridotite (default H$_2$O, Sossi et al. 2023 [^cite-sossi2023]), basalt (Dixon et al. 1995 [^cite-dixon1995], Wilson & Head 1981 [^cite-wilsonhead1981], Hamilton et al. 1964 [^cite-hamilton1964]), anorthite-diopside (Newcombe et al. 2017 [^cite-newcombe2017]), lunar glass (Newcombe et al. 2017 [^cite-newcombe2017]); CO$_2$ (Dixon et al. 1995 [^cite-dixon1995]); CO (Armstrong et al. 2015 [^cite-armstrong2015]); CH$_4$ (Ardia et al. 2013 [^cite-ardia2013]); N$_2$ (Libourel et al. 2003 [^cite-libourel2003] or Dasgupta et al. 2022 [^cite-dasgupta2022]); S$_2$ (Gaillard et al. 2022 [^cite-gaillard2022]) +- **Optional metal reservoir**: liquid metal in equilibrium with the melt takes part of the H, C, N and S, with partition laws read from a file ([Metal reservoir](Explanations/metal.md)) - **Robust hybrid solver**: alternating `scipy.optimize.fsolve` (Powell hybrid) and `trust-constr` minimisation, with Monte-Carlo restart on failure - **PROTEUS-coupled or standalone**: the same equilibrium kernel powers both the in-loop call from PROTEUS and one-off scripts diff --git a/mkdocs.yml b/mkdocs.yml index f5b7be93..84c69a27 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,6 +24,8 @@ nav: - Two-mode round-trip: Tutorials/two_modes.md - Speciation phase diagram: Tutorials/phase_diagram.md - Coupled-loop driver: Tutorials/coupled_loop.md + - Metal reservoir: Tutorials/metal_reservoir.md + - Add a new partition law: Tutorials/partition_law.md - Reproducing the Earth fiducial: Tutorials/earth_fiducial.md - Mars-like atmosphere: Tutorials/mars_fiducial.md @@ -34,27 +36,33 @@ nav: - Noble gases: Explanations/noble_gases.md - Oxygen fugacity: Explanations/oxygen_fugacity.md - Mass balance & solver: Explanations/mass_balance.md + - Metal reservoir: Explanations/metal.md - Authoritative-oxygen mode: Explanations/authoritative_oxygen.md - Coupling to PROTEUS: Explanations/proteus_coupling.md - Backend comparison: Explanations/cross_backend_comparison.md - Code architecture: Explanations/code_architecture.md - Testing suite: Explanations/testing.md + - Validation: + - Overview: Validation/index.md + - Chemistry: Validation/chemistry.md + - Oxygen fugacity: Validation/oxygen_fugacity.md + - Solubility: Validation/solubility.md + - Solve: Validation/solve.md + - Metal reservoir: Validation/metal.md + - Structure: Validation/structure.md + - Reference: - API reference: - Overview: Reference/api/index.md - Solve: Reference/api/calliope.solve.md + - Metal: Reference/api/calliope.metal.md - Chemistry: Reference/api/calliope.chemistry.md - Solubility: Reference/api/calliope.solubility.md - Oxygen fugacity: Reference/api/calliope.oxygen_fugacity.md - Structure: Reference/api/calliope.structure.md - Constants: Reference/api/calliope.constants.md - - Validation anchors: - - Chemistry: Validation/chemistry.md - - Oxygen fugacity: Validation/oxygen_fugacity.md - - Solubility: Validation/solubility.md - - Solve: Validation/solve.md - - Structure: Validation/structure.md + - Metal reservoir and partition file: Reference/partition_file.md - Publications: Reference/publications.md - Community: diff --git a/pyproject.toml b/pyproject.toml index e917b521..7347c4e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ develop = [ "pytest-cov", "pytest-timeout", "pre-commit", + "proteus-mpl >= 1.5.0", "pyyaml", "ruff", ] @@ -71,6 +72,9 @@ docs = [ package-dir = {"calliope" = "src/calliope" } include-package-data = true +[tool.setuptools.package-data] +calliope = ["data/*.toml"] + [tool.setuptools_scm] # CalVer: tags are bare YY.MM.DD dates (no leading 'v', e.g. 26.05.10). # setuptools-scm derives the version from the latest git tag. @@ -89,12 +93,8 @@ branch = true source = ["calliope"] [tool.coverage.report] -# Full coverage gate: unit + smoke + integration + slow tests, enforced by -# the nightly workflow. 90% is the PROTEUS-ecosystem ceiling: the gate does -# not rise above this value even if real coverage exceeds it. Above 90% -# the gate tracks pragma usage and style rather than bug-finding signal. -# Auto-ratcheted by tools/update_coverage_threshold.py; never manually -# decrease. See .github/.claude/rules/calliope-tests.md section 15. +# Full gate (all tiers, nightly), capped at the 90% PROTEUS-ecosystem ceiling. +# Raised by tools/update_coverage_threshold.py, never lowered; see tests/AGENTS.md. fail_under = 90.0 show_missing = true exclude_lines = [ @@ -104,11 +104,8 @@ exclude_lines = [ ] [tool.calliope.coverage_fast] -# Fast PR gate: unit + smoke coverage only, enforced on every PR via -# .github/workflows/tests.yaml. Capped at the 90% PROTEUS-ecosystem -# ceiling by tools/update_coverage_threshold.py. Measured unit+smoke -# coverage exceeds 90, so the gate sits at the ceiling. Never manually -# decrease. See .github/.claude/rules/calliope-tests.md section 15. +# Fast gate (unit and smoke, every pull request in tests.yaml), capped at 90%. +# Raised by tools/update_coverage_threshold.py, never lowered; see tests/AGENTS.md. fail_under = 90.0 [tool.pytest.ini_options] @@ -119,8 +116,8 @@ markers = [ "smoke: real-binary tests on minimal configurations (target < 30 s each)", "integration: real-binary tests with full coupling", "slow: anything > 1 minute, run in nightly only", - "physics_invariant: marks tests that assert a physical invariant (conservation, positivity, boundedness, monotonicity, symmetry). See .github/.claude/rules/calliope-tests.md section 3.", - "reference_pinned: marks tests that pin behavior against a published benchmark, analytical limit, or cross-implementation cross-check. Cite the source in the test docstring. Each physics source must contain at least one. See .github/.claude/rules/calliope-tests.md section 3.", + "physics_invariant: marks tests that assert a physical invariant (conservation, positivity, boundedness, monotonicity, symmetry). See tests/AGENTS.md.", + "reference_pinned: marks tests that pin behavior against a published benchmark, analytical limit, or cross-implementation cross-check. Cite the source in the test docstring. Each physics source must contain at least one. See tests/AGENTS.md.", ] [tool.ruff] diff --git a/src/calliope/constants.py b/src/calliope/constants.py index 9a86f178..1edd187b 100644 --- a/src/calliope/constants.py +++ b/src/calliope/constants.py @@ -42,6 +42,17 @@ 'NH3': 0.017031, } +# Molar masses of the rock-forming elements [kg mol-1], for the metal and silicate bases +molar_mass_rock = { + 'Fe': 0.055845, + 'Si': 0.028085, + 'Mg': 0.024305, + 'Al': 0.026982, + 'Ca': 0.040078, + 'Na': 0.022990, + 'Ti': 0.047867, +} + # Noble gases. Each is monatomic and chemically inert: its gas species and # its element are the same entity, it takes no part in the CHNOS reaction # network, and it partitions between melt and atmosphere by Henry's law diff --git a/src/calliope/data/__init__.py b/src/calliope/data/__init__.py new file mode 100644 index 00000000..8d327e40 --- /dev/null +++ b/src/calliope/data/__init__.py @@ -0,0 +1 @@ +"""Package data: the placeholder metal-silicate partition coefficient file.""" diff --git a/src/calliope/data/partition_coefficients_placeholder.toml b/src/calliope/data/partition_coefficients_placeholder.toml new file mode 100644 index 00000000..f7001c51 --- /dev/null +++ b/src/calliope/data/partition_coefficients_placeholder.toml @@ -0,0 +1,132 @@ +# Placeholder metal-silicate partition laws for release 1 of the core reservoir. +# log10 D = a + b/T + c P/T + d dIW_factor dIW + e NBO/T + sum_j eps_j log10(1 - X_j) +schema_version = 1 + +[meta] +name = "placeholder-2026-10" +placeholder = true + +[H] +form = "log10_linear" +quantity = "K_D" +basis = "mole_xprime_noHC" +silicate_basis = "single_cation" +a = 2.42 +b = -2892.0 +c = -32.0 +d = -0.25 +e = 0.0 +dIW_factor = 1.0 +eps = {} +covariance = [] +P_range_GPa = [30.0, 60.0] +T_range_K = [3080.0, 4560.0] +dIW_range = [-1.33, -0.88] +P_unit = "GPa" +source = "Tagawa et al. 2021, Nat. Commun., Eq. 2; ranges of their laser-heated diamond anvil cell runs (SI Table 1)" +doi = "10.1038/s41467-021-22035-0" + +[C] +form = "log10_linear" +quantity = "D" +basis = "mass" +a = 1.49 +b = 3000.0 +c = -235.0 +d = -0.238 +e = -0.118 +dIW_factor = 1.0 +eps = { O = -19.5, S = 9.6 } +covariance = [ + [0.49, -749.0, -23.1, 0.0272, -0.0155, -0.855, 0.134], + [-749.0, 1234000.0, 35170.0, -22.2, 20.9, 999.0, -133.0], + [-23.1, 35170.0, 3565.0, 0.506, 0.669, 384.0, -0.149], + [0.0272, -22.2, 0.506, 0.00848, -0.00073, 0.229, 0.0178], + [-0.0155, 20.9, 0.669, -0.00073, 0.0018, 0.172, -0.0053], + [-0.855, 999.0, 384.0, 0.229, 0.172, 79.0, 0.675], + [0.134, -133.0, -0.149, 0.0178, -0.0053, 0.675, 1.98], +] +P_range_GPa = [1.0, 59.0] +T_range_K = [1618.0, 5200.0] +dIW_range = [-3.9, -0.53] +P_unit = "GPa" +source = "Fischer et al. 2020, PNAS, Eq. 1 (nanoSIMS), covariance SI Appendix Table S3; ranges of Dataset S1 and their 5 runs (dIW of the runs from SI Tables S1 and S2, ideal mixing)" +doi = "10.1073/pnas.1919930117" + +[N] +form = "log10_linear" +quantity = "D" +basis = "mass" +a = 2.121419 +b = 0.0 +c = 0.0 +d = 0.530355 +e = 0.0 +dIW_factor = 1.0 +eps = {} +covariance = [] +P_range_GPa = [20.0, 20.0] +T_range_K = [3000.0, 3000.0] +dIW_range = [-4.0, -1.0] +P_unit = "GPa" +source = "Suer et al. 2023, Front. Earth Sci., Table 1, embryo column (20 GPa, 3000 K): line through D_N = 1 at dIW -4 and 39 at dIW -1" +doi = "10.3389/feart.2023.1159412" + +[S] +form = "log10_linear" +quantity = "D" +basis = "mass" +a = 2.650728 +b = 0.0 +c = 0.0 +d = 0.412682 +e = 0.0 +dIW_factor = 1.0 +eps = {} +covariance = [] +P_range_GPa = [60.0, 60.0] +T_range_K = [4000.0, 4000.0] +dIW_range = [-4.0, -1.0] +P_unit = "GPa" +source = "Suer et al. 2023, Front. Earth Sci., Table 1, deep magma ocean column (60 GPa, 4000 K): line through D_S = 10 at dIW -4 and 173 at dIW -1" +doi = "10.3389/feart.2023.1159412" + +[O] +form = "exchange_KD" +quantity = "K_D" +basis = "mole" +silicate_basis = "single_cation" +a = 0.986 +b = -3275.0 +c = 0.0 +d = 0.0 +e = 0.0 +dIW_factor = 1.0 +eps = {} +covariance = [] +P_range_GPa = [35.0, 74.0] +T_range_K = [] +dIW_range = [] +P_unit = "GPa" +source = "Tagawa et al. 2021, Nat. Commun., SI Table 3, K_D(O) for the partitioning data of Siebert et al. 2012 and 2013; P range of the Siebert et al. 2013 diamond anvil cell runs (main text), data range and basis not checked against Siebert et al., which are not read" +doi = "10.1038/s41467-021-22035-0" + +[Si] +form = "exchange_KD" +quantity = "K_D" +basis = "mole" +silicate_basis = "single_cation" +a = 0.549 +b = -12324.0 +c = 0.0 +d = 0.0 +e = 0.0 +dIW_factor = 1.0 +eps = {} +covariance = [] +P_range_GPa = [35.0, 74.0] +T_range_K = [] +dIW_range = [] +P_unit = "GPa" +source = "Tagawa et al. 2021, Nat. Commun., SI Table 3, K_D(Si) for the partitioning data of Siebert et al. 2012 and 2013; P range of the Siebert et al. 2013 diamond anvil cell runs (main text), data range and basis not checked against Siebert et al., which are not read" +doi = "10.1038/s41467-021-22035-0" diff --git a/src/calliope/metal.py b/src/calliope/metal.py new file mode 100644 index 00000000..35a92d25 --- /dev/null +++ b/src/calliope/metal.py @@ -0,0 +1,572 @@ +"""Metal-silicate partitioning of volatiles into core-forming metal. + +The partition laws live in a TOML file with ``schema_version = 1``, a ``[meta]`` table +(``name``, ``placeholder``) and one table per element of :data:`ELEMENTS`. Each entry +gives ``log10 D = a + b/T + c P/T + d dIW_factor dIW + e NBO/T + sum_j eps_j +log10(1 - X_j)`` with T in K, P in GPa and X_j the mole fraction of element j in the +metal, together with the quantity the source fits (``D`` or ``K_D``), the metal +``basis`` of D (``mass``, ``mole`` or ``mole_xprime_noHC``, the mole fraction over the +metal without H and C), the ``silicate_basis`` of a mole basis (``single_cation``), +the ranges of the source data (an empty T or dIW range means unknown), an optional covariance of the coefficients in the +order a, b, c, d, e and the eps terms by element name, and the source and DOI. The +packaged file ``data/partition_coefficients_placeholder.toml`` holds placeholder laws. +""" + +from __future__ import annotations + +import math +import tomllib +from importlib import resources +from pathlib import Path + +import numpy as np + +from .constants import molar_mass, molar_mass_rock + +ELEMENTS = ('H', 'C', 'N', 'S', 'O', 'Si') +EXCHANGE_ELEMENTS = ('O', 'Si') +SCHEMA_VERSION = 1 +FORMS = ('log10_linear', 'exchange_KD') +QUANTITIES = ('D', 'K_D') +BASES = ('mass', 'mole', 'mole_xprime_noHC') +SILICATE_BASES = ('single_cation',) +COEFFICIENTS = ('a', 'b', 'c', 'd', 'e', 'dIW_factor') +RANGES = ('P_range_GPa', 'T_range_K', 'dIW_range') +KEYS = frozenset(('form', 'quantity', 'basis', 'silicate_basis', 'P_unit', 'eps', 'covariance')) +KEYS = KEYS | set(COEFFICIENTS) | set(RANGES) | {'source', 'doi'} +PLACEHOLDER_FILE = 'partition_coefficients_placeholder.toml' + + +class PartitionTableError(ValueError): + """A partition coefficient file that breaks the schema.""" + + +def _number(value, el, field): + """Return a finite float; a boolean, string or non-finite value raises.""" + if ( + isinstance(value, bool) + or not isinstance(value, int | float) + or not math.isfinite(value) + ): + raise PartitionTableError(f'{el!r}: {field!r} must be a finite number, got {value!r}') + return float(value) + + +def _choice(entry, el, field, options): + value = entry.get(field) + if value not in options: + raise PartitionTableError(f'{el!r}: {field!r} must be one of {options}, got {value!r}') + return value + + +def _entry(el, raw): + """Check and type one element entry.""" + if not isinstance(raw, dict): + raise PartitionTableError(f'{el!r}: the entry must be a table') + unknown = sorted(set(raw) - KEYS) + if unknown: + raise PartitionTableError(f'{el!r}: unknown keys {unknown}') + out = { + 'element': el, + 'form': _choice(raw, el, 'form', FORMS), + 'quantity': _choice(raw, el, 'quantity', QUANTITIES), + 'basis': _choice(raw, el, 'basis', BASES), + 'P_unit': _choice(raw, el, 'P_unit', ('GPa',)), + } + out['silicate_basis'] = ( + _choice(raw, el, 'silicate_basis', SILICATE_BASES) if out['basis'] != 'mass' else None + ) + for field in COEFFICIENTS: + out[field] = _number(raw.get(field), el, field) + for field in RANGES: + pair = raw.get(field) + if pair == [] and field != 'P_range_GPa': + out[field] = None # not given by the source: every use is out of range + continue + if not isinstance(pair, list) or len(pair) != 2: + raise PartitionTableError(f'{el!r}: {field!r} must be [low, high], got {pair!r}') + lo, hi = (_number(v, el, field) for v in pair) + if lo > hi: + raise PartitionTableError(f'{el!r}: {field!r} low {lo} above high {hi}') + if lo < 0 and field != 'dIW_range': + raise PartitionTableError(f'{el!r}: {field!r} must not be negative, got {lo}') + out[field] = (lo, hi) + eps = raw.get('eps', {}) + if not isinstance(eps, dict) or any(k not in ELEMENTS for k in eps): + raise PartitionTableError(f'{el!r}: {"eps"!r} keys must be elements of {ELEMENTS}') + out['eps'] = {k: _number(v, el, 'eps') for k, v in sorted(eps.items())} + out['covariance'] = _covariance(raw.get('covariance', []), 5 + len(eps), el) + for field in ('source', 'doi'): + if not isinstance(raw.get(field), str) or not raw[field].strip(): + raise PartitionTableError(f'{el!r}: {field!r} must be a non-empty string') + out[field] = raw[field] + _check_read_values(el, out) + return out + + +def _need_form(entry, form): + """Refuse an entry whose form is not the one the evaluator implements.""" + if entry['form'] != form: + raise PartitionTableError(f'{"form"!r} must be {form!r} here, got {entry["form"]!r}') + + +def _check_read_values(el, entry): + """Refuse a value that the evaluator of the element does not read. + + H, C, N and S use :func:`mass_D` (``log10_linear``, every basis); O and Si use + :func:`exchange_metal` (``exchange_KD``, ``K_D`` on the mole basis, with only the + a, b and c terms). + """ + if el in EXCHANGE_ELEMENTS: + need = {'form': 'exchange_KD', 'quantity': 'K_D', 'basis': 'mole', 'dIW_factor': 1.0} + need |= {'d': 0.0, 'e': 0.0} + else: + need = {'form': 'log10_linear'} + for field, value in need.items(): + if entry[field] != value: + raise PartitionTableError( + f'{el!r}: {field!r} must be {value!r} for this element, got {entry[field]!r}' + ) + if el in EXCHANGE_ELEMENTS and entry['eps']: + raise PartitionTableError(f'{el!r}: {"eps"!r} must be empty for an exchange law') + + +def _covariance(cov, size, el): + """An empty list, or a symmetric positive semidefinite size x size matrix.""" + if isinstance(cov, list) and not cov: + return [] + try: + mat = np.array( + [[_number(v, el, 'covariance') for v in row] for row in cov], dtype=float + ) + except (TypeError, ValueError) as err: + raise PartitionTableError(f'{el!r}: {"covariance"!r} must be a matrix') from err + if mat.shape != (size, size): + raise PartitionTableError( + f'{el!r}: {"covariance"!r} must be {size} x {size} (a, b, c, d, e, eps), ' + f'got shape {mat.shape}' + ) + if not np.allclose(mat, mat.T, rtol=1e-12, atol=0.0): + raise PartitionTableError(f'{el!r}: {"covariance"!r} must be symmetric') + if np.linalg.eigvalsh(mat).min() < -1e-12 * max(1.0, np.abs(mat).max()): + raise PartitionTableError(f'{el!r}: {"covariance"!r} must be positive semidefinite') + return mat.tolist() + + +def load_partition_table(path=None): + """Read and check a partition coefficient file. + + Parameters + ---------- + path : str or Path or None + TOML file; None reads the packaged placeholder file. + + Returns + ------- + dict + ``schema_version``, ``meta`` and one checked entry per element in the file. + + Raises + ------ + PartitionTableError + A field that breaks the schema, named with its element. + FileNotFoundError + No file at ``path``. + tomllib.TOMLDecodeError + A file that is not valid TOML (a ``ValueError``). + """ + if path is None: + text = resources.files('calliope.data').joinpath(PLACEHOLDER_FILE).read_text() + else: + text = Path(path).read_text() + raw = tomllib.loads(text) + version = raw.pop('schema_version', None) + if type(version) is not int or version != SCHEMA_VERSION: + raise PartitionTableError(f'schema_version must be {SCHEMA_VERSION}, got {version!r}') + meta = raw.pop('meta', None) + name = meta.get('name') if isinstance(meta, dict) else None + if not ( + isinstance(name, str) and name.strip() and isinstance(meta.get('placeholder'), bool) + ): + raise PartitionTableError( + 'the file needs a [meta] table with a non-empty name and a boolean placeholder' + ) + unknown = [k for k in raw if k not in ELEMENTS] + if unknown: + raise PartitionTableError(f'unknown element sections {unknown!r}; known: {ELEMENTS}') + table = {'schema_version': version, 'meta': dict(meta)} + table.update({el: _entry(el, entry) for el, entry in raw.items()}) + return table + + +def _finite(name, value, positive=False): + if not math.isfinite(value) or (positive and value <= 0): + raise ValueError( + f'{name} must be finite{" and positive" if positive else ""}, got {value!r}' + ) + + +def log10_D(entry, p_gpa, t_k, d_iw, x_metal=None, nbo_t=2.6): + """log10 of the partition coefficient of one entry, in the entry's basis. + + Parameters + ---------- + entry : dict + One element entry of :func:`load_partition_table`. + p_gpa : float + Equilibration pressure in GPa. + t_k : float + Equilibration temperature in K. + d_iw : float + Oxygen fugacity relative to the iron-wustite buffer, log10 units. + x_metal : dict or None + Mole fractions of the elements of the eps terms in the metal; absent is 0. + nbo_t : float + NBO/T of the silicate melt. + + Returns + ------- + float + ``a + b/T + c P/T + d dIW_factor dIW + e NBO/T + sum_j eps_j log10(1 - X_j)``. + + Raises + ------ + ValueError + P, dIW or NBO/T not finite, T not finite and positive, or an X_j of an eps term + outside [0, 1). + """ + _finite('P [GPa]', p_gpa) + _finite('T [K]', t_k, positive=True) + _finite('dIW', d_iw) + _finite('NBO/T', nbo_t) + x_metal = x_metal or {} + val = entry['a'] + entry['b'] / t_k + entry['c'] * p_gpa / t_k + val += entry['d'] * entry['dIW_factor'] * d_iw + entry['e'] * nbo_t + for el, eps in entry['eps'].items(): + x = x_metal.get(el, 0.0) + if not 0.0 <= x < 1.0: + raise ValueError(f'X_{el} in the metal must be in [0, 1), got {x!r}') + val += eps * math.log10(1.0 - x) + return val + + +def _pow10(x, label, p_gpa, t_k, d_iw): + """10**x, or ValueError naming ``label`` and the conditions beyond the float range.""" + if not x < 308.0: + raise ValueError( + f'{label}: 10**{x:.4g} at P {p_gpa:g} GPa, T {t_k:g} K, dIW {d_iw:g} overflows' + ) + return 10.0**x + + +def out_of_range(entry, p_gpa, t_k, d_iw): + """True when (P, T, dIW) lies outside a range of the entry or on an unknown range. + + Parameters + ---------- + entry : dict + One element entry of :func:`load_partition_table`. + p_gpa : float + Equilibration pressure in GPa, against ``P_range_GPa``. + t_k : float + Equilibration temperature in K, against ``T_range_K``. + d_iw : float + Oxygen fugacity relative to the iron-wustite buffer, log10 units, against + ``dIW_range``. + + Returns + ------- + bool + True when a value lies outside the closed range ``[low, high]`` of the entry, or + when the T or dIW range is unknown (an empty range in the file). + """ + ranges = (entry['P_range_GPa'], entry['T_range_K'], entry['dIW_range']) + return any(r is None or not r[0] <= v <= r[1] for v, r in zip((p_gpa, t_k, d_iw), ranges)) + + +# Takahashi 1986, JGR 91, 9367, Table 1, KLB-1 bulk: the 7 major oxides [wt%] +KLB1 = { + 'SiO2': 44.48, + 'TiO2': 0.16, + 'Al2O3': 3.59, + 'FeO': 8.10, + 'MgO': 39.22, + 'CaO': 3.44, + 'Na2O': 0.30, +} +# cation, cations and O atoms per formula unit +_OXIDES = { + 'SiO2': ('Si', 1, 2), + 'TiO2': ('Ti', 1, 2), + 'Al2O3': ('Al', 2, 3), + 'FeO': ('Fe', 1, 1), + 'MgO': ('Mg', 1, 1), + 'CaO': ('Ca', 1, 1), + 'Na2O': ('Na', 2, 1), +} +_OXIDE_MASS = { + ox: n_cat * molar_mass_rock[cat] + n_o * molar_mass['O'] + for ox, (cat, n_cat, n_o) in _OXIDES.items() +} + + +def silicate_units_per_kg(oxides=KLB1): + """Moles of single-cation units (SiO2, TiO2, AlO1.5, FeO, MgO, CaO, NaO0.5) per kg. + + Parameters + ---------- + oxides : dict + Melt composition in oxide wt%, keyed by the oxides of :data:`KLB1`; used as + given, not normalised. + + Returns + ------- + float + Moles of single-cation units per kg of melt [mol/kg]. + """ + return sum(wt / 100.0 * _OXIDES[ox][1] / _OXIDE_MASS[ox] for ox, wt in oxides.items()) + + +def core_light_elements(d_x, w_melt, w_fixed, oxides=KLB1): + """Core H and C mass fractions from molar D on the H- and C-free metal basis. + + ``x'_i = D_i x_i`` with the melt mole fraction ``x_i`` on the single-cation basis, + H counted as HO0.5 and C as one unit each, and ``x'_i = N_i / sum_{k != H, C} N_k`` + in the metal, whose other light elements are fixed and whose rest is Fe. The metal + composition depends on H and C only through Fe, so the solve is closed form. + + Parameters + ---------- + d_x : dict + Molar D for 'H' and/or 'C'. + w_melt : dict + Mass fraction of H and C in the melt. + w_fixed : dict + Mass fractions of O, Si, S and N in the metal. + oxides : dict + Melt composition in oxide wt%. + + Returns + ------- + dict + Mass fraction of each element of ``d_x`` in the metal. + + Raises + ------ + ValueError + An element outside H and C in ``d_x``, outside O, Si, S, N in ``w_fixed``, or a + metal with no iron left. + """ + if not set(d_x) <= {'H', 'C'} or not set(w_fixed) <= {'O', 'Si', 'S', 'N'}: + raise ValueError(f'd_x takes H and C, w_fixed O, Si, S and N; got {d_x}, {w_fixed}') + m_fe = molar_mass_rock['Fe'] + rest = 1.0 - sum(w_fixed.values()) + n0 = rest / m_fe + sum(w / _mass(el) for el, w in w_fixed.items()) + n_sil = silicate_units_per_kg(oxides) + sum(w / molar_mass[el] for el, w in w_melt.items()) + xp = {el: d * (w_melt[el] / molar_mass[el]) / n_sil for el, d in d_x.items()} + n_metal = n0 / (1.0 + sum(molar_mass[el] * x for el, x in xp.items()) / m_fe) + out = {el: molar_mass[el] * x * n_metal for el, x in xp.items()} + if rest - sum(out.values()) <= 0: + raise ValueError(f'the metal has no iron left: fixed {w_fixed}, H and C {out}') + return out + + +def _mass(el): + return molar_mass_rock[el] if el in molar_mass_rock else molar_mass[el] + + +def metal_masses(mass_int_d, ddict, d_mass): + """Element masses in the equilibrated metal [kg]: D_i M_metal w_i(melt). + + Parameters + ---------- + mass_int_d : dict + Element masses dissolved in the melt [kg]. + ddict : dict + Reads ``metal_mass`` [kg] (0 or absent: no metal), ``M_mantle`` [kg] and + ``Phi_global``; the melt mass is ``M_mantle * Phi_global``. + d_mass : dict + Mass-basis D per element (metal over melt mass fraction). + + Returns + ------- + dict + Metal mass of each element of ``d_mass``. + + Raises + ------ + ValueError + ``metal_mass`` negative or not finite, metal with no melt, or a D that is + negative or not finite. + """ + m_metal = ddict.get('metal_mass', 0.0) + if not (math.isfinite(m_metal) and m_metal >= 0): + raise ValueError(f'metal_mass must be finite and >= 0, got {m_metal!r}') + if m_metal == 0.0: + return {el: 0.0 for el in d_mass} + m_melt = ddict['M_mantle'] * ddict['Phi_global'] + if not m_melt > 0: + raise ValueError( + f'metal_mass {m_metal!r} kg needs melt, but M_mantle * Phi_global is {m_melt!r}' + ) + out = {} + for el, d in d_mass.items(): + if not (math.isfinite(d) and d >= 0): + raise ValueError(f'D of {el} must be finite and >= 0, got {d!r}') + out[el] = d * m_metal * mass_int_d[el] / m_melt + return out + + +def _metal_moles(w_metal, exclude=()): + """Moles per kg of metal of the elements outside ``exclude``, the rest being Fe.""" + rest = 1.0 - sum(w_metal.values()) + if rest <= 0: + raise ValueError(f'the metal has no iron left: {w_metal}') + n = {el: w / _mass(el) for el, w in w_metal.items() if el not in exclude} + return rest / molar_mass_rock['Fe'] + sum(n.values()), n + + +def mass_D(entry, p_gpa, t_k, d_iw, w_metal, w_melt, oxides=KLB1, nbo_t=2.6): + """Mass-basis D (metal over melt mass fraction) of one entry. + + Parameters + ---------- + entry : dict + One element entry of :func:`load_partition_table`, form ``log10_linear``. + p_gpa, t_k, d_iw : float + Equilibration pressure [GPa], temperature [K] and dIW [log10 units]. + w_metal : dict + Light-element mass fractions of the metal (the rest is Fe). + w_melt : dict + Mass fractions of H and C in the melt, counted as HO0.5 and C units. + oxides : dict + Melt composition in oxide wt%. + nbo_t : float + NBO/T of the melt. + + Returns + ------- + float + ``D_w = D N_metal / N_melt`` for a mole basis, with N the moles per kg of the + metal (without H and C for ``mole_xprime_noHC``) and of the melt units; ``D`` + for the mass basis. + + Raises + ------ + PartitionTableError + An entry whose form is not ``log10_linear``. + ValueError + A metal with no iron left, a D beyond the float range (naming the law and its P, + T and dIW), or an invalid input of :func:`log10_D`. + + Notes + ----- + The X_j of the eps terms are ``x'_j = N_j / sum_{k != H, C} N_k``, the metal basis of + Tagawa et al. (2021, Methods Eq. 4) and of the H law. Fischer et al. (2020, p. 8744) + fit X_O and X_S as mole fractions in metal without H and do not name the elements of + the denominator; x' is the choice of this module. + """ + _need_form(entry, 'log10_linear') + total, n = _metal_moles(w_metal) + free = total - n.get('H', 0.0) - n.get('C', 0.0) + x_metal = {el: v / free for el, v in n.items()} + label = f'{entry["element"]} law' + d = _pow10(log10_D(entry, p_gpa, t_k, d_iw, x_metal, nbo_t), label, p_gpa, t_k, d_iw) + if entry['basis'] == 'mass': + return d + n_metal = free if entry['basis'] == 'mole_xprime_noHC' else total + n_melt = silicate_units_per_kg(oxides) + sum(w / molar_mass[el] for el, w in w_melt.items()) + return d * n_metal / n_melt + + +def cation_fraction(oxides, name): + """Mole fraction of oxide ``name`` on the single-cation basis of the melt. + + Parameters + ---------- + oxides : dict + Melt composition in oxide wt%, keyed by the oxides of :data:`KLB1`. + name : str + One oxide of ``oxides``, for example ``'FeO'`` or ``'SiO2'``. + + Returns + ------- + float + Cation units of ``name`` over all cation units of the melt, from 0 to 1 (an + oxide with 2 cations, such as Al2O3, counts 2 units of AlO1.5). + """ + return ( + oxides[name] + / 100.0 + / _OXIDE_MASS[name] + * _OXIDES[name][1] + / silicate_units_per_kg(oxides) + ) + + +def exchange_metal(table, p_gpa, t_k, d_iw, oxides=KLB1, elements=('O', 'Si'), check=True): + """Metal mole fractions of O and Si from their exchange K_D, ideal activities. + + ``FeO = Fe + O``: ``x_O = K_D x_FeO / x_Fe = K_D 10^(dIW/2)``; ``SiO2 + 2 Fe = Si + + 2 FeO``: ``x_Si = K_D x_SiO2 (x_Fe / x_FeO)^2 = K_D x_SiO2 10^(-dIW)``, with + ``dIW = 2 log10(x_FeO / x_Fe)``. Melt fractions are on the single-cation basis; metal + fractions are x' over the metal without H and C (Tagawa et al. 2021, Methods Eq. 4). + Activity coefficients are 1; the fits of Siebert et al. come with activity terms + that this release does not use. + + Only the laws of ``elements`` are evaluated. ``check=False`` returns finite fractions + that leave no iron as they are, for a fixed-point pass that shortens its step. + + Parameters + ---------- + table : dict + Table of :func:`load_partition_table` with an ``exchange_KD`` law (``K_D`` on the + mole basis) for each element of ``elements``. + p_gpa : float + Equilibration pressure in GPa. + t_k : float + Equilibration temperature in K. + d_iw : float + Oxygen fugacity of the metal-melt equilibrium relative to the iron-wustite + buffer, log10 units. + oxides : dict + Melt composition in oxide wt%; its SiO2 sets ``x_SiO2`` of the Si exchange. + elements : tuple of str + The exchanged elements to evaluate, a subset of ('O', 'Si'). + check : bool + Raise when the O and Si fractions sum to 1 or more. + + Returns + ------- + dict + Mole fraction of each element of ``elements`` in the metal, on the basis of the + metal without H and C (x'). + + Raises + ------ + PartitionTableError + A law of ``elements`` whose form is not ``exchange_KD``. + ValueError + P not finite, T not finite and positive, dIW not finite, a K_D or exchange term + beyond the float range (naming the law and its P, T and dIW), or O and Si that + leave no iron in the metal (with ``check``) or are not finite. + """ + for el in elements: + _need_form(table[el], 'exchange_KD') + x = {} + if 'O' in elements: + k_d = _pow10(log10_D(table['O'], p_gpa, t_k, 0.0), 'O law', p_gpa, t_k, d_iw) + x['O'] = k_d * _pow10(d_iw / 2.0, 'O exchange', p_gpa, t_k, d_iw) + if 'Si' in elements: + x_sio2 = cation_fraction(oxides, 'SiO2') + k_d = _pow10(log10_D(table['Si'], p_gpa, t_k, 0.0), 'Si law', p_gpa, t_k, d_iw) + x['Si'] = k_d * x_sio2 * _pow10(-d_iw, 'Si exchange', p_gpa, t_k, d_iw) + total = sum(x.values()) + if not math.isfinite(total): + raise ValueError( + f'the O and Si exchange is not finite ({x}) at P {p_gpa:g} GPa, T {t_k:g} K, ' + f'dIW {d_iw:g}' + ) + if check and total >= 1.0: + raise ValueError(f'O and Si leave the metal with no iron: {x}') + return x diff --git a/src/calliope/solve.py b/src/calliope/solve.py index 9314db17..53ba006b 100644 --- a/src/calliope/solve.py +++ b/src/calliope/solve.py @@ -1,6 +1,8 @@ from __future__ import annotations import logging +import math +import numbers import warnings import numpy as np @@ -10,10 +12,24 @@ from .constants import ( element_list_chnos, molar_mass, + molar_mass_rock, noble_gases, ocean_moles, volatile_species, ) +from .metal import ( + _OXIDES, + KLB1, + PartitionTableError, + _mass, + _metal_moles, + cation_fraction, + exchange_metal, + load_partition_table, + mass_D, + metal_masses, + out_of_range, +) from .oxygen_fugacity import DEFAULT_FO2_MODEL, FO2_MODELS, OxygenFugacity from .solubility import ( SolubilityCH4, @@ -104,6 +120,9 @@ def _noble_henry_seed(core_out, active, target_d, ddict): # not rejected for sub-10-kg mass-balance noise. TRUNC_MASS = 1e1 +# (element, kind) of the metal-reservoir warnings given in this process +_METAL_WARNED = set() + # Attempts allowed after the first fallback is stored, before the best one is returned. A # solvable trace budget (H 1e15 kg) reaches its root within 100 of them for ~85% of seeds. FALLBACK_ATTEMPTS = 100 @@ -549,15 +568,26 @@ def func(pin_arr, ddict, mass_target_d): mass_atm_d = atmosphere_mass(pin_dict, ddict) mass_int_d = dissolved_mass(pin_dict, ddict) + mass_core_d = _core_masses(mass_int_d, ddict) elems = ['H', 'C', 'N', 'S'] + list(active) res_l = [0.0] * len(elems) for i, vol in enumerate(elems): res_l[i] = mass_atm_d[vol] + mass_int_d[vol] - mass_target_d[vol] + res_l[i] += mass_core_d.get(vol, 0.0) return res_l +def _core_masses(mass_int_d, ddict): + """Core masses [kg] of H, C, N, S at the mass-basis D in ``ddict['_metal_D']``. + + Without that key (no metal) the result is empty, so the balance is unchanged. + """ + d_mass = ddict.get('_metal_D') + return metal_masses(mass_int_d, ddict, d_mass) if d_mass else {} + + def obj(pin_arr, ddict, mass_target_d): """Function to compute the residual of the mass balance given the partial pressures [bar]""" @@ -605,11 +635,13 @@ def func_authoritative_O(x_arr, ddict, mass_target_d): mass_atm_d = _atmosphere_mass(pin_dict, fO2_shift, ddict) mass_int_d = _dissolved_mass(pin_dict, fO2_shift, ddict) + mass_core_d = _core_masses(mass_int_d, ddict) elems = ['H', 'C', 'N', 'S', 'O'] + list(active) res_l = [0.0] * len(elems) for i, elem in enumerate(elems): res_l[i] = mass_atm_d[elem] + mass_int_d[elem] - mass_target_d[elem] + res_l[i] += mass_core_d.get(elem, 0.0) return res_l @@ -839,6 +871,8 @@ def equilibrium_atmosphere( Target elemental mass inventories [kg], with keys 'H', 'C', 'N', 'S'. ddict : dict Dictionary of coupler options variables (planet, magma state, inclusion flags). + With ``metal_mass`` > 0 [kg] the solve adds a metal reservoir in equilibrium with + the melt; its keys are listed on the docs page 'Metal reservoir and partition file'. hide_warnings : bool, default True Hide floating point runtime warnings raised by `scipy` for poor guesses. rtol : float, default 1e-5 @@ -894,6 +928,23 @@ def equilibrium_atmosphere( ddict = _with_fO2_model(ddict, fO2_model) + if 'metal_mass' in ddict and '_metal_D' not in ddict: + check_metal_fields(ddict) + if ddict.get('metal_mass', 0.0) > 0 and '_metal_D' not in ddict: + kwargs = dict( + hide_warnings=hide_warnings, + rtol=rtol, + atol=atol, + xtol=xtol, + p_guess=p_guess, + nsolve=nsolve, + nguess=nguess, + print_result=print_result, + opt_solver=opt_solver, + p_guess_max=p_guess_max, + ) + return _with_metal(equilibrium_atmosphere, target_d, ddict, kwargs) + if print_result: log.info('Solving for equilibrium partial pressures at surface') log.debug(' target masses: %s' % str(target_d)) @@ -1131,6 +1182,7 @@ def equilibrium_atmosphere( # on a dict that already contains the secondaries (read but ignored). mass_atm_d = atmosphere_mass(sol_dict, ddict) mass_int_d = dissolved_mass(sol_dict, ddict) + mass_core_d = _core_masses(mass_int_d, ddict) # Active noble gases join the reaction-network species and elements in the # output schema, so their partial pressures, reservoir masses, mole @@ -1213,6 +1265,9 @@ def equilibrium_atmosphere( outdict[gas + '_res'] = res_l[4 + i] outdict['fallback'] = fell_back outdict['fO2_model'] = ddict['fO2_model'] + if 'metal_mass' in ddict: + for el in ('H', 'C', 'N', 'S'): + outdict[el + '_kg_core'] = mass_core_d.get(el, 0.0) return outdict @@ -1260,7 +1315,9 @@ def equilibrium_atmosphere_authoritative_O( Coupler options dict (planet, magma state, inclusion flags). ``ddict['fO2_shift_IW']`` is IGNORED by this entry point; the value is treated as a solver unknown initialised from - ``fO2_hint`` instead. + ``fO2_hint`` instead. With ``metal_mass`` > 0 [kg] the solve adds a + metal reservoir; its O comes from the silicate FeO and stays out of + the O budget (docs page 'Metal reservoir and partition file'). fO2_hint : float, default 4.0 Initial guess for the IW-buffer offset (log10 units). Provide a value close to the expected solution to speed convergence; @@ -1371,6 +1428,28 @@ def equilibrium_atmosphere_authoritative_O( >>> abs(out_new['fO2_shift_derived'] - 4.0) < 0.01 # round-trip True """ + ddict = _with_fO2_model(ddict, fO2_model) + + if 'metal_mass' in ddict and '_metal_D' not in ddict: + check_metal_fields(ddict) + if ddict.get('metal_mass', 0.0) > 0 and '_metal_D' not in ddict: + kwargs = dict( + fO2_hint=fO2_hint, + hide_warnings=hide_warnings, + rtol=rtol, + atol=atol, + xtol=xtol, + p_guess=p_guess, + nsolve=nsolve, + nguess=nguess, + print_result=print_result, + opt_solver=opt_solver, + random_seed=random_seed, + p_guess_max=p_guess_max, + ) + return _with_metal( + equilibrium_atmosphere_authoritative_O, target_d, ddict, kwargs, o_budget=True + ) # Active noble gases are appended after the O residual, so the residual # (and target) order is H, C, N, S, O, then each active noble gas. @@ -1395,8 +1474,6 @@ def equilibrium_atmosphere_authoritative_O( if v < 0: raise ValueError('target_d[%r] must be non-negative [kg], got %r.' % (e, v)) - ddict = _with_fO2_model(ddict, fO2_model) - # Validate fO2_hint and the planet/state parameters consumed from # ddict by the residual chain. The solver evaluates the residual at # arbitrarily wild trial points, so it cannot recover from a bad @@ -1816,8 +1893,299 @@ def equilibrium_atmosphere_authoritative_O( outdict[gas + '_res'] = res_l[5 + i] outdict['fO2_shift_derived'] = fO2_derived outdict['fO2_model'] = ddict['fO2_model'] + if 'metal_mass' in ddict: + mass_core_d = _core_masses(mass_int_d, ddict) + for el in ('H', 'C', 'N', 'S'): + outdict[el + '_kg_core'] = mass_core_d.get(el, 0.0) if print_result: log.info(' Derived fO2_shift = %.4f (hint was %.4f)', fO2_derived, fO2_hint) return outdict + + +def _with_metal(solve, target_d, ddict, kwargs, o_budget=False): + """Solve with a metal reservoir: fixed point on the metal composition. + + Each pass evaluates the mass-basis D of H, C, N and S on the metal and melt of the + previous pass with the O and Si of this pass (``metal.mass_D``), solves at those fixed + D, and takes the new metal H, C, N and S + from the core masses. O and Si in the metal are fixed mass fractions + (``O_mode_core``, ``Si_mode_core`` = 'fixed'; ``w_O_core``, ``w_Si_core``) or follow + their exchange K_D at the dIW of the pass ('partition', ideal activities). A + partitioned element moves by ``1 / (1 - s)`` of its exchange step, with ``s`` the + secant slope of the exchange value against the metal value of the last 2 passes when + ``s < 0`` (1 otherwise): with dIW from FeO, more metal Si raises dIW and lowers the + exchange Si, and the undamped passes alternate. With O and Si both partitioned each + secant also holds the move of the other element. The moves of a pass are halved until + they take at most half of the iron left in the metal; an exchange that leaves no iron + and does not fall from the previous pass raises ``ValueError``. That is a stop rule, not + a proof that no fixed point with iron exists: in 'partition' mode ``w_O_core`` and + ``w_Si_core`` are the start metal, and another start can reach one. O and Si both + partitioned with dIW from FeO do not converge at 6000-7000 K (P_eq 50 GPa, KLB-1); O + alone with dIW from FeO stalls near K_D(O) x_FeO = 1/4. The passes stop when + each metal mass fraction, or exchange step, is at most ``tol_w_core`` (default 1e-6) + of its value, plus 1e-15; that bounds the last step, not the error. The dIW of a pass + uses the metal before its O and Si move. The metal O comes from the silicate FeO and never enters the + volatile O budget; with ``o_budget`` (the authoritative-O solve) gas and melt O close + on ``target_d['O']``. + The closure check uses the acceptance gate of the solve: the largest H, C, N, S + budget times ``rtol`` plus ``atol`` for ``equilibrium_atmosphere``, each budget + times ``rtol`` (at least 10 kg) with ``o_budget``. ``_kg_total`` stays gas plus + melt and excludes the core; the bulk inventory is ``_kg_total + _kg_core``. + ``_D_out_of_range`` is 1 for each element whose law runs outside its (P, T, dIW) + range or on an unknown range (O and Si only in 'partition' mode); such a law, the + ideal activities of 'partition' mode and a table marked placeholder each warn once + per process. + + Raises + ------ + RuntimeError + No convergence in 30 passes, or a solved state that breaks the element closure. + ValueError + A metal field that breaks its rule, or metal with no melt (Phi_global 0). + """ + check_metal_fields(ddict) + table = ddict.get('partition_table') or load_partition_table() + m_metal, m_melt = ddict['metal_mass'], ddict['M_mantle'] * ddict['Phi_global'] + if not m_melt > 0: + raise ValueError( + f'metal_mass {m_metal!r} kg needs melt, but M_mantle * Phi_global is {m_melt!r}' + ) + oxides = ddict.get('silicate_oxides', KLB1) + nbo_t = ddict.get('silicate_nbo_t', 2.6) + p_eq, t_eq = ddict['P_eq'], ddict['T_eq'] + fixed, modes = {}, {} + for el in ('O', 'Si'): + modes[el] = ddict.get(f'{el}_mode_core', 'fixed') + fixed[el] = float(ddict.get(f'w_{el}_core', 0.0)) + if modes[el] == 'partition': + _warn_metal_once( + el, + 'ideal', + f"{el}_mode_core 'partition' uses ideal activities in metal and melt; " + 'activity coefficients are not included', + ) + if table['meta']['placeholder']: + _warn_metal_once('table', 'placeholder', 'the metal partition laws are placeholders') + partitioned = tuple(el for el in ('O', 'Si') if modes[el] == 'partition') + missing = [el for el in ('H', 'C', 'N', 'S') + partitioned if el not in table] + if missing: + raise PartitionTableError(f'the partition table has no law for {missing}') + w = {**fixed, 'H': 0.0, 'C': 0.0, 'N': 0.0, 'S': 0.0} + w_melt = {'H': 0.0, 'C': 0.0} + tol = ddict.get('tol_w_core', 1e-6) + active = _active_noble(ddict) + out, last, resid, x_sum = None, {}, {}, math.inf + for _ in range(30): + d_iw = _metal_diw(ddict, w, oxides) + if partitioned: + x_ex = exchange_metal(table, p_eq, t_eq, d_iw, oxides, partitioned, check=False) + x_sum, x_prev = sum(x_ex.values()), x_sum + if x_sum >= max(1.0, x_prev): # an exchange without iron that does not fall + raise ValueError( + ', '.join(f"{el}_mode_core 'partition'" for el in partitioned) + + f': the exchange holds no iron and does not fall ({x_ex}); no fixed ' + 'point was reached from the start metal (' + + ', '.join(f'w_{el}_core' for el in partitioned) + + ')' + ) + n_total = _metal_moles(w, ('H', 'C'))[0] + move = {} + for el in partitioned: + target, lam = x_ex[el] * n_total * _mass(el), 1.0 + if el in last and fixed[el] != last[el][0]: + slope = (target - last[el][1]) / (fixed[el] - last[el][0]) + lam = 1.0 / (1.0 - min(slope, 0.0)) + last[el], resid[el] = (fixed[el], target), target - fixed[el] + move[el] = lam * resid[el] + # a pass takes at most half of the iron left in the metal + while sum(w.values()) + sum(move.values()) >= 0.5 * (1.0 + sum(w.values())): + move = {el: 0.5 * v for el, v in move.items()} + for el in partitioned: + fixed[el] += move[el] + d_mass = { + el: mass_D(table[el], p_eq, t_eq, d_iw, {**w, **fixed}, w_melt, oxides, nbo_t) + for el in ('H', 'C', 'N', 'S') + } + inner = {**ddict, '_metal_D': d_mass} + out = solve(target_d, inner, **kwargs) + kwargs['p_guess'] = {s: out[s + '_bar'] for s in ('H2O', 'CO2', 'N2', 'S2') + active} + if o_budget: + kwargs['p_guess']['fO2_shift_IW'] = out['fO2_shift_derived'] + new = {**fixed, **{el: out[el + '_kg_core'] / m_metal for el in ('H', 'C', 'N', 'S')}} + w_melt = {el: out[el + '_kg_liquid'] / m_melt for el in ('H', 'C')} + step = {el: new[el] - w[el] for el in new} | resid + settled = all(abs(step[el]) <= tol * abs(new[el]) + 1e-15 for el in new) + w = new + if settled: + break + else: + raise RuntimeError(f'metal composition did not converge in 30 passes: {w}') + for el in ('H', 'C', 'N', 'S'): + out[el + '_D_metal'] = d_mass[el] + out['w_' + el + '_core'] = w[el] + for el in ('H', 'C', 'N', 'S') + partitioned: + out[el + '_D_out_of_range'] = int(out_of_range(table[el], p_eq, t_eq, d_iw)) + if out[el + '_D_out_of_range']: + _warn_metal_once( + el, + 'range', + f'{el} partition law evaluated outside its range or on an unknown range: ' + f'P_eq {p_eq:g} GPa, T_eq {t_eq:g} K, dIW_eq {d_iw:.3g}', + ) + for el in ('O', 'Si'): + out['w_' + el + '_core'] = fixed[el] + out[el + '_kg_core'] = fixed[el] * m_metal + out.update(_feo_exchange(out['O_kg_core'], out['Si_kg_core'], m_melt, oxides)) + rtol = kwargs['rtol'] + gate = max(target_d[el] for el in ('H', 'C', 'N', 'S')) * rtol + kwargs['atol'] + TRUNC_MASS + for el in ('H', 'C', 'N', 'S') + (('O',) if o_budget else ()): + core = out[el + '_kg_core'] if el != 'O' else 0.0 # metal O is rock O, not budget O + closure = out[el + '_kg_atm'] + out[el + '_kg_liquid'] + core + # the 1e-12 relative term covers round-off of the 3-term sum + tol_kg = (max(target_d[el] * rtol, TRUNC_MASS) if o_budget else gate) + 1e-12 * ( + target_d[el] + ) + if not 0.0 <= core <= target_d[el] + tol_kg: + raise RuntimeError( + f'{el} core mass {out[el + "_kg_core"]:.4g} kg is outside [0, budget ' + f'{target_d[el]:.4g} kg]' + ) + if abs(closure - target_d[el]) > tol_kg: + raise RuntimeError( + f'{el} closure over gas, melt and core fails: {closure} vs {target_d[el]}' + ) + out['dIW_eq'] = d_iw + return out + + +def _feo_exchange(o_kg, si_kg, m_melt, oxides): + """Silicate FeO taken by the metal O and Si, against the FeO of the melt. + + FeO = Fe + O takes 1 FeO per metal O; SiO2 + 2 Fe = Si + 2 FeO gives back 2 per metal + Si, so ``n_FeO_exchange = n_O - 2 n_Si`` [mol]. ``FeO_exchange_out_of_range`` is 1 + when the exchange is more than 10 % of the melt FeO, in either sign. + """ + m_feo = _mass('Fe') + molar_mass['O'] + n_ex = o_kg / molar_mass['O'] - 2.0 * si_kg / _mass('Si') + n_sil = m_melt * oxides.get('FeO', 0.0) / 100.0 / m_feo + ratio = abs(n_ex) / n_sil if n_sil > 0 else (math.inf if n_ex else 0.0) + if ratio > 0.1: + _warn_metal_once( + 'FeO', + 'exchange', + f'the metal exchange takes more than 10 % of the silicate FeO ({ratio:.3g}); ' + 'the melt FeO and dIW_eq of this solve do not follow it', + ) + return { + 'n_FeO_exchange': n_ex, + 'FeO_kg_exchange': n_ex * m_feo, + 'n_FeO_silicate': n_sil, + 'FeO_exchange_out_of_range': int(ratio > 0.1), + } + + +def _warn_metal_once(el, kind, message): + """Log a metal-reservoir warning once per process for each (element, kind).""" + if (el, kind) not in _METAL_WARNED: + _METAL_WARNED.add((el, kind)) + log.warning(message) + + +def _metal_diw(ddict, w_metal, oxides): + """dIW of the metal-melt equilibrium: the parameter, or 2 log10(x_FeO / x'_Fe) (ideal). + + x_FeO is on the single-cation basis of the melt, x'_Fe over the metal without H and C + (Tagawa et al. 2021, Methods Eq. 4 and the fO2 path of their multi-stage model). + """ + mode = ddict.get('dIW_eq_mode', 'parameter') + if mode == 'parameter': + return float(ddict['dIW_eq']) + if mode != 'from_FeO': + raise ValueError(f"dIW_eq_mode {mode!r}: use 'parameter' or 'from_FeO'") + total, _ = _metal_moles(w_metal, ('H', 'C')) + x_fe = (1.0 - sum(w_metal.values())) / molar_mass_rock['Fe'] / total + return 2.0 * np.log10(cation_fraction(oxides, 'FeO') / x_fe) + + +_METAL_MODES = { + 'O_mode_core': ('fixed', 'partition'), + 'Si_mode_core': ('fixed', 'partition'), + 'dIW_eq_mode': ('parameter', 'from_FeO'), +} +# field -> (lowest value, highest value, low end allowed, high end allowed) +_METAL_FIELDS = { + 'metal_mass': (0.0, math.inf, True, False), + 'P_eq': (0.0, math.inf, False, False), + 'T_eq': (0.0, math.inf, False, False), + 'dIW_eq': (-math.inf, math.inf, False, False), + 'silicate_nbo_t': (0.0, math.inf, True, False), + 'w_O_core': (0.0, 1.0, True, False), + 'w_Si_core': (0.0, 1.0, True, False), + 'tol_w_core': (0.0, 1.0, False, False), +} + + +def check_metal_fields(ddict): + """Check the metal-reservoir fields of ``ddict`` that are present. + + Each must be a finite real number (not a boolean) in its range: ``metal_mass`` [kg] + and ``silicate_nbo_t`` >= 0, ``P_eq`` [GPa] and ``T_eq`` [K] > 0, ``tol_w_core`` in + (0, 1), ``w_O_core`` and ``w_Si_core`` in [0, 1) with a sum below 1. A mode is one of + its strings. ``silicate_oxides`` is a dict of known + oxides in wt%, finite and >= 0, that sums to 95-101. + + Raises + ------ + ValueError + Names the first field that breaks its rule. + """ + for field, (lo, hi, lo_ok, hi_ok) in _METAL_FIELDS.items(): + if field not in ddict: + continue + v = ddict[field] + if not _finite_number(v): + raise ValueError(f'{field} must be a finite number, got {v!r}') + if v < lo or v > hi or (v == lo and not lo_ok) or (v == hi and not hi_ok): + raise ValueError(f'{field} = {v!r} is outside its range ({lo}, {hi})') + if 'silicate_oxides' in ddict: + ox = ddict['silicate_oxides'] + if not isinstance(ox, dict): + raise ValueError(f'silicate_oxides must be a dict of oxide wt%, got {ox!r}') + unknown = [k for k in ox if k not in _OXIDES] + if unknown: + raise ValueError( + f'silicate_oxides has unknown oxides {unknown}; known: {list(_OXIDES)}' + ) + for k, v in ox.items(): + if not (_finite_number(v) and v >= 0): + raise ValueError(f'silicate_oxides[{k!r}] must be finite and >= 0, got {v!r}') + if not 95.0 <= sum(ox.values()) <= 101.0: + raise ValueError(f'silicate_oxides sum to {sum(ox.values())!r} wt%, not 95-101') + for field, options in _METAL_MODES.items(): + mode = ddict.get(field, options[0]) + if not isinstance(mode, str) or mode not in options: + raise ValueError(f'{field} {mode!r}: use one of {options}') + if ddict.get('w_O_core', 0.0) + ddict.get('w_Si_core', 0.0) >= 1.0: + raise ValueError('w_O_core + w_Si_core leave no iron in the metal') + if not ddict.get('metal_mass', 0.0) > 0: + return + diw_mode = ddict.get('dIW_eq_mode', 'parameter') + for field in ('P_eq', 'T_eq') + (('dIW_eq',) if diw_mode == 'parameter' else ()): + if field not in ddict: + raise ValueError(f'{field} is required with metal_mass > 0') + ox = ddict.get('silicate_oxides', KLB1) + needs = {'FeO': diw_mode == 'from_FeO', 'SiO2': ddict.get('Si_mode_core') == 'partition'} + for oxide in (k for k, need in needs.items() if need and not ox.get(k, 0) > 0): + raise ValueError(f'silicate_oxides needs {oxide} above 0 for the selected mode') + + +def _finite_number(v): + """A finite real number that is not a boolean (Python or numpy).""" + if not isinstance(v, numbers.Real) or isinstance(v, bool | np.bool_): + return False + try: + return math.isfinite(v) + except OverflowError: # an int beyond the float range + return False diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 00000000..c80cfd12 --- /dev/null +++ b/tests/AGENTS.md @@ -0,0 +1,87 @@ +# CALLIOPE test instructions + + +## Test rules shared by the PROTEUS ecosystem + +Each test file starts with a module-level tier marker and a timeout. CI selects tests by marker, so a file without one runs in no CI job; the timeout stops a hang, it is not a target. + +```python +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] +``` + +| tier | what it tests | timeout | +|---|---|---| +| `unit` | Python logic with the heavy physics mocked; aim for < 100 ms | 30 s | +| `smoke` | the real binaries or solvers, one step, low resolution | 60 s | +| `integration` | several modules coupled | 300 s | +| `slow` | full physics validation | 3600 s | + +Each test carries exactly one tier marker, so that the tier filters select it in the one CI job meant for it; a function marker for a second tier breaks that. Split a file whose tests need different tiers. `skip` excludes a test from every CI job. + +Every new test covers an edge case (a boundary value, an empty input, an extreme physical parameter), exercises the error contract (a documented exception with the check that no side effect ran, a guard, a clamp, or the limit input of the formula), and asserts values that do not follow trivially from the implementation. A pinned value of 1 that every exponent reproduces checks nothing. + +`python tools/check_test_quality.py --check` fails when the count of any rule rises above `tools/test_quality_baseline.json`; the offenders it prints are the first few of that rule in the tree, not necessarily yours. The rules: a file without a tier marker, a test without a docstring, a test with one assertion or none, a weak assertion as the only one (`is None`, `is not None`, `> 0`, `len(...) > 0`, `isinstance`), `==` next to a non-zero float literal, and an optional dependency imported without `pytest.importorskip`. `bash tools/validate_test_structure.sh` runs the repository's own structure check; the repository part of this file says what it checks. + +Physics tests carry markers so their coverage is tracked apart from line coverage: +- `@pytest.mark.physics_invariant` on each test function that asserts a conservation law, a bound (T > 0, fractions in [0, 1]), a monotonicity or symmetry, or a pinned value with a discrimination guard. The marker goes on the function, not the module: structural tests in the same file do not carry it. +- `@pytest.mark.reference_pinned` (together with `physics_invariant`) on a test that pins a published benchmark, an analytical limit or a cross-implementation result; cite the paper, table or figure in the docstring. +- A discrimination guard asserts that the most plausible wrong formula (a missing factor, a swapped exponent, the wrong unit) gives a result outside the tolerance of the pinned value. + +Floats: compare with `pytest.approx` or `np.testing.assert_allclose` and a tolerance you can justify from the method, not the one that makes the test pass. + +Mocks: mock at the narrowest scope (the one external call), and return physically plausible values, so the code under test runs its real branches. Set random seeds and write files only under `tmp_path`. + +A module-level constant read from an environment variable at import time does not change with `monkeypatch.setenv`; patch the constant with `monkeypatch.setattr`. + + +## CALLIOPE specifics + +Structure: `src/calliope/.py` is tested in `tests/test_.py`. Three kinds of file do not follow this: cross-cutting tests (`test_invariants_hypothesis.py`, `test_init.py`, `test_element_balance_grid.py`, `test_cross_code_consistency.py`), tests of a repository file or of `tests/conftest.py` (`test_precommit_config.py`, `test_validation_index.py`, `test_conftest_*.py`), and topical files of a physics source above about 500 lines whose topics are independent, for example `test_authoritative_O*.py`, `test_equilibrium_paths.py`, `test_noble_gases.py`, `test_metal_solve.py` and `test_metal_fields.py` for `solve.py`. The primary file `tests/test_.py` still exists and carries at least one `physics_invariant` and one `reference_pinned` test. `bash tools/validate_test_structure.sh` checks that every test carries exactly one of `unit`, `smoke`, `integration`, `slow` and `skip` (module, class or function). + +Tiers: `unit` is Python logic with the solver mocked; `smoke` is the real solver on a minimal composition (aim for < 30 s); `integration` is the multi-species CHNS solve; `slow` is full physics validation. A file whose slowest test takes 7.5 s or more in CI uses four times that duration, rounded up to a full minute, as its timeout. pytest-timeout is a required plugin, and a hook in `tests/conftest.py` stops the run when the timeout mark of a test has no positive budget. An autouse fixture seeds the global NumPy generator with 0 before every test, so solver restarts repeat. `docs/How-to/build_tests.md` gives the commands per tier. + +### Physics sources and invariants + +The physics sources are listed in `AGENTS.md` and in `PHYSICS_SOURCES` of `tools/check_test_quality.py`. Each unit test of a physics source asserts at least one of these; utility sources are exempt from this rule but not from the rest: + +- Conservation: per-species closure `kg_atm + kg_liquid == kg_total`, element closure across gas, melt and metal, mole fractions summing to 1. +- Bounds: T > 0, P > 0, partial pressures and solubilities non-negative, mole fractions in [0, 1], `log10(fO2)` finite (no NaN, infinity or complex value). +- Monotonicity or symmetry: `log10(fO2)` along an isobaric buffer decreasing with `1/T`; CO2 solubility rising with P at fixed T; partial pressures doubling with the pressure at fixed mole fractions; outputs unchanged when two non-reacting species swap places. +- A pinned value with a discrimination guard, when a closed-form result or a published table value is the contract. + +A property is preferred over a point pin where both are possible. Each case has a section on a page in `docs/Validation/` that names the source, the reference and the test ids; the [Validation overview](../docs/Validation/index.md) lists the cases, and `test_validation_index.py` fails on a pinned test missing from it. `python tools/check_test_quality.py --reference-pinned-status` and `--physics-invariant-status` list the gaps (advisory). + +### Edge cases and discriminating values + +Edge cases here: `Phi` 0 or 1, T at the solidus, P = 0, `fO2_shift_IW = 0`, `metal_mass = 0`, and a single-species composition, which is a degenerate fixed point of the multi-species solve. A function without input checks (a closed-form relation, an equilibrium constant) is tested at its limit input against the invariant there; the missing check is no exemption. + +The plausible bugs here are a wrong exponent or factor, a wrong sign, a unit slip (Pa against bar, K against Celsius, log10 against ln) and a wrong buffer or law (Hirschmann, Fischer or O'Neill, one solubility law or its sibling). A pin with `pytest.approx` adds a guard for each class: a wrong-value distance, an explicit sign (`val < 0` for `log10(fO2)` at IW), a scale window with the unit in the comment (`-10 < val < -3`), and a value that separates the chosen buffer or law from its sibling. When the primary assertion is a conservation closure, the closure covers the exponent class; the sign and scale guards stay. + +Discriminating inputs: the IW buffer at 1500 and 2500 K against the Fischer to O'Neill slope ratio; Henry's law with one dominant species and traces, so a swapped constant moves it beyond the tolerance; an equilibrium constant at an off-grid temperature; stoichiometric closure with several species at a non-zero `fO2_shift_IW`. + +A single assertion is accepted only for a hard-fail invariant (mass closure at `1e-12`) that no other test in the file checks. Delete a test that asserts a fixture's implicit default. + +### Tolerances, mocks, constants + +- State the reason for a tolerance in a comment when it is not obvious ("`rtol=1e-3` because the fit gives four significant figures"). Closed-form algebra holds to `rel=1e-12`; solver output needs a looser tolerance. +- Unit tests mock atmodeller, file, HTTP and subprocess access with `unittest.mock`, patching one function (`calliope.solve.`), not a module. A mock returns physically plausible values, not `0.0` or `1.0` everywhere. Never mock the function under test. Smoke, integration and slow tests use the real solver. +- `hypothesis` and `atmodeller` are the optional dependencies the linter knows (`OPTIONAL_DEPS`, rule `missing_importorskip`); atmodeller is in no extra, so its import is always guarded; a new optional dependency is added there. +- A solve takes its IW buffer from `fO2_model=` or `ddict['fO2_model']`, else `calliope.solve.DEFAULT_FO2_MODEL` read at each call, and passes it to every `OxygenFugacity`, `ModifiedKeq` and `SolubilityS2` it builds; select the buffer of a test with `fO2_model=` or the key. A patch of `calliope.solve.DEFAULT_FO2_MODEL` changes every solve in the process; one of `calliope.oxygen_fugacity.DEFAULT_FO2_MODEL` changes nothing, and one of a class's `__init__.__defaults__` only the calls of that class that omit the buffer. +- Test parameters use SI units unless the function takes config units (bar, K, ppmw), named in a comment; parametrize ids name the physical scenario (Earth-like, Mars-like, high fO2). A hypothesis test uses `@settings(derandomize=True)` or a fixed `--hypothesis-seed`, named in its docstring. Write no large outputs. + +### Docstrings and names + +The file docstring starts with `Tests for src/calliope/.py`, lists the invariants and contract clauses it checks, and links `docs/How-to/build_tests.md`. Each test docstring states the physical scenario or contract clause. A comment explains why an input was chosen ("1500 and 2500 K so the Fischer to O'Neill slope difference is resolved"). Names describe behaviour in snake_case (`test_iw_buffer_monotonic_with_temperature`, not `test_iw_buffer`); a class groups tests that share setup. Test names, skip reasons, parametrize ids and log-capture assertions follow the public-text rule of `AGENTS.md` and do not use the words "audit", "review pass" or "adversarial review"; the pages under `docs/` do not cite the agent instruction files. + +### Coverage gates + +The fast gate (`[tool.calliope.coverage_fast]`, unit and smoke, every pull request) and the full gate (`[tool.coverage.report]`, all tiers, nightly) move one way toward 90 % (`ECOSYSTEM_CEILING` in `tools/update_coverage_threshold.py`, which raises them: `--target fast` or `--target full`); never lower one by hand. `python tools/check_test_quality.py --baseline` rewrites `tools/test_quality_baseline.json`; run it only after a change that reduced violations. + +### Review of test changes + +A pull request that adds or changes more than 50 lines under `tests/` (`git diff origin/main...HEAD -- tests/`, over all its commits) gets an independent review of its tests before merge. The reviewer checks the rules above, recomputes a pinned value with the most plausible wrong formula (wrong buffer, unit slip) and confirms the test fails with it, and names the invariant each physics test asserts. Fixes go in a follow-up commit whose subject states the outcome. Recurring faults: + +- A reference value tied to a buffer whose test does not name the buffer and has no sibling-buffer guard, so a default change passes silently. +- A solver test that checks only the final output, while a complex or NaN intermediate was coerced to a plausible float; assert `np.isreal` and `np.isfinite` on the intermediates the defence covers. +- A helper that turns a failed lookup into `continue`; assert instead. +- An assertion on a log line where the contract is a value; capture the call argument. diff --git a/tests/CLAUDE.md b/tests/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/tests/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/tests/test_metal.py b/tests/test_metal.py new file mode 100644 index 00000000..642bd27b --- /dev/null +++ b/tests/test_metal.py @@ -0,0 +1,785 @@ +"""Tests for `src/calliope/metal.py`: the metal-silicate partition coefficient file. + +Contract clauses: a file with schema_version 1 and one section per element (H, C, N, +S, O, Si) loads with every field typed and checked; each malformed field (a TOML +boolean or string where a number belongs, NaN, inf, an unknown form, quantity or +basis, a mole basis without its silicate basis, a reversed or non-finite range, a +pressure unit other than GPa, an interaction term for an unknown element, a +covariance that is not square, not symmetric, not positive semidefinite or of the +wrong size) raises PartitionTableError naming the element and field; the packaged +placeholder file loads and says that it is a placeholder. +""" + +from __future__ import annotations + +import math + +import pytest + +from calliope.metal import ( + ELEMENTS, + PartitionTableError, + core_light_elements, + exchange_metal, + load_partition_table, + log10_D, + mass_D, + metal_masses, + out_of_range, +) + +hypothesis = pytest.importorskip('hypothesis') +from hypothesis import given, settings # noqa: E402 +from hypothesis import strategies as st # noqa: E402 + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + +# Tagawa et al. 2021 Eq. 2 for H, the shape every entry of the file has +_H = { + 'form': '"log10_linear"', + 'quantity': '"K_D"', + 'basis': '"mole_xprime_noHC"', + 'silicate_basis': '"single_cation"', + 'a': '2.42', + 'b': '-2892.0', + 'c': '-32.0', + 'd': '-0.25', + 'e': '0.0', + 'dIW_factor': '1.0', + 'eps': '{}', + 'covariance': '[]', + 'P_range_GPa': '[30.0, 60.0]', + 'T_range_K': '[3080.0, 4560.0]', + 'dIW_range': '[-1.33, -0.88]', + 'P_unit': '"GPa"', + 'source': '"Tagawa et al. 2021, Nat. Commun. 12, 2588, Eq. 2"', + 'doi': '"10.1038/s41467-021-22035-0"', +} + + +def _text(entries=None, header='schema_version = 1'): + """TOML text with a meta table and one section per element of `entries`.""" + entries = {'H': _H} if entries is None else entries + out = [header, '[meta]', 'name = "test"', 'placeholder = true'] + for el, fields in entries.items(): + out.append(f'[{el}]') + out += [f'{k} = {v}' for k, v in fields.items()] + return '\n'.join(out) + '\n' + + +def _write(tmp_path, text): + path = tmp_path / 'partition.toml' + path.write_text(text) + return path + + +def _with(**changes): + """The H entry with fields replaced (value None removes the field).""" + fields = dict(_H) + for k, v in changes.items(): + if v is None: + fields.pop(k) + else: + fields[k] = v + return {'H': fields} + + +class TestValidFile: + """A well-formed file loads with typed fields.""" + + def test_tagawa_entry_loads_with_numbers_and_ranges(self, tmp_path): + """Every coefficient is a float, the ranges are pairs and the meta table is kept.""" + table = load_partition_table(_write(tmp_path, _text())) + h = table['H'] + assert h['a'] == pytest.approx(2.42) + assert h['c'] == pytest.approx(-32.0) + assert h['P_range_GPa'] == (pytest.approx(30.0), pytest.approx(60.0)) + assert h['eps'] == {} + assert table['meta']['placeholder'] is True + assert set(table) == {'schema_version', 'meta', 'H'} + + def test_interaction_terms_and_matching_covariance_load(self, tmp_path): + """Two eps terms need a 7 x 7 covariance in the order a, b, c, d, e, eps by name.""" + cov = [[1.0 if i == j else 0.0 for j in range(7)] for i in range(7)] + entry = _with(eps='{ S = 9.6, O = -19.5 }', covariance=str(cov)) + table = load_partition_table(_write(tmp_path, _text(entry))) + assert table['H']['eps'] == {'O': pytest.approx(-19.5), 'S': pytest.approx(9.6)} + assert len(table['H']['covariance']) == 7 + + def test_empty_range_means_an_unknown_range(self, tmp_path): + """An empty list is a range the source does not give: it loads as None.""" + table = load_partition_table( + _write(tmp_path, _text(_with(T_range_K='[]', dIW_range='[]'))) + ) + assert table['H']['T_range_K'] is None + assert table['H']['dIW_range'] is None + assert table['H']['P_range_GPa'] == (pytest.approx(30.0), pytest.approx(60.0)) + + def test_packaged_placeholder_covers_every_element(self): + """The default file is the placeholder set, flagged as such, with all 6 elements.""" + table = load_partition_table() + assert table['meta']['placeholder'] is True + assert set(ELEMENTS) <= set(table) + assert ELEMENTS == ('H', 'C', 'N', 'S', 'O', 'Si') + for el in ELEMENTS: + assert table[el]['source'] and math.isfinite(table[el]['a']) + + +class TestMalformedFile: + """Each malformed field raises PartitionTableError with the element and the field.""" + + @pytest.mark.parametrize( + 'entry, field', + [ + (_with(a='true'), 'a'), + (_with(b='"-2892"'), 'b'), + (_with(c='nan'), 'c'), + (_with(d='inf'), 'd'), + (_with(e=None), 'e'), + (_with(dIW_factor='-inf'), 'dIW_factor'), + (_with(form='"power_law"'), 'form'), + (_with(quantity='"Kd"'), 'quantity'), + (_with(basis='"molar"'), 'basis'), + (_with(silicate_basis=None), 'silicate_basis'), + (_with(silicate_basis='"oxide"'), 'silicate_basis'), + (_with(P_range_GPa='[60.0, 30.0]'), 'P_range_GPa'), + (_with(T_range_K='[3100.0, nan]'), 'T_range_K'), + (_with(dIW_range='[-1.0]'), 'dIW_range'), + (_with(P_unit='"Pa"'), 'P_unit'), + (_with(eps='{ Xe = 1.0 }'), 'eps'), + (_with(eps='{ S = true }'), 'eps'), + (_with(covariance='[[1.0, 0.0], [0.0, 1.0]]'), 'covariance'), + (_with(covariance='[[1.0, 2.0, 0.0, 0.0, 0.0]]'), 'covariance'), + (_with(covariance='[1.0, 2.0]'), 'covariance'), + (_with(covariance='[[1.0, 0.0], [0.0]]'), 'covariance'), + (_with(source='""'), 'source'), + ], + ids=[ + 'bool_coefficient', + 'string_coefficient', + 'nan_coefficient', + 'inf_coefficient', + 'missing_coefficient', + 'infinite_diw_factor', + 'unknown_form', + 'unknown_quantity', + 'unknown_basis', + 'mole_basis_without_silicate_basis', + 'unknown_silicate_basis', + 'reversed_pressure_range', + 'nan_temperature_range', + 'short_diw_range', + 'pressure_in_pa', + 'eps_unknown_element', + 'eps_bool', + 'covariance_wrong_size', + 'covariance_not_square', + 'covariance_flat_list', + 'covariance_ragged', + 'empty_source', + ], + ) + def test_malformed_field_raises_with_its_name(self, tmp_path, entry, field): + """The error names the element and the field, and is a ValueError.""" + with pytest.raises(PartitionTableError) as exc: + load_partition_table(_write(tmp_path, _text(entry))) + assert "'H'" in str(exc.value) and f"'{field}'" in str(exc.value) + assert isinstance(exc.value, ValueError) + + @pytest.mark.parametrize( + 'el, changes, field', + [ + ('H', {'form': '"exchange_KD"'}, 'form'), + ('O', {'form': '"log10_linear"', 'quantity': '"D"', 'basis': '"mass"'}, 'form'), + ('O', {'quantity': '"D"'}, 'quantity'), + ('Si', {'basis': '"mass"'}, 'basis'), + ('Si', {'basis': '"mole_xprime_noHC"'}, 'basis'), + ('O', {'d': '0.5'}, 'd'), + ('O', {'e': '-0.1'}, 'e'), + ('Si', {'eps': '{ O = -5.0 }'}, 'eps'), + ('O', {'dIW_factor': '2.0'}, 'dIW_factor'), + ], + ids=[ + 'H_exchange_form', + 'O_linear_form', + 'exchange_quantity_D', + 'exchange_basis_mass', + 'exchange_basis_xprime', + 'exchange_d_term', + 'exchange_e_term', + 'exchange_eps_term', + 'exchange_diw_factor', + ], + ) + def test_unread_field_values_are_refused(self, tmp_path, el, changes, field): + """A value the evaluator of the element does not read is refused, naming the field.""" + exchange = { + **_H, + 'form': '"exchange_KD"', + 'quantity': '"K_D"', + 'basis': '"mole"', + 'a': '0.986', + 'b': '-3275.0', + 'c': '0.0', + 'd': '0.0', + } + entry = dict(_H if el == 'H' else exchange) + entry.update(changes) + with pytest.raises(PartitionTableError) as exc: + load_partition_table(_write(tmp_path, _text({el: entry}))) + assert f"'{el}'" in str(exc.value) and f"'{field}'" in str(exc.value) + # the unchanged entry of the same element loads + base = _H if el == 'H' else exchange + assert load_partition_table(_write(tmp_path, _text({el: base})))[el]['form'] + + def test_evaluators_refuse_an_entry_of_the_other_form(self): + """A table built in code bypasses the loader: mass_D and exchange_metal check the form.""" + table = load_partition_table() + exchange_h = dict(table['H'], form='exchange_KD') + with pytest.raises(PartitionTableError, match="'form'"): + mass_D(exchange_h, 50.0, 3500.0, -2.0, {}, {}) + linear_o = dict(table, O=dict(table['O'], form='log10_linear')) + with pytest.raises(PartitionTableError, match="'form'"): + exchange_metal(linear_o, 50.0, 3500.0, -2.0) + assert mass_D(table['H'], 50.0, 3500.0, -2.0, {}, {}) > 0 + assert 0 < exchange_metal(table, 50.0, 3500.0, -2.0, elements=('Si',))['Si'] < 1 + + def test_element_entry_must_be_a_table(self, tmp_path): + """An element given as a value instead of a table is refused with its name.""" + text = 'schema_version = 1\nH = 3.0\n[meta]\nname = "x"\nplaceholder = true\n' + with pytest.raises(PartitionTableError) as exc: + load_partition_table(_write(tmp_path, text)) + assert "'H'" in str(exc.value) and 'table' in str(exc.value) + assert isinstance(exc.value, ValueError) + + @pytest.mark.parametrize( + 'cov, reason', + [ + ( + [ + [1.0, 0.5, 0, 0, 0], + [0.4, 1.0, 0, 0, 0], + [0, 0, 1.0, 0, 0], + [0, 0, 0, 1.0, 0], + [0, 0, 0, 0, 1.0], + ], + 'symmetric', + ), + ( + [ + [1.0, 2.0, 0, 0, 0], + [2.0, 1.0, 0, 0, 0], + [0, 0, 1.0, 0, 0], + [0, 0, 0, 1.0, 0], + [0, 0, 0, 0, 1.0], + ], + 'semidefinite', + ), + ], + ids=['asymmetric', 'negative_eigenvalue'], + ) + def test_covariance_must_be_a_covariance(self, tmp_path, cov, reason): + """An asymmetric matrix or one with a negative eigenvalue (here -1) is refused.""" + with pytest.raises(PartitionTableError) as exc: + load_partition_table(_write(tmp_path, _text(_with(covariance=str(cov))))) + assert reason in str(exc.value) + assert "'covariance'" in str(exc.value) + + @pytest.mark.parametrize( + 'header, message', + [ + ('', 'schema_version'), + ('schema_version = 2', 'schema_version'), + ('schema_version = true', 'schema_version'), + ('schema_version = 1.0', 'schema_version'), + ], + ids=['missing_version', 'future_version', 'bool_version', 'float_version'], + ) + def test_schema_version_must_be_one(self, tmp_path, header, message): + """Only schema version 1 (an integer, not a boolean) is read.""" + with pytest.raises(PartitionTableError) as exc: + load_partition_table(_write(tmp_path, _text(header=header))) + assert message in str(exc.value) + assert load_partition_table(_write(tmp_path, _text()))['schema_version'] == 1 + + def test_unknown_element_section_and_missing_meta_raise(self, tmp_path): + """A section that is no element is refused, as is a file without its meta table.""" + with pytest.raises(PartitionTableError, match="'Xe'"): + load_partition_table(_write(tmp_path, _text({'Xe': _H}))) + with pytest.raises(PartitionTableError, match='meta'): + load_partition_table(_write(tmp_path, 'schema_version = 1\n[H]\na = 1.0\n')) + + def test_meta_needs_a_name(self, tmp_path): + """A [meta] table without a non-empty name or without its placeholder flag is refused.""" + text = _text().replace('name = "test"\n', '') + with pytest.raises(PartitionTableError, match='name'): + load_partition_table(_write(tmp_path, text)) + no_flag = _text().replace('placeholder = true\n', '') + with pytest.raises(PartitionTableError, match='placeholder'): + load_partition_table(_write(tmp_path, no_flag)) + + @pytest.mark.parametrize('key', ['esp', 'f']) + def test_unknown_key_in_an_entry_is_refused(self, tmp_path, key): + """A key outside the schema (a misspelt eps, an extra coefficient) is refused.""" + entry = dict(_H, **{key: '{ O = 1.0 }' if key == 'esp' else '7.0'}) + with pytest.raises(PartitionTableError) as exc: + load_partition_table(_write(tmp_path, _text({'H': entry}))) + assert repr(key) in str(exc.value) and "'H'" in str(exc.value) + + @pytest.mark.parametrize( + 'field, pair', [('P_range_GPa', '[-30.0, 60.0]'), ('T_range_K', '[-5.0, 4560.0]')] + ) + def test_negative_pressure_or_temperature_range_is_refused(self, tmp_path, field, pair): + """P and T ranges start at 0 or above; a negative dIW range is valid.""" + with pytest.raises(PartitionTableError, match=field): + load_partition_table(_write(tmp_path, _text(_with(**{field: pair})))) + table = load_partition_table(_write(tmp_path, _text(_with(dIW_range='[-3.0, -1.0]')))) + assert table['H']['dIW_range'] == (-3.0, -1.0) + + def test_missing_file_raises_file_not_found(self, tmp_path): + """A path that does not exist is an OSError, not a malformed table.""" + with pytest.raises(FileNotFoundError): + load_partition_table(tmp_path / 'none.toml') + assert not (tmp_path / 'none.toml').exists() + + +@settings(derandomize=True, max_examples=100, deadline=None) +@given(st.floats(allow_nan=True, allow_infinity=True)) +def test_any_float_coefficient_loads_only_when_finite(tmp_path_factory, value): + """Over every float, a coefficient loads exactly when it is finite.""" + path = tmp_path_factory.mktemp('h') / 'partition.toml' + path.write_text(_text(_with(a=repr(value)))) + if math.isfinite(value): + assert load_partition_table(path)['H']['a'] == value + else: + with pytest.raises(PartitionTableError, match="'a'"): + load_partition_table(path) + + +class TestLaw: + """The log10 D law of an entry and its range flag.""" + + @pytest.mark.physics_invariant + @pytest.mark.reference_pinned + @pytest.mark.parametrize( + 'p_gpa, t_k, expected', + [(20.0, 3000.0, 1.2427), (135.0, 4000.0, 0.6170), (300.0, 5000.0, -0.0784)], + ) + def test_tagawa_eq2_values(self, p_gpa, t_k, expected): + """Tagawa et al. 2021 Eq. 2: log10 K_D = 2.42 - 2892/T - 32.0 P/T (dIW 0), 4 decimals.""" + h = load_partition_table()['H'] + val = log10_D(h, p_gpa, t_k, 0.0) + assert val == pytest.approx(expected, abs=5e-5) + # the dIW term is -dIW/4: one log unit more reduced raises log10 D by 0.25 + assert log10_D(h, p_gpa, t_k, -1.0) - val == pytest.approx(0.25, abs=1e-12) + # Tagawa Eq. 10 (0.692 - 4590/T + 141 P/T) differs by more than 0.3 at each point + assert abs(val - (0.692 - 4590 / t_k + 141 * p_gpa / t_k)) > 0.3 + + @pytest.mark.physics_invariant + @pytest.mark.reference_pinned + def test_fischer_eq1_single_stage_value(self): + """Fischer et al. 2020 p3: Eq. 1 gives D_C = 0.550 at 54 GPa, 4100 K, NBO/T 2.6, IW-2.2, X_O 0.06, X_S 0.03.""" + c = load_partition_table()['C'] + d_c = 10 ** log10_D(c, 54.0, 4100.0, -2.2, x_metal={'O': 0.06, 'S': 0.03}, nbo_t=2.6) + assert d_c == pytest.approx(0.550, abs=5e-4) + # Suer SA prints the dIW coefficient as 0.328: that law lands far outside + wrong = 10 ** (log10_D(c, 54.0, 4100.0, -2.2, {'O': 0.06, 'S': 0.03}, 2.6) + 0.09 * 2.2) + assert abs(wrong - d_c) > 0.1 + # without the O and S terms D_C is 0.447 + assert 10 ** log10_D(c, 54.0, 4100.0, -2.2, nbo_t=0.0) == pytest.approx(0.447, abs=2e-3) + + @pytest.mark.physics_invariant + @pytest.mark.reference_pinned + @pytest.mark.parametrize( + 'el, p_gpa, t_k, d_lo, d_hi', + [('N', 20.0, 3000.0, 1.0, 39.0), ('S', 60.0, 4000.0, 10.0, 173.0)], + ) + def test_suer_lines(self, el, p_gpa, t_k, d_lo, d_hi): + """Suer et al. 2023 Table 1: D_N 1 and 39, D_S 10 and 173 at dIW -4 and -1.""" + entry = load_partition_table()[el] + assert 10 ** log10_D(entry, p_gpa, t_k, -4.0) == pytest.approx(d_lo, rel=1e-5) + assert 10 ** log10_D(entry, p_gpa, t_k, -1.0) == pytest.approx(d_hi, rel=1e-5) + + def test_diw_factor_multiplies_the_diw_term(self): + """dIW_factor 2 doubles the d dIW term.""" + entry = load_partition_table()['N'] + one = log10_D(entry, 20.0, 3000.0, -2.0) + two = log10_D(dict(entry, dIW_factor=2.0), 20.0, 3000.0, -2.0) + assert two - one == pytest.approx(entry['d'] * -2.0, rel=1e-12) + zero = log10_D(dict(entry, dIW_factor=0.0), 20.0, 3000.0, -2.0) + assert zero == pytest.approx(one + 2.0 * entry['d'], rel=1e-12) + + def test_out_of_range_flags_each_axis_and_unknown_ranges(self): + """A point outside any range, or on an axis whose range is unknown, is flagged.""" + table = load_partition_table() + h, o = table['H'], table['O'] + assert not out_of_range(h, 45.0, 4000.0, -1.0) + assert out_of_range(h, 135.0, 4000.0, -1.0) + assert out_of_range(h, 45.0, 2500.0, -1.0) + assert out_of_range(h, 45.0, 4000.0, -2.3) + assert out_of_range(o, 50.0, 4000.0, -2.0) + + def test_non_finite_condition_raises(self): + """A NaN pressure, a non-positive temperature or a metal fraction of 1 is refused.""" + c = load_partition_table()['C'] + for args in ((math.nan, 4000.0, -2.0), (50.0, 0.0, -2.0), (50.0, 4000.0, math.inf)): + with pytest.raises(ValueError): + log10_D(c, *args) + with pytest.raises(ValueError, match='X_O'): + log10_D(c, 50.0, 4000.0, -2.0, x_metal={'O': 1.0}) + + +class TestBasis: + """Molar D on the H- and C-free metal basis converted to core mass fractions.""" + + @pytest.mark.physics_invariant + @pytest.mark.reference_pinned + @pytest.mark.parametrize( + 'd_h, d_c, w_o, w_si, w_s, core_h, core_c', + [ + (92, 67, 0.00, 3.43, 2.00, 0.68, 0.75), + (56, 96, 2.52, 4.42, 2.00, 0.45, 1.15), + (44, 107, 1.49, 7.78, 2.00, 0.35, 1.29), + ], + ids=['wade_wood_40gpa', 'siebert_50gpa', 'fischer_54gpa'], + ) + def test_tsutsumi_single_stage_core(self, d_h, d_c, w_o, w_si, w_s, core_h, core_c): + """Tsutsumi et al. 2025 SI Table 1: BSE 710 ppm H2O, 120 ppm C, KLB-1 melt (Takahashi 1986).""" + w_melt = {'H': 710e-6 * 2 * 1.008 / 18.01528, 'C': 120e-6} + fixed = {'O': w_o / 100, 'Si': w_si / 100, 'S': w_s / 100} + w = core_light_elements({'H': d_h, 'C': d_c}, w_melt, fixed) + assert round(100 * w['H'], 2) == pytest.approx(core_h, abs=1e-9) + assert round(100 * w['C'], 2) == pytest.approx(core_c, abs=1e-9) + assert 0 < w['H'] < 0.01 and 0 < w['C'] < 0.02 + + def test_plain_mass_d_misses_the_wade_wood_case(self): + """Without the basis conversion, D times the melt H gives 0.73 wt%, not 0.68 (40 GPa case).""" + w_melt = {'H': 710e-6 * 2 * 1.008 / 18.01528, 'C': 120e-6} + plain = 92 * w_melt['H'] * 100 + w = core_light_elements({'H': 92, 'C': 67}, w_melt, {'Si': 0.0343, 'S': 0.02}) + assert round(plain, 2) != pytest.approx(0.68, abs=1e-9) + assert abs(plain / (100 * w['H']) - 1) > 0.06 + + def test_elements_outside_their_roles_are_refused(self): + """D_x is for H and C, the fixed fractions for O, Si, S and N; anything else raises.""" + w_melt = {'H': 7.94e-5, 'C': 1.2e-4} + for d_x, fixed in ( + ({'H': 50, 'N': 5}, {}), + ({'H': 50}, {'C': 0.01}), + ({}, {'H': 0.01}), + ): + with pytest.raises(ValueError, match='d_x takes H and C'): + core_light_elements(d_x, w_melt, fixed) + # the edge: every allowed role filled is accepted and leaves iron + w = core_light_elements( + {'H': 50, 'C': 50}, w_melt, {'O': 0.01, 'Si': 0.01, 'S': 0.01, 'N': 0.01} + ) + assert 0 < w['H'] < 0.01 and 0 < w['C'] < 0.01 + + def test_lighter_metal_holds_more_h_and_c_at_fixed_mole_ratio(self): + """O and Si are lighter than Fe: a kg of metal holds more H- and C-free moles, so more H.""" + w_melt = {'H': 7.94e-5, 'C': 1.2e-4} + clean = core_light_elements({'H': 50, 'C': 50}, w_melt, {}) + loaded = core_light_elements({'H': 50, 'C': 50}, w_melt, {'O': 0.05, 'Si': 0.05}) + assert loaded['H'] > clean['H'] and loaded['C'] > clean['C'] + # the ratio is the moles per kg of the H- and C-free metal, about 1.17 here + assert loaded['H'] / clean['H'] == pytest.approx(loaded['C'] / clean['C'], rel=1e-3) + with pytest.raises(ValueError, match='no iron'): + core_light_elements({'H': 50, 'C': 50}, w_melt, {'O': 0.6, 'Si': 0.5}) + + +class TestMetalReservoir: + """The metal term of the element balance: core_i = D_i M_metal w_i(melt).""" + + @pytest.mark.physics_invariant + @pytest.mark.reference_pinned + @pytest.mark.parametrize('d', [0.1, 1.0, 10.0, 30.0, 100.0]) + def test_gas_free_core_fraction_is_dx_over_one_plus_dx(self, d): + """Melt and metal alone: the core holds f = Dx/(1 + Dx) of an element, x = 0.325/0.675.""" + m_planet, x = 6e24, 0.325 / 0.675 + ddict = { + 'M_mantle': 0.675 * m_planet, + 'Phi_global': 1.0, + 'metal_mass': 0.325 * m_planet, + } + total = 1e20 + melt = total / (1 + d * x) # the gas-free balance melt + core = total + core = metal_masses({'H': melt}, ddict, {'H': d})['H'] + f = core / (melt + core) + assert f == pytest.approx(d * x / (1 + d * x), rel=1e-12) + assert melt + core == pytest.approx(total, rel=1e-12) + # f = Dx (no melt back-reaction) would exceed the right value by a factor 1 + Dx + if d >= 1.0: + assert abs(f - d * x) > 0.3 * f + + def test_no_metal_gives_no_core_term_and_no_melt_raises(self): + """metal_mass 0 gives zero core masses; metal with Phi_global 0 has no melt to equilibrate.""" + ddict = {'M_mantle': 4e24, 'Phi_global': 1.0, 'metal_mass': 0.0} + assert metal_masses({'H': 1e20, 'C': 1e19}, ddict, {'H': 50.0, 'C': 5.0}) == { + 'H': 0.0, + 'C': 0.0, + } + ddict.update(metal_mass=2e24, Phi_global=0.0) + with pytest.raises(ValueError, match='melt'): + metal_masses({'H': 1e20}, ddict, {'H': 50.0}) + ddict.update(Phi_global=0.5) + with pytest.raises(ValueError, match='D'): + metal_masses({'H': 1e20}, ddict, {'H': -1.0}) + for bad in (-1.0, math.nan): + with pytest.raises(ValueError, match='metal_mass'): + metal_masses({'H': 1e20}, {**ddict, 'metal_mass': bad}, {'H': 50.0}) + + +class TestMassD: + """The mass-basis D that the element balance uses.""" + + @staticmethod + def _const(log10_d, basis): + entry = dict(load_partition_table()['H']) + entry.update(a=log10_d, b=0.0, c=0.0, d=0.0, e=0.0, eps={}, basis=basis) + return entry + + @pytest.mark.physics_invariant + def test_xprime_basis_reproduces_the_closed_form_core(self): + """On the self-consistent metal, D_w times the melt H equals the closed-form core H.""" + w_melt = {'H': 710e-6 * 2 * 1.008 / 18.01528, 'C': 120e-6} + fixed = {'Si': 0.0343, 'S': 0.02} + core = core_light_elements({'H': 92, 'C': 67}, w_melt, fixed) + w_metal = {**fixed, **core} + d_w = mass_D( + self._const(math.log10(92), 'mole_xprime_noHC'), 45.0, 3750.0, -2.3, w_metal, w_melt + ) + assert d_w * w_melt['H'] == pytest.approx(core['H'], rel=1e-12) + # the conversion factor is not 1: plain D would put 7 % more H in the core + assert abs(92 / d_w - 1) > 0.06 + + def test_mass_basis_is_the_law_itself(self): + """A mass-basis entry gives 10**log10_D whatever the metal and melt.""" + entry = self._const(1.5, 'mass') + d_w = mass_D(entry, 45.0, 3750.0, -2.3, {'O': 0.05}, {'H': 1e-4}) + assert d_w == pytest.approx(10**1.5, rel=1e-15) + assert mass_D(entry, 45.0, 3750.0, -2.3, {}, {}) == pytest.approx(d_w, rel=1e-15) + + @pytest.mark.physics_invariant + def test_eps_terms_take_x_over_the_h_and_c_free_metal(self): + """X_O, X_S of the C law are mole fractions over the metal without H and C (x').""" + from calliope.constants import molar_mass, molar_mass_rock + from calliope.metal import KLB1 + + c = load_partition_table()['C'] + w = {'H': 0.003, 'C': 1e-4, 'O': 0.05, 'S': 0.02} + n = {el: w[el] / molar_mass[el] for el in w} + free = (1 - sum(w.values())) / molar_mass_rock['Fe'] + n['O'] + n['S'] + x = {el: n[el] / free for el in ('O', 'S')} + expected = 10 ** log10_D(c, 50.0, 3500.0, -2.0, x, 2.6) + got = mass_D(c, 50.0, 3500.0, -2.0, w, {}, KLB1, 2.6) + assert got == pytest.approx(expected, rel=1e-12) + # H and C in the denominator dilute X_O, and eps_O = -19.5 then lowers D_C + every = free + n['H'] + n['C'] + diluted = 10 ** log10_D(c, 50.0, 3500.0, -2.0, {el: n[el] / every for el in x}, 2.6) + assert got / diluted > 1.3 + + def test_mole_basis_counts_every_metal_element(self): + """A 'mole' entry converts with the moles of the whole metal, H included.""" + from calliope.constants import molar_mass, molar_mass_rock + + w = {'H': 0.003, 'O': 0.05} + n_fe = (1 - sum(w.values())) / molar_mass_rock['Fe'] + free = n_fe + w['O'] / molar_mass['O'] + args = (45.0, 3750.0, -2.3, w, {'H': 1e-4}) + ratio = mass_D(self._const(1.0, 'mole'), *args) / mass_D( + self._const(1.0, 'mole_xprime_noHC'), *args + ) + assert ratio == pytest.approx((free + w['H'] / molar_mass['H']) / free, rel=1e-12) + assert ratio > 1.1 + + def test_nbo_t_defaults_to_the_documented_2_6(self): + """mass_D and log10_D without NBO/T use 2.6, the solve default.""" + c = load_partition_table()['C'] + args = (50.0, 3500.0, -2.0) + assert log10_D(c, *args) == log10_D(c, *args, nbo_t=2.6) + w = {'O': 0.05, 'S': 0.02} + assert mass_D(c, *args, w, {}) == mass_D(c, *args, w, {}, nbo_t=2.6) + + def test_law_that_overflows_raises_a_named_value_error(self): + """A D or K_D beyond the float range raises ValueError with the conditions.""" + table = load_partition_table() + with pytest.raises(ValueError, match='H law: .* dIW -2000 overflows'): + mass_D(table['H'], 50.0, 3500.0, -2000.0, {}, {}) + # log10 D_N = 2.12 + 0.530 dIW = 310.3, just above the float range + with pytest.raises(ValueError, match='N law: 10\\*\\*310'): + mass_D(table['N'], 20.0, 3000.0, 581.0, {}, {}) + with pytest.raises(ValueError, match='O exchange: .* overflows'): + exchange_metal(table, 50.0, 3500.0, 2000.0) + # each factor of x_O stays below 1e308, their product does not + with pytest.raises(ValueError, match='exchange is not finite .* dIW 615.9'): + exchange_metal(table, 50.0, 5000.0, 615.9, elements=('O',), check=False) + + def test_metal_with_no_iron_raises(self): + """Light elements that sum to 1 leave no iron for the metal basis.""" + entry = self._const(1.0, 'mole_xprime_noHC') + with pytest.raises(ValueError, match='no iron'): + mass_D(entry, 45.0, 3750.0, -2.3, {'O': 0.6, 'Si': 0.4}, {'H': 1e-4}) + assert mass_D(entry, 45.0, 3750.0, -2.3, {'O': 0.1}, {'H': 1e-4}) > 0 + + +class TestExchange: + """O and Si in the metal from the exchange K_D of FeO = Fe + O and SiO2 + 2 Fe = Si + 2 FeO.""" + + @pytest.mark.physics_invariant + @pytest.mark.reference_pinned + def test_ideal_exchange_mole_fractions(self): + """x_O = K_D 10^(dIW/2) and x_Si = K_D x_SiO2 10^(-dIW), Tagawa SI Table 3 (refs. 2, 57).""" + table = load_partition_table() + t_k, d_iw = 4000.0, -2.0 + x = exchange_metal(table, 50.0, t_k, d_iw) + k_o = 10 ** (0.986 - 3275.0 / t_k) + k_si = 10 ** (0.549 - 12324.0 / t_k) + x_sio2 = (44.48 / 60.083) / 1.96965 # KLB-1 single-cation basis, mol per 100 g + assert x['O'] == pytest.approx(k_o * 10 ** (d_iw / 2), rel=1e-12) + assert x['Si'] == pytest.approx(k_si * x_sio2 * 10 ** (-d_iw), rel=1e-3) + # read as the K of the half reaction, K_D is squared: x_Si about 3e-4, not 0.11 + assert x['Si'] > 100 * k_si**2 * x_sio2 * 10 ** (-d_iw) + + def test_more_oxidised_metal_takes_more_o_and_less_si(self): + """Raising dIW by 1 multiplies x_O by 10^0.5 and x_Si by 10^-1; too much raises.""" + table = load_partition_table() + lo = exchange_metal(table, 50.0, 4000.0, -2.0) + hi = exchange_metal(table, 50.0, 4000.0, -1.0) + assert hi['O'] / lo['O'] == pytest.approx(10**0.5, rel=1e-12) + assert hi['Si'] / lo['Si'] == pytest.approx(0.1, rel=1e-12) + with pytest.raises(ValueError, match='no iron'): + exchange_metal(table, 50.0, 4000.0, -6.0) + + def test_one_partitioned_element_ignores_the_other_law(self): + """With only O partitioned the Si law is not evaluated, so a reduced dIW passes.""" + table = load_partition_table() + both = exchange_metal(table, 50.0, 4000.0, -2.0) + only_o = exchange_metal(table, 50.0, 4000.0, -2.0, elements=('O',)) + assert set(only_o) == {'O'} + assert only_o['O'] == pytest.approx(both['O'], rel=1e-15) + # at dIW -8 the Si law alone exceeds 1, the O law gives a small x_O + assert exchange_metal(table, 50.0, 4000.0, -8.0, elements=('O',))['O'] < 1e-3 + with pytest.raises(ValueError, match='no iron'): + exchange_metal(table, 50.0, 4000.0, -8.0, elements=('Si',)) + + +# Tagawa et al. 2021 SI Table 1: P [GPa], T [K], dIW, measured log10 K_D and its 1 sigma +TAGAWA_RUNS = [ + (46.0, 3920.0, -0.88, 1.32, 0.06), + (48.0, 3450.0, -0.99, 1.10, 0.06), + (57.0, 3860.0, -1.08, 1.22, 0.05), + (60.0, 4560.0, -1.17, 1.37, 0.05), + (47.0, 4230.0, -1.27, 1.35, 0.06), + (30.0, 3080.0, -1.33, 1.18, 0.06), +] + + +@pytest.mark.physics_invariant +@pytest.mark.reference_pinned +def test_tagawa_runs_lie_within_one_sigma_of_eq2(validation_plot): + """Tagawa et al. 2021 SI Table 1: each of the 6 runs within its 1 sigma of the H law.""" + h = load_partition_table()['H'] + fit = [log10_D(h, p, t, 0.0) for p, t, _, _, _ in TAGAWA_RUNS] + ratio = [(m - f) / s for (_, _, _, m, s), f in zip(TAGAWA_RUNS, fit)] + assert max(abs(r) for r in ratio) <= 1.0 + # Tagawa Eq. 10 (fit with the multi-anvil data) misses every run by more than 2 sigma + eq10 = [0.692 - 4590 / t + 141 * p / t for p, t, _, _, _ in TAGAWA_RUNS] + assert min(abs(m - e) / s for (_, _, _, m, s), e in zip(TAGAWA_RUNS, eq10)) > 2.0 + # the ranges of the H entry are those of the 6 runs, so every run is in range + assert not any(out_of_range(h, p, t, d) for p, t, d, _, _ in TAGAWA_RUNS) + assert out_of_range(h, 30.0, 3079.0, -1.33) and out_of_range(h, 60.0, 4561.0, -1.17) + + def figure(): + import matplotlib.pyplot as plt + + meas = [r[3] for r in TAGAWA_RUNS] + fig, ax = plt.subplots() + ax.errorbar(fit, meas, yerr=[r[4] for r in TAGAWA_RUNS], fmt='o', label='runs #1-#6') + ax.plot([1.0, 1.5], [1.0, 1.5], ls='--', color=plt.rcParams['text.color'], label='1:1') + ax.set(xlabel=r'Eq. 2 $\log_{10} K_D$', ylabel=r'measured $\log_{10} K_D$') + ax.legend() + fig.tight_layout() + return fig + + validation_plot(figure, 'metal_tagawa2021_h_runs') + + +@pytest.mark.physics_invariant +@pytest.mark.reference_pinned +def test_gas_free_core_fraction_curve(validation_plot): + """The core term on a D grid lies on Dx/(1 + Dx); f = Dx lies more than 10 % above it.""" + x = 0.325 / 0.675 + ddict = {'M_mantle': 0.675, 'Phi_global': 1.0, 'metal_mass': 0.325} + ds = [0.1, 0.3, 1.0, 3.0, 10.0, 30.0, 100.0] + f = [] + for d in ds: + melt = 1.0 / (1 + d * x) + core = metal_masses({'H': melt}, ddict, {'H': d})['H'] + f.append(core / (melt + core)) + assert f == pytest.approx([d * x / (1 + d * x) for d in ds], rel=1e-12) + # f = Dx overshoots by (Dx)^2/(1 + Dx): more than 10 % of f from D = 0.3 on + assert all(d * x - fi > 0.1 * fi for fi, d in zip(f[1:], ds[1:])) + + def figure(): + import matplotlib.pyplot as plt + + grid = [10 ** (k / 20) for k in range(-25, 46)] + fig, ax = plt.subplots() + ax.plot(grid, [d * x / (1 + d * x) for d in grid], label=r'$Dx/(1+Dx)$') + ax.plot( + grid, [min(d * x, 1.0) for d in grid], ls='--', label=r'$Dx$ (no back-reaction)' + ) + ax.plot(ds, f, 'o', label='metal_masses') + ax.set(xscale='log', xlabel='D (metal / melt, mass)', ylabel='core fraction') + ax.minorticks_off() + ax.legend() + fig.tight_layout() + return fig + + validation_plot(figure, 'metal_gas_free_core_fraction') + + +@pytest.mark.physics_invariant +@pytest.mark.reference_pinned +def test_tsutsumi_core_values_plot(validation_plot): + """Core H and C of the 3 Tsutsumi et al. 2025 single-stage cases against SI Table 1.""" + cases = [ + ((92, 67), (0.00, 3.43), (0.68, 0.75)), + ((56, 96), (2.52, 4.42), (0.45, 1.15)), + ((44, 107), (1.49, 7.78), (0.35, 1.29)), + ] + w_melt = {'H': 710e-6 * 2 * 1.008 / 18.01528, 'C': 120e-6} + got = [] + for (d_h, d_c), (o, si), _ in cases: + w = core_light_elements( + {'H': d_h, 'C': d_c}, w_melt, {'O': o / 100, 'Si': si / 100, 'S': 0.02} + ) + got.append((100 * w['H'], 100 * w['C'])) + for (_, _, paper), (h, c) in zip(cases, got): + assert abs(h - paper[0]) <= 0.005 + 1e-9 + assert abs(c - paper[1]) <= 0.005 + 1e-9 + # D times the melt H without the basis conversion misses the 40 GPa case (0.73 wt%) + assert abs(100 * 92 * w_melt['H'] - cases[0][2][0]) > 0.005 + + def figure(): + import matplotlib.pyplot as plt + + fig, ax = plt.subplots() + labels = ['40 GPa', '50 GPa', '54 GPa'] + for k, (name, marker) in enumerate((('H', 'o'), ('C', 's'))): + ax.plot( + labels, + [c[2][k] for c in cases], + marker, + mfc='none', + ms=10, + label=f'{name}, SI Table 1', + ) + ax.plot(labels, [g[k] for g in got], marker, ms=4, label=f'{name}, CALLIOPE') + ax.set(ylabel='core mass fraction [wt%]') + ax.legend(fontsize='small') + fig.tight_layout() + return fig + + validation_plot(figure, 'metal_tsutsumi2025_core_h_c') diff --git a/tests/test_metal_fields.py b/tests/test_metal_fields.py new file mode 100644 index 00000000..4147986a --- /dev/null +++ b/tests/test_metal_fields.py @@ -0,0 +1,168 @@ +"""Tests for the metal-reservoir fields of the CALLIOPE input dict (`src/calliope/solve.py`). + +Contract: each field that the metal reservoir reads is checked before the first solve; +a boolean (a TOML true), a string, NaN, inf, or a value outside its range raises +ValueError naming the field, and the defaults and the KLB-1 melt pass. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from calliope.metal import KLB1 +from calliope.solve import check_metal_fields + +hypothesis = pytest.importorskip('hypothesis') +from hypothesis import given, settings # noqa: E402 +from hypothesis import strategies as st # noqa: E402 + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + +GOOD = { + 'metal_mass': 1.9e24, + 'P_eq': 50.0, + 'T_eq': 3500.0, + 'dIW_eq': -2.0, + 'silicate_nbo_t': 2.6, + 'silicate_oxides': dict(KLB1), + 'w_O_core': 0.0, + 'w_Si_core': 0.02, + 'tol_w_core': 1e-6, +} + +# field -> values that must raise; bounds: P_eq, T_eq > 0, metal_mass >= 0, w in [0, 1) +BAD = { + 'metal_mass': [True, 'x', math.nan, math.inf, -1.0], + 'P_eq': [True, math.nan, math.inf, 0.0, -5.0, 10**400], + 'T_eq': [False, math.nan, -math.inf, 0.0, -1.0], + 'dIW_eq': [True, 'IW-2', math.nan, math.inf], + 'silicate_nbo_t': [True, math.nan, math.inf, -0.1], + 'w_O_core': [True, math.nan, -0.01, 1.0], + 'w_Si_core': [math.inf, -1e-9, 1.5], + # from 1 on, the relative stop holds on the first pass + 'tol_w_core': [True, math.nan, 0.0, -1e-6, 1.0, 5.0], +} + + +class TestMetalFields: + """Field checks of the metal reservoir.""" + + def test_good_fields_and_klb1_pass(self): + """The fields above, with the KLB-1 melt (99.29 wt%), pass the check.""" + assert check_metal_fields(dict(GOOD)) is None + assert sum(KLB1.values()) == pytest.approx(99.29, abs=1e-9) + + @pytest.mark.parametrize( + 'field, value', + [(f, v) for f, vals in BAD.items() for v in vals], + ids=lambda v: repr(v)[:24], + ) + def test_bad_value_raises_naming_the_field(self, field, value): + """Each wrong type or out-of-range value raises ValueError with the field name.""" + with pytest.raises(ValueError) as exc: + check_metal_fields({**GOOD, field: value}) + assert field in str(exc.value) + assert check_metal_fields(dict(GOOD)) is None + + @pytest.mark.parametrize( + 'oxides, reason', + [ + ({**KLB1, 'FeO': math.nan}, 'finite'), + ({**KLB1, 'FeO': -1.0}, 'finite'), + ({**KLB1, 'SiO2': 10**400}, 'finite'), + ({**KLB1, 'Cr2O3': 0.3}, 'unknown'), + ({'SiO2': 44.48, 'MgO': 39.22}, 'sum'), + ({**KLB1, 'SiO2': 60.0}, 'sum'), + ('KLB-1', 'dict'), + ], + ids=[ + 'nan_oxide', + 'negative_oxide', + 'int_beyond_float_oxide', + 'unknown_oxide', + 'sum_low', + 'sum_high', + 'not_a_dict', + ], + ) + def test_bad_melt_composition_raises(self, oxides, reason): + """The melt needs known oxides, finite and non-negative, summing to 95-101 wt%.""" + with pytest.raises(ValueError) as exc: + check_metal_fields({**GOOD, 'silicate_oxides': oxides}) + assert 'silicate_oxides' in str(exc.value) and reason in str(exc.value) + + @pytest.mark.parametrize('field', ['P_eq', 'T_eq', 'dIW_eq']) + def test_required_fields_are_named_when_absent(self, field): + """With metal, P_eq, T_eq and (parameter mode) dIW_eq are required; without, not.""" + missing = {k: v for k, v in GOOD.items() if k != field} + with pytest.raises(ValueError) as exc: + check_metal_fields(missing) + assert field in str(exc.value) and 'required' in str(exc.value) + assert check_metal_fields({**missing, 'metal_mass': 0.0}) is None + if field == 'dIW_eq': + assert check_metal_fields({**missing, 'dIW_eq_mode': 'from_FeO'}) is None + + @pytest.mark.parametrize( + 'extra, oxide', + [ + ({'dIW_eq_mode': 'from_FeO'}, 'FeO'), + ({'Si_mode_core': 'partition'}, 'SiO2'), + ], + ids=['from_FeO_needs_FeO', 'Si_partition_needs_SiO2'], + ) + def test_mode_needs_its_oxide(self, extra, oxide): + """from_FeO needs FeO above 0 in the melt, Si partition needs SiO2 above 0.""" + rest = {k: v for k, v in KLB1.items() if k != oxide} + rest['MgO'] += KLB1[oxide] + for oxides in (rest, {**rest, oxide: 0.0}): + with pytest.raises(ValueError) as exc: + check_metal_fields({**GOOD, **extra, 'silicate_oxides': oxides}) + assert oxide in str(exc.value) + assert check_metal_fields({**GOOD, **extra}) is None + + @pytest.mark.parametrize( + 'field, value', + [('O_mode_core', 'free'), ('Si_mode_core', 1), ('dIW_eq_mode', 'buffer')], + ) + def test_unknown_mode_raises(self, field, value): + """A mode outside the known ones is refused with its field name.""" + with pytest.raises(ValueError) as exc: + check_metal_fields({**GOOD, field: value}) + assert field in str(exc.value) + assert repr(value) in str(exc.value) + + def test_array_valued_mode_is_refused_by_name(self): + """A mode given as an array is refused with its field name.""" + with pytest.raises(ValueError, match='O_mode_core'): + check_metal_fields({**GOOD, 'O_mode_core': np.array(['fixed', 'partition'])}) + + def test_fixed_o_and_si_that_leave_no_iron_are_refused(self): + """w_O_core + w_Si_core of 1 or more leaves no iron in the metal.""" + with pytest.raises(ValueError) as exc: + check_metal_fields({**GOOD, 'w_O_core': 0.6, 'w_Si_core': 0.5}) + assert 'w_O_core' in str(exc.value) and 'w_Si_core' in str(exc.value) + assert check_metal_fields({**GOOD, 'w_O_core': 0.5, 'w_Si_core': 0.4}) is None + + def test_numpy_scalars_pass_and_numpy_bool_fails(self): + """numpy float32 and int64 values are numbers; a numpy bool is not.""" + ok = {**GOOD, 'P_eq': np.float32(50.0), 'dIW_eq': np.int64(-2)} + ok['silicate_oxides'] = {k: np.float32(v) for k, v in KLB1.items()} + assert check_metal_fields(ok) is None + with pytest.raises(ValueError, match='T_eq'): + check_metal_fields({**GOOD, 'T_eq': np.bool_(True)}) + with pytest.raises(ValueError, match='finite'): + check_metal_fields({**GOOD, 'P_eq': np.float64('nan')}) + + @settings(derandomize=True, max_examples=200, deadline=None) + @given(st.floats(allow_nan=True, allow_infinity=True)) + def test_temperature_passes_only_when_finite_and_positive(self, t_eq): + """Over every float, T_eq passes exactly when it is finite and above 0 K.""" + ok = math.isfinite(t_eq) and t_eq > 0 + if ok: + assert check_metal_fields({**GOOD, 'T_eq': t_eq}) is None + else: + with pytest.raises(ValueError, match='T_eq'): + check_metal_fields({**GOOD, 'T_eq': t_eq}) diff --git a/tests/test_metal_solve.py b/tests/test_metal_solve.py new file mode 100644 index 00000000..9afeff1e --- /dev/null +++ b/tests/test_metal_solve.py @@ -0,0 +1,927 @@ +"""Tests for the metal reservoir in `src/calliope/solve.py`. + +Contract: with metal_mass > 0 the H, C, N and S balances carry a core term +D_i M_metal w_i(melt). With the same mass-basis D for all four elements, no O or Si +in the metal and dIW a parameter, the solve equals the solve without metal and a +melt mass larger by D M_metal (an identity of the element balance); the gas +reservoir matters, so the gas-free core fraction Dx/(1 + Dx) misses the result. +Every element closes over gas, melt and core, and metal_mass 0 is the solve +without metal. The O of the metal comes from the silicate FeO and never enters the +volatile O budget: in the authoritative-O solve gas and melt O close on the budget +with metal O present, in 'fixed' and 'partition' mode alike. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from calliope.constants import volatile_species +from calliope.metal import load_partition_table +from calliope.solve import equilibrium_atmosphere, equilibrium_atmosphere_authoritative_O + +pytestmark = [pytest.mark.smoke, pytest.mark.timeout(60)] + +ELEMS = ('H', 'C', 'N', 'S') +BUFFERS = ('hirschmann', 'fischer', 'oneill') +P_GUESS = {'H2O': 50.0, 'CO2': 20.0, 'N2': 1.0, 'S2': 1.0} + + +def _ddict(m_mantle=4.03e24): + d = { + 'M_mantle': m_mantle, + 'gravity': 9.81, + 'radius': 6.371e6, + 'Phi_global': 1.0, + 'T_magma': 1800.0, + 'fO2_shift_IW': 2.0, + } + for sp in volatile_species: + d[f'{sp}_included'] = 1 + d[f'{sp}_initial_bar'] = 0.0 + return d + + +def _equal_d_table(d): + """Placeholder table with a constant mass-basis D for H, C, N and S.""" + table = load_partition_table() + for el in ELEMS: + table[el] = dict( + table[el], basis='mass', a=math.log10(d), b=0.0, c=0.0, d=0.0, e=0.0, eps={} + ) + return table + + +def _metal(d, m_metal): + return { + 'metal_mass': m_metal, + 'partition_table': _equal_d_table(d), + 'P_eq': 50.0, + 'T_eq': 3500.0, + 'dIW_eq': -2.0, + 'dIW_eq_mode': 'parameter', + 'O_mode_core': 'fixed', + 'w_O_core': 0.0, + 'Si_mode_core': 'fixed', + 'w_Si_core': 0.0, + } + + +TARGET = {'H': 1.5e20, 'C': 1.5e19, 'N': 8.0e18, 'S': 8.0e20} + + +def _solve(ddict, target=TARGET, p_guess=P_GUESS, **kw): + """One seeded solve, so a restart draws the same pressures for both runs compared.""" + np.random.seed(0) + return equilibrium_atmosphere( + target, + ddict, + p_guess=p_guess, + print_result=False, + nguess=200, + xtol=1e-12, + rtol=1e-10, + atol=1.0, + opt_solver=False, + **kw, + ) + + +@pytest.mark.physics_invariant +@pytest.mark.reference_pinned +@pytest.mark.parametrize('d', [1.0, 30.0]) +def test_metal_with_equal_d_is_a_larger_melt(d): + """Equal D for H, C, N, S: metal solve = no-metal solve with M_melt + D M_metal.""" + m_metal = 1.9e24 + with_metal = _solve({**_ddict(), **_metal(d, m_metal)}) + larger = _solve(_ddict(4.03e24 + d * m_metal)) + for sp in ('H2O', 'CO2', 'N2', 'S2', 'H2', 'CO', 'CH4'): + assert with_metal[sp + '_bar'] == pytest.approx(larger[sp + '_bar'], rel=1e-6) + for el in ELEMS: + melt, core = with_metal[el + '_kg_liquid'], with_metal[el + '_kg_core'] + assert core == pytest.approx(d * m_metal * melt / 4.03e24, rel=1e-12) + assert melt + core == pytest.approx(larger[el + '_kg_liquid'], rel=1e-6) + closure = with_metal[el + '_kg_atm'] + melt + core + assert closure == pytest.approx(TARGET[el], rel=1e-4) + # the gas holds the rest: core = (budget - gas) Dx/(1 + Dx), short of the gas-free value + x = m_metal / 4.03e24 + gas_free = TARGET['H'] * d * x / (1 + d * x) + gas_share = with_metal['H_kg_atm'] / TARGET['H'] + assert with_metal['H_kg_core'] / gas_free == pytest.approx(1 - gas_share, abs=2e-5) + assert gas_share > 0 + + +@pytest.mark.parametrize('authoritative', [False, True]) +def test_zero_metal_mass_is_the_solve_without_metal(authoritative): + """metal_mass 0 gives the solve without metal bit for bit, plus 4 zero core masses.""" + if authoritative: + target = dict(TARGET, O=_solve(_ddict())['O_kg_total']) + plain = _solve_auth(_ddict(), target) + zero = _solve_auth({**_ddict(), **_metal(30.0, 0.0)}, target) + else: + plain = _solve(_ddict()) + zero = _solve({**_ddict(), **_metal(30.0, 0.0)}) + assert set(zero) - set(plain) == {el + '_kg_core' for el in ELEMS} + assert all(zero[el + '_kg_core'] == 0 for el in ELEMS) + nan = [k for k in plain if plain[k] != plain[k]] + assert all(zero[k] != zero[k] for k in nan) + assert [k for k in plain if k not in nan and zero[k] != plain[k]] == [] + + +@pytest.mark.parametrize('authoritative', [False, True]) +def test_fo2_model_argument_reaches_every_metal_pass(authoritative): + """fO2_model='fischer' as an argument equals ddict['fO2_model'] = 'fischer' with metal. + + The passes of the metal fixed point call the solve again; a buffer given only as an + argument must reach them, or they run on the default buffer. The authoritative-O case + guards the order of the buffer and the metal branch in that entry point. + """ + ddict = {**_ddict(), **_metal(30.0, 1.9e24)} + if authoritative: + target = dict(TARGET, O=_solve(dict(ddict, fO2_model='fischer'))['O_kg_total']) + by_arg = _solve_auth(ddict, target, fO2_model='fischer') + by_ddict = _solve_auth(dict(ddict, fO2_model='fischer'), target) + default = _solve_auth(ddict, target) + key = 'fO2_shift_derived' + else: + by_arg = _solve(ddict, fO2_model='fischer') + by_ddict = _solve(dict(ddict, fO2_model='fischer')) + default = _solve(ddict) + key = 'H2_bar' + assert by_arg['fO2_model'] == by_ddict['fO2_model'] == 'fischer' + assert default['fO2_model'] == 'hirschmann' + for k in (key, 'H2_bar', 'O_kg_total', 'H_kg_core', 'C_kg_core'): + assert by_arg[k] == pytest.approx(by_ddict[k], rel=1e-9), k + # Hirschmann is 0.144 dex above Fischer at 1800 K: the shift moves by that, H2 by 15 % + gap = 0.1 if authoritative else 0.1 * by_ddict[key] + assert abs(default[key] - by_ddict[key]) > gap + # a contradicting argument is refused before the metal branch, by name + solve = equilibrium_atmosphere_authoritative_O if authoritative else equilibrium_atmosphere + args = (dict(TARGET, O=1e20),) if authoritative else (TARGET,) + with pytest.raises(ValueError, match='contradicts'): + solve(*args, dict(ddict, fO2_model='oneill'), fO2_model='fischer') + + +@pytest.mark.physics_invariant +def test_partition_mode_puts_exchange_o_and_si_in_the_metal(): + """O and Si 'partition': the converged metal holds the exchange x_O, x_Si of its dIW.""" + from calliope.metal import exchange_metal, molar_mass_rock + + ddict = {**_ddict(), **_metal(30.0, 1.9e24)} + ddict.update(O_mode_core='partition', Si_mode_core='partition', T_eq=3500.0) + out = _solve(ddict) + w = {el: out['w_' + el + '_core'] for el in ('H', 'C', 'N', 'S', 'O', 'Si')} + x = exchange_metal(ddict['partition_table'], 50.0, 3500.0, -2.0) + masses = {'O': 0.015999, 'Si': molar_mass_rock['Si']} + # x' of Tagawa et al. 2021 Eq. 4: mole fractions over the metal without H and C + n_total = (1 - sum(w.values())) / molar_mass_rock['Fe'] + sum( + w[el] / (masses.get(el) or {'N': 0.014007, 'S': 0.03206}[el]) + for el in w + if el not in ('H', 'C') + ) + for el in ('O', 'Si'): + assert w[el] / masses[el] / n_total == pytest.approx(x[el], rel=1e-4) + assert 0 < w['O'] < 0.2 and 0 < w['Si'] < 0.2 + assert out['O_kg_core'] == pytest.approx(w['O'] * 1.9e24, rel=1e-12) + + +@pytest.mark.physics_invariant +def test_diw_from_feo_matches_the_melt_and_metal(): + """dIW_eq_mode 'from_FeO': dIW = 2 log10(x_FeO / x'_Fe) on the KLB-1 melt and the final metal. + + x_FeO is on the single-cation basis of the melt and x'_Fe over the metal without H and C + (Tagawa et al. 2021 Eq. 4); the oxide-molecule x_FeO would give -2.3763. + """ + from calliope.metal import KLB1, molar_mass_rock, silicate_units_per_kg + + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'dIW_eq_mode': 'from_FeO', 'w_Si_core': 0.05} + out = _solve(ddict) + w = {el: out['w_' + el + '_core'] for el in ('H', 'C', 'N', 'S', 'O', 'Si')} + m = {'N': 0.014007, 'S': 0.03206, 'O': 0.015999, 'Si': molar_mass_rock['Si']} + n_fe = (1 - sum(w.values())) / molar_mass_rock['Fe'] + x_fe = n_fe / (n_fe + sum(w[el] / m[el] for el in m)) + x_feo = 0.081 / (molar_mass_rock['Fe'] + 0.015999) / silicate_units_per_kg(KLB1) + assert out['dIW_eq'] == pytest.approx(2 * math.log10(x_feo / x_fe), abs=1e-4) + assert out['dIW_eq'] == pytest.approx(-2.3975, abs=1e-4) + # KLB-1 with 8.1 wt% FeO sits near IW-2.3 against nearly pure Fe metal + assert -2.6 < out['dIW_eq'] < -2.0 + + +@pytest.mark.physics_invariant +def test_metal_diw_takes_no_absolute_fo2(): + """The metal dIW reads dIW_eq or the melt and metal composition, never the IW buffer. + + The buffer reaches a solved dIW_eq and the D only through the composition the solve + returns; with the same composition every buffer gives the same dIW. + """ + from calliope.metal import KLB1 + from calliope.solve import _metal_diw + + w = {'H': 2e-3, 'C': 1e-3, 'N': 1e-5, 'S': 2e-2, 'O': 3e-2, 'Si': 0.0} + diw = [_metal_diw({'dIW_eq_mode': 'from_FeO', 'fO2_model': b}, w, KLB1) for b in BUFFERS] + assert diw[0] == diw[1] == diw[2] + # S and O dilute the metal Fe, so dIW lies above the pure-Fe value of KLB-1 (-2.485) + pure = _metal_diw({'dIW_eq_mode': 'from_FeO'}, dict.fromkeys(w, 0.0), KLB1) + assert pure == pytest.approx(-2.4846, abs=1e-4) + assert diw[0] - pure == pytest.approx(0.1194, abs=1e-4) + for b in BUFFERS: + assert _metal_diw({'dIW_eq': -2.5, 'fO2_model': b}, w, KLB1) == pytest.approx(-2.5), b + + +@pytest.mark.physics_invariant +def test_buffer_reaches_the_metal_through_the_solved_composition(): + """Little melt and metal at IW-4: the solved dIW_eq and D_C move with the IW buffer. + + The buffer moves the melt volatiles and so the metal S and C that dIW (from FeO) and + the C law read; each state still closes. The placeholder laws run out of range here. + """ + ddict = {**_ddict(), **_metal(30.0, 1e21), 'partition_table': load_partition_table()} + ddict.update(fO2_shift_IW=-4.0, Phi_global=0.01, dIW_eq_mode='from_FeO') + target = dict(TARGET, H=1e22) + h, f = (_solve(dict(ddict, fO2_model=b), target=target) for b in ('hirschmann', 'fischer')) + assert not (h['fallback'] or f['fallback']) + # measured: dIW_eq -0.052 dex and D_C +83 % from Fischer to Hirschmann + assert h['dIW_eq'] - f['dIW_eq'] < -0.02 + assert h['C_D_metal'] / f['C_D_metal'] > 1.3 + assert -3.0 < h['dIW_eq'] < -1.0 + for out in (h, f): + for el in ELEMS: + closure = out[el + '_kg_atm'] + out[el + '_kg_liquid'] + out[el + '_kg_core'] + assert closure == pytest.approx(target[el], rel=1e-6), el + + +def test_unknown_metal_modes_raise(): + """An O or Si mode or a dIW mode outside the known ones is refused before solving.""" + for key, value in (('O_mode_core', 'free'), ('Si_mode_core', 1), ('dIW_eq_mode', 'buffer')): + with pytest.raises(ValueError) as exc: + _solve({**_ddict(), **_metal(30.0, 1.9e24), key: value}) + assert key in str(exc.value) or repr(value) in str(exc.value) + + +def _solve_auth(ddict, target, p_guess=None, **kw): + """One seeded authoritative-O solve at the tight acceptance of `_solve`.""" + from calliope.solve import equilibrium_atmosphere_authoritative_O + + try: + return equilibrium_atmosphere_authoritative_O( + target, + ddict, + fO2_hint=2.0, + p_guess=p_guess or dict(P_GUESS, fO2_shift_IW=2.0), + print_result=False, + nguess=200, + xtol=1e-12, + rtol=1e-10, + atol=1.0, + opt_solver=False, + random_seed=0, + **kw, + ) + except Exception as exc: + pytest.fail(f'authoritative-O solve with metal raised {type(exc).__name__}: {exc}') + + +@pytest.mark.physics_invariant +@pytest.mark.reference_pinned +def test_authoritative_o_budget_excludes_the_metal_o(): + """Authoritative-O with 0.1 wt% O in the metal returns the fO2 of the buffered solve. + + The O budget is the gas and melt O of the buffered metal solve at IW+2; the metal O is + rock O from FeO, so the derived fO2 must be IW+2 and gas + melt O must equal the budget. + """ + m_metal, w_o = 1.9e24, 1e-3 + buffered = _solve({**_ddict(), **_metal(30.0, m_metal)}) + target = dict(TARGET, O=buffered['O_kg_total']) + out = _solve_auth({**_ddict(), **_metal(30.0, m_metal), 'w_O_core': w_o}, target) + assert out['fO2_shift_derived'] == pytest.approx(2.0, abs=1e-6) + for sp in ('H2O', 'CO2', 'N2', 'S2', 'H2', 'CO'): + assert out[sp + '_bar'] == pytest.approx(buffered[sp + '_bar'], rel=1e-6) + assert out['O_kg_core'] > 0 + assert out['O_kg_core'] == pytest.approx(w_o * m_metal, rel=1e-12) + gas_melt_o = out['O_kg_atm'] + out['O_kg_liquid'] + assert abs(gas_melt_o - target['O']) <= target['O'] * 1e-10 + 10.0 + for el in ELEMS: + closure = out[el + '_kg_atm'] + out[el + '_kg_liquid'] + out[el + '_kg_core'] + assert abs(closure - target[el]) <= target[el] * 1e-10 + 10.0, el + # metal O (1.9e21 kg) is 22 times gas and melt O (8.76e19 kg): charged to the budget it + # would move fO2 far from IW+2 + assert out['O_kg_core'] > 10 * buffered['O_kg_total'] + assert out['H_kg_core'] > 0.5 * TARGET['H'] + + +@pytest.mark.physics_invariant +@pytest.mark.reference_pinned +def test_authoritative_o_with_equal_d_is_a_larger_melt(): + """Authoritative-O, equal D = 30, no metal O: the solve of a melt larger by D M_metal. + + The larger melt also holds the O of its dissolved species, so its O budget is larger + by D M_metal times the O mass fraction of the melt of the metal solve. + """ + d, m_metal, m_melt = 30.0, 1.9e24, 4.03e24 + metal = {**_ddict(), **_metal(d, m_metal)} + target = dict(TARGET, O=_solve(metal)['O_kg_total']) + with_metal = _solve_auth(metal, target) + o_extra = d * m_metal * with_metal['O_kg_liquid'] / m_melt + larger = _solve_auth(_ddict(m_melt + d * m_metal), dict(target, O=target['O'] + o_extra)) + for sp in ('H2O', 'CO2', 'N2', 'S2', 'H2', 'CO', 'CH4'): + assert with_metal[sp + '_bar'] == pytest.approx(larger[sp + '_bar'], rel=1e-6) + assert with_metal['fO2_shift_derived'] == pytest.approx( + larger['fO2_shift_derived'], abs=1e-6 + ) + for el in ELEMS: + melt, core = with_metal[el + '_kg_liquid'], with_metal[el + '_kg_core'] + assert core == pytest.approx(d * m_metal * melt / m_melt, rel=1e-12) + assert melt + core == pytest.approx(larger[el + '_kg_liquid'], rel=1e-6) + assert with_metal['O_kg_core'] == 0 + # the core holds a large share of H, so the identity is not the trivial no-metal solve + assert with_metal['H_kg_core'] > 0.5 * TARGET['H'] + + +@pytest.mark.physics_invariant +def test_partition_o_far_above_the_o_budget_solves(): + """Partition-mode metal O (3.6 wt%) is about 800 times the volatile O budget and solves.""" + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'partition_table': load_partition_table()} + ddict.update(O_mode_core='partition', Si_mode_core='partition') + buffered = _solve(ddict) + target = dict(TARGET, O=buffered['O_kg_total']) + out = _solve_auth(ddict, target) + assert out['fO2_shift_derived'] == pytest.approx(2.0, abs=1e-6) + assert out['O_kg_core'] > 100 * target['O'] + assert out['O_kg_core'] == pytest.approx(buffered['O_kg_core'], rel=1e-6) + assert abs(out['O_kg_atm'] + out['O_kg_liquid'] - target['O']) <= target['O'] * 1e-10 + 10 + + +@pytest.mark.parametrize('authoritative', [False, True]) +def test_metal_without_melt_is_refused_by_name(authoritative): + """metal_mass > 0 with Phi_global 0 raises the named melt error before any solve.""" + from calliope.solve import equilibrium_atmosphere_authoritative_O + + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'Phi_global': 0.0} + try: + if authoritative: + equilibrium_atmosphere_authoritative_O( + dict(TARGET, O=1e21), ddict, print_result=False, nguess=3 + ) + else: + equilibrium_atmosphere(TARGET, ddict, print_result=False, nguess=3) + raised = None + except Exception as err: + raised = err + assert type(raised) is ValueError, raised + assert 'needs melt' in str(raised) and 'Phi_global' in str(raised) + + +@pytest.mark.physics_invariant +def test_trace_element_converges_to_the_relative_stop(): + """C at w about 1e-6 in the metal: the relative stop holds it to the tol 1e-12 result. + + The absolute stop of 1e-6 in mass fraction left the C core mass 2.0e-5 off. + """ + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'partition_table': load_partition_table()} + ddict.update(O_mode_core='partition', Si_mode_core='partition') + kw = dict(p_guess=P_GUESS, print_result=False, nguess=200, opt_solver=False, rtol=1e-5) + np.random.seed(0) + loose = equilibrium_atmosphere(TARGET, ddict, **kw) + np.random.seed(0) + tight = equilibrium_atmosphere(TARGET, {**ddict, 'tol_w_core': 1e-12}, **kw) + assert 1e-7 < loose['w_C_core'] < 1e-5 + for el in ELEMS: + assert loose[el + '_kg_core'] == pytest.approx(tight[el + '_kg_core'], rel=3e-6), el + + +@pytest.mark.physics_invariant +@pytest.mark.parametrize('authoritative', [False, True]) +def test_metal_solve_with_a_noble_gas(authoritative): + """He in the solve: the metal passes keep the He pressure in the warm start; He closes.""" + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'He_included': 1} + target = dict(TARGET, He=1e16) + try: + out = _solve(ddict, target, dict(P_GUESS, He=1e-3)) + except Exception as exc: + pytest.fail(f'metal solve with He raised {type(exc).__name__}: {exc}') + if authoritative: + target['O'] = out['O_kg_total'] + out = _solve_auth(ddict, target, dict(P_GUESS, fO2_shift_IW=2.0, He=1e-3)) + assert out['fO2_shift_derived'] == pytest.approx(2.0, abs=1e-6) + he = out['He_kg_atm'] + out['He_kg_liquid'] + assert abs(he - 1e16) <= 1e16 * 1e-10 + 10.0 + assert out['He_bar'] > 0 + for el in ELEMS: + closure = out[el + '_kg_atm'] + out[el + '_kg_liquid'] + out[el + '_kg_core'] + assert closure == pytest.approx(target[el], rel=1e-4), el + assert 'He_kg_core' not in out + + +def _stub_solve(core_of_call, close=True): + """A solve stand-in whose H core mass follows core_of_call(n) on its n-th call.""" + calls = [] + + def solve(target_d, ddict, **kwargs): + calls.append(1) + out = {s + '_bar': 1.0 for s in ('H2O', 'CO2', 'N2', 'S2')} + for el in ELEMS: + core = core_of_call(len(calls)) if el == 'H' else 0.0 + out[el + '_kg_core'] = core + out[el + '_kg_liquid'] = 1e18 + out[el + '_kg_atm'] = target_d[el] - 1e18 - core if close else 0.0 + return out + + return solve, calls + + +def test_fixed_point_that_does_not_settle_raises(): + """A metal H that alternates between 2 values never settles: RuntimeError after 30 passes.""" + from calliope.solve import _with_metal + + solve, calls = _stub_solve(lambda n: 1e19 * (n % 2)) + kwargs = {'rtol': 1e-5, 'atol': 1e10, 'p_guess': None} + with pytest.raises(RuntimeError, match='did not converge'): + _with_metal(solve, TARGET, {**_ddict(), **_metal(30.0, 1.9e24)}, kwargs) + assert len(calls) == 30 + + +def test_state_that_breaks_closure_raises(): + """A returned state whose gas, melt and core miss a budget fails the closure check.""" + from calliope.solve import _with_metal + + solve, calls = _stub_solve(lambda n: 1e19, close=False) + kwargs = {'rtol': 1e-5, 'atol': 1e10, 'p_guess': None} + with pytest.raises(RuntimeError, match='closure'): + _with_metal(solve, TARGET, {**_ddict(), **_metal(30.0, 1.9e24)}, kwargs) + assert len(calls) == 2 + + +def _offset_solve(miss): + """A solve stand-in whose gas, melt and core miss each budget by miss[el] kg.""" + + def solve(target_d, ddict, **kwargs): + out = {s + '_bar': 1.0 for s in ('H2O', 'CO2', 'N2', 'S2')} + for el in ELEMS + ('O',): + out[el + '_kg_core'] = 0.0 + out[el + '_kg_liquid'] = 1e18 + out[el + '_kg_atm'] = target_d.get(el, 0.0) - 1e18 + miss.get(el, 0.0) + out['fO2_shift_derived'] = 2.0 + return out + + return solve + + +# budgets at rtol 1e-5, atol 1e10: the buffered gate is 8e20 rtol + atol = 8.0e15 kg for +# every element; with an O budget each element has its own gate, 8e13 kg for N, 1e17 kg for O +@pytest.mark.parametrize( + ('o_budget', 'miss', 'closes'), + [ + (False, {'N': 1e15}, True), + (False, {'N': 1e16}, False), + (True, {'O': 5e16}, True), + (True, {'O': 2e17}, False), + (True, {'N': 1e15}, False), + (True, {'O': -1.9e21}, False), + ], + ids=[ + 'buffered-N-in', + 'buffered-N-out', + 'O-in', + 'O-out', + 'O-budget-N-out', + 'metal-O-charged-to-budget', + ], +) +def test_closure_check_uses_the_gate_of_the_solve(o_budget, miss, closes): + """The fixed-point closure check accepts what the solve accepts and refuses the rest.""" + from calliope.solve import _with_metal + + kwargs = {'rtol': 1e-5, 'atol': 1e10, 'p_guess': None} + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'w_O_core': 1e-3} + target = dict(TARGET, O=1e22) + try: + out = _with_metal(_offset_solve(miss), target, ddict, kwargs, o_budget=o_budget) + raised = None + except Exception as exc: + raised = exc + if closes: + assert raised is None, raised + assert out['O_kg_core'] == pytest.approx(1.9e21, rel=1e-12) + else: + assert isinstance(raised, RuntimeError), raised + assert f'{next(iter(miss))} closure' in str(raised) + + +def test_out_of_range_and_ideal_activities_are_flagged_and_warned_once(caplog, monkeypatch): + """Flags for every partitioned element; one warning per element and process. + + At P_eq 50 GPa, T_eq 3500 K, dIW -2 the H law (dIW -1.33 to -0.88) is outside its + range and O and Si have no T or dIW range, so all 3 are flagged; C (dIW -3.9 to -0.53) + is inside. + """ + import calliope.solve as solve_mod + + monkeypatch.setattr(solve_mod, '_METAL_WARNED', set()) + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'partition_table': load_partition_table()} + ddict.update(O_mode_core='partition', Si_mode_core='partition') + with caplog.at_level('WARNING', logger='fwl.calliope.solve'): + out = _solve(ddict) + first = [r.getMessage() for r in caplog.records] + caplog.clear() + _solve(ddict) + second = [r.getMessage() for r in caplog.records] + flags = {el: out[el + '_D_out_of_range'] for el in ('H', 'C', 'O', 'Si')} + assert flags == {'H': 1, 'C': 0, 'O': 1, 'Si': 1} + for el in ('H', 'O', 'Si'): + assert sum(f'{el} partition law' in m and 'outside' in m for m in first) == 1, el + assert not any('C partition law' in m for m in first) + assert sum('ideal activities' in m for m in first) == 2 + assert second == [] + # fixed O and Si use no law, so they carry no flag + fixed = _solve({**_ddict(), **_metal(30.0, 1.9e24)}) + assert 'O_D_out_of_range' not in fixed and 'Si_D_out_of_range' not in fixed + + +@pytest.mark.parametrize('value', [math.nan, -1.9e24, 'x', None], ids=repr) +@pytest.mark.parametrize('authoritative', [False, True]) +def test_bad_metal_mass_is_refused_by_both_solves(value, authoritative): + """A NaN, negative or non-number metal_mass raises ValueError; it never solves without metal.""" + from calliope.solve import equilibrium_atmosphere_authoritative_O + + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'metal_mass': value} + try: + if authoritative: + equilibrium_atmosphere_authoritative_O( + dict(TARGET, O=1e21), ddict, print_result=False, nguess=3 + ) + else: + equilibrium_atmosphere(TARGET, ddict, print_result=False, nguess=3) + raised = None + except Exception as err: + raised = err + assert isinstance(raised, ValueError), raised + assert 'metal_mass' in str(raised) + + +def test_table_without_a_needed_element_is_refused(): + """The solve needs laws for H, C, N, S and for each partitioned element of O and Si.""" + from calliope.metal import PartitionTableError + + table = {k: v for k, v in load_partition_table().items() if k != 'C'} + for extra in ({}, {'Si_mode_core': 'partition'}): + t = table if not extra else {k: v for k, v in table.items() if k != 'Si'} | {'C': 1} + try: + _solve({**_ddict(), **_metal(30.0, 1.9e24), 'partition_table': t, **extra}) + raised = None + except Exception as err: + raised = err + assert isinstance(raised, PartitionTableError), raised + assert ("'C'" if not extra else "'Si'") in str(raised) + + +@pytest.mark.physics_invariant +def test_placeholder_laws_are_evaluated_on_the_converged_metal_and_melt(): + """Real placeholder laws, half-molten mantle, own melt and NBO/T: D matches its inputs. + + At convergence each reported D equals the law on the reported metal and melt + compositions, and each core mass equals D M_metal w_melt with the melt of + Phi_global M_mantle; P and T swapped, NBO/T 2.6, the KLB-1 melt or a melt + without its volatiles each give a D outside the tolerance. + """ + from calliope.metal import KLB1, mass_D + + oxides = {**KLB1, 'FeO': 10.0, 'MgO': KLB1['MgO'] - 1.9} + m_metal, m_melt, nbo_t = 1.9e24, 0.5 * 4.03e24, 1.5 + ddict = {**_ddict(), **_metal(30.0, m_metal), 'Phi_global': 0.5} + ddict.update( + partition_table=load_partition_table(), + silicate_oxides=oxides, + silicate_nbo_t=nbo_t, + w_O_core=0.02, + w_Si_core=0.03, + tol_w_core=1e-12, + ) + out = _solve(ddict) + table = ddict['partition_table'] + w = {el: out['w_' + el + '_core'] for el in ('H', 'C', 'N', 'S', 'O', 'Si')} + w_melt = {el: out[el + '_kg_liquid'] / m_melt for el in ('H', 'C')} + for el in ELEMS: + d = mass_D(table[el], 50.0, 3500.0, -2.0, w, w_melt, oxides, nbo_t) + assert out[el + '_D_metal'] == pytest.approx(d, rel=1e-6), el + core = d * m_metal * out[el + '_kg_liquid'] / m_melt + assert out[el + '_kg_core'] == pytest.approx(core, rel=1e-6), el + wrong = { + 'P_T_swapped': mass_D(table['C'], 3500.0, 50.0, -2.0, w, w_melt, oxides, nbo_t), + 'nbo_t_default': mass_D(table['C'], 50.0, 3500.0, -2.0, w, w_melt, oxides, 2.6), + 'klb1_melt': mass_D(table['H'], 50.0, 3500.0, -2.0, w, w_melt, KLB1, nbo_t), + 'no_melt_volatiles': mass_D(table['H'], 50.0, 3500.0, -2.0, w, {}, oxides, nbo_t), + } + for name, d in wrong.items(): + el = 'C' if name in ('P_T_swapped', 'nbo_t_default') else 'H' + assert abs(d / out[el + '_D_metal'] - 1) > 1e-5, name + + +@pytest.mark.physics_invariant +def test_user_silicate_oxides_set_the_exchange_feo_and_diw(): + """The melt oxides of the caller, not KLB-1, set x_SiO2, x_FeO of dIW and the melt FeO.""" + from calliope.metal import KLB1, exchange_metal, molar_mass_rock, silicate_units_per_kg + + oxides = {**KLB1, 'FeO': 10.0, 'SiO2': KLB1['SiO2'] + 2.0, 'MgO': KLB1['MgO'] - 3.9} + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'partition_table': load_partition_table()} + ddict.update(O_mode_core='partition', Si_mode_core='partition', dIW_eq_mode='from_FeO') + ddict.update(silicate_oxides=oxides) + out = _solve(ddict) + m_feo = molar_mass_rock['Fe'] + 0.015999 + assert out['n_FeO_silicate'] == pytest.approx(4.03e24 * 0.10 / m_feo, rel=1e-4) + w = {el: out['w_' + el + '_core'] for el in ('H', 'C', 'N', 'S', 'O', 'Si')} + m = {'N': 0.014007, 'S': 0.03206, 'O': 0.015999, 'Si': molar_mass_rock['Si']} + n_fe = (1 - sum(w.values())) / molar_mass_rock['Fe'] + n_free = n_fe + sum(w[el] / m[el] for el in m) + x_feo = 0.10 / m_feo / silicate_units_per_kg(oxides) + assert out['dIW_eq'] == pytest.approx(2 * math.log10(x_feo / (n_fe / n_free)), abs=1e-4) + x = exchange_metal(ddict['partition_table'], 50.0, 3500.0, out['dIW_eq'], oxides) + for el in ('O', 'Si'): + assert w[el] / m[el] / n_free == pytest.approx(x[el], rel=1e-4), el + + +def test_placeholder_table_warns_once(caplog, monkeypatch): + """A table whose meta says placeholder warns once per process; a real table does not.""" + import calliope.solve as solve_mod + + monkeypatch.setattr(solve_mod, '_METAL_WARNED', set()) + ddict = {**_ddict(), **_metal(30.0, 1.9e24)} + real = dict( + ddict, + partition_table={ + **ddict['partition_table'], + 'meta': {'name': 'x', 'placeholder': False}, + }, + ) + with caplog.at_level('WARNING', logger='fwl.calliope.solve'): + _solve(real) + assert not [m for m in caplog.messages if 'placeholder' in m] + _solve(ddict) + _solve(ddict) + assert len([m for m in caplog.messages if 'placeholder' in m]) == 1 + + +def test_negative_core_mass_names_the_core_in_the_error(): + """A returned negative core mass fails the check with the core mass in the message.""" + from calliope.solve import _with_metal + + solve, _ = _stub_solve(lambda n: -1e6) + kwargs = {'rtol': 1e-5, 'atol': 1e10, 'p_guess': None} + try: + _with_metal(solve, TARGET, {**_ddict(), **_metal(30.0, 1.9e24)}, kwargs) + raised = None + except Exception as err: + raised = err + assert isinstance(raised, RuntimeError), raised + assert 'core' in str(raised) and '-1e+06' in str(raised) + + +# M_FeO, M_Si from the element masses (kg/mol); KLB-1 FeO 8.10 wt% in 4.03e24 kg of melt +_M_O, _M_SI, _M_FE = 0.015999, 0.0280855, 0.055845 +_N_FEO_SIL = 4.03e24 * 0.081 / (_M_FE + _M_O) + + +@pytest.mark.physics_invariant +@pytest.mark.parametrize( + ('w_o', 'w_si', 'flag'), + [(1e-3, 0.0, 0), (5e-3, 0.0, 1), (0.02, 0.03, 1), (1e-6, 0.05, 1)], + ids=['ratio-0.026', 'ratio-0.13', 'si-returns-feo', 'trace-o-in-si-rich-metal'], +) +def test_feo_exchange_balances_o_and_si_atoms(w_o, w_si, flag, caplog, monkeypatch): + """n_FeO_exchange = n_O - 2 n_Si of the metal; above 10 % of the melt FeO it is flagged. + + FeO = Fe + O takes 1 FeO per metal O, SiO2 + 2 Fe = Si + 2 FeO gives back 2 FeO per + metal Si; the ratio to the KLB-1 melt FeO is 0.026, 0.13, -0.37 and -1.49 for the 4 + cases. In the last, n_O is 2e-5 of 2 n_Si. + """ + import calliope.solve as solve_mod + + monkeypatch.setattr(solve_mod, '_METAL_WARNED', set()) + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'w_O_core': w_o, 'w_Si_core': w_si} + with caplog.at_level('WARNING', logger='fwl.calliope.solve'): + try: + out = _solve(ddict) + _solve(ddict) + except Exception as exc: + pytest.fail(f'metal solve raised {type(exc).__name__}: {exc}') + n_ex = w_o * 1.9e24 / _M_O - 2 * w_si * 1.9e24 / _M_SI + assert out['n_FeO_exchange'] == pytest.approx(n_ex, rel=1e-4) + assert out['FeO_kg_exchange'] == pytest.approx(n_ex * (_M_FE + _M_O), rel=1e-4) + assert out['n_FeO_silicate'] == pytest.approx(_N_FEO_SIL, rel=1e-4) + assert out['FeO_exchange_out_of_range'] == flag + warned = [m for m in caplog.messages if '10 % of the silicate FeO' in m] + assert len(warned) == flag + if w_si: + # without the Si term the exchange would be n_O alone, 2.4e24 mol of the other sign + assert abs(out['n_FeO_exchange'] - w_o * 1.9e24 / _M_O) > abs(n_ex) + + +@pytest.mark.physics_invariant +def test_partition_o_budget_round_trip_on_a_partial_melt(): + """Partition O and Si, 70 % melt, IW+1: the O-budget solve returns IW+1. + + Each pass evaluates D_C on the O and Si of the same pass (the C law has eps_O = -19.5), + and the melt FeO is that of Phi_global M_mantle, not of the whole mantle. + """ + from calliope.metal import KLB1, mass_D + + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'partition_table': load_partition_table()} + ddict.update(O_mode_core='partition', Si_mode_core='partition', P_eq=40.0) + ddict.update(Phi_global=0.7, fO2_shift_IW=1.0) + buffered = _solve(ddict) + target = dict(TARGET, O=buffered['O_kg_total']) + out = _solve_auth(ddict, target, dict(P_GUESS, fO2_shift_IW=1.0)) + assert out['fO2_shift_derived'] == pytest.approx(1.0, abs=1e-6) + assert abs(out['O_kg_atm'] + out['O_kg_liquid'] - target['O']) <= target['O'] * 1e-10 + 10 + assert out['O_kg_core'] == pytest.approx(buffered['O_kg_core'], rel=1e-6) + assert out['n_FeO_silicate'] == pytest.approx(0.7 * _N_FEO_SIL, rel=1e-4) + w = {el: out['w_' + el + '_core'] for el in ('H', 'C', 'N', 'S', 'O', 'Si')} + w_melt = {el: out[el + '_kg_liquid'] / (0.7 * 4.03e24) for el in ('H', 'C')} + at = (ddict['partition_table']['C'], 40.0, 3500.0, -2.0) + d_c = mass_D(*at, w, w_melt, KLB1, 2.6) + no_o = mass_D(*at, {**w, 'O': 0.0, 'Si': 0.0}, w_melt, KLB1, 2.6) + assert out['C_D_metal'] == pytest.approx(d_c, rel=1e-5) + assert d_c / no_o > 5 + + +@pytest.mark.physics_invariant +def test_si_partition_with_diw_from_feo_settles(monkeypatch): + """Si 'partition' with dIW from FeO, 34 % melt, 4487 K: the metal Si settles in 10 passes. + + More metal Si lowers x'_Fe, raises dIW and lowers the exchange x_Si; undamped, the Si + of successive passes alternates about the fixed point with a ratio of about -0.6. + """ + import calliope.solve as solve_mod + from calliope.metal import exchange_metal + + passes, real = [0], solve_mod._with_metal + + def counting(solve, *args, **kwargs): + def counted(*a, **k): + passes[0] += 1 + return solve(*a, **k) + + return real(counted, *args, **kwargs) + + monkeypatch.setattr(solve_mod, '_with_metal', counting) + + ddict = {**_ddict(), **_metal(30.0, 1.6166e24), 'partition_table': load_partition_table()} + ddict.update(P_eq=52.64, T_eq=4487.46, Phi_global=0.3386, silicate_nbo_t=3.105) + ddict.update(Si_mode_core='partition', dIW_eq_mode='from_FeO', w_O_core=0.00534) + try: + out = _solve(ddict) + except Exception as exc: + pytest.fail(f'metal solve raised {type(exc).__name__}: {exc}') + x_si = exchange_metal( + ddict['partition_table'], 52.64, 4487.46, out['dIW_eq'], elements=('Si',) + ) + w = {el: out['w_' + el + '_core'] for el in ('H', 'C', 'N', 'S', 'O', 'Si')} + m = {'N': 0.014007, 'S': 0.03206, 'O': 0.015999, 'Si': 0.028085} + n_free = (1 - sum(w.values())) / 0.055845 + sum(w[el] / m[el] for el in m) + assert w['Si'] / m['Si'] / n_free == pytest.approx(x_si['Si'], rel=1e-4) + assert -2.3 < out['dIW_eq'] < -2.0 + assert 1 <= passes[0] <= 10 + + +@pytest.mark.parametrize( + 'extra', + [ + {'Si_mode_core': 'partition', 'dIW_eq': -6.0}, + {'O_mode_core': 'partition', 'dIW_eq_mode': 'from_FeO', 'T_eq': 5000.0, 'feo': 17.9}, + {'O_mode_core': 'partition', 'dIW_eq': 615.9, 'T_eq': 5000.0}, + ], + ids=['si-at-iw-minus-6', 'o-from-feo-rich-melt', 'o-exchange-overflows'], +) +def test_exchange_without_iron_raises_the_named_error(extra, request): + """An exchange without iron that does not fall, or one that is not finite, raises ValueError. + + The message names the partitioned element and its mode field only. + """ + from calliope.metal import KLB1 + + extra = dict(extra) + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'partition_table': load_partition_table()} + if 'feo' in extra: + feo = extra.pop('feo') + ddict['silicate_oxides'] = { + **KLB1, + 'FeO': feo, + 'MgO': KLB1['MgO'] - (feo - KLB1['FeO']), + } + ddict.update(extra) + el = 'Si' if 'Si_mode_core' in extra else 'O' + overflow = request.node.callspec.id == 'o-exchange-overflows' + message = 'exchange is not finite' if overflow else 'does not fall' + with pytest.raises(ValueError, match=message) as exc: + _solve(ddict) + if message == 'does not fall': + assert f"{el}_mode_core 'partition'" in str(exc.value) + assert 'no fixed point was reached from the start metal' in str(exc.value) + assert ({'O', 'Si'} - {el}).pop() + '_mode_core' not in str(exc.value) + + +def test_stop_reads_the_whole_exchange_step(monkeypatch): + """A damped step below tol_w_core with a whole step 500 times above it does not stop. + + The stub exchange is linear in w_Si with slope -1000 (lam 1/1001): pass 2 moves w_Si by + 0.4 tol w_Si onto the fixed point while its whole step is 400 tol w_Si, so the solve stops + on pass 3. + """ + import calliope.solve as solve_mod + from calliope.metal import molar_mass_rock + + w0, tol, s = 0.05, 1e-6, -1000.0 + a = 0.4 * tol * w0 + w_star = w0 + a / (1 - s) + targets = iter([w0 + a, w0 + a + s * a] + [w_star] * 28) + monkeypatch.setattr(solve_mod, 'exchange_metal', lambda *args, **kw: {'Si': next(targets)}) + m_si = molar_mass_rock['Si'] + monkeypatch.setattr(solve_mod, '_metal_moles', lambda w, exclude=(): (1.0 / m_si, {})) + solve, calls = _stub_solve(lambda n: 1e19) + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'Si_mode_core': 'partition', 'w_Si_core': w0} + ddict['tol_w_core'] = tol + out = solve_mod._with_metal( + solve, TARGET, ddict, {'rtol': 1e-5, 'atol': 1e10, 'p_guess': None} + ) + assert len(calls) == 3 + assert out['w_Si_core'] == pytest.approx(w_star, rel=1e-12) + + +@pytest.mark.physics_invariant +@pytest.mark.parametrize( + ('t_eq', 'both'), [(4800.0, False), (5000.0, True), (6500.0, False), (8000.0, False)] +) +def test_exchange_beyond_the_iron_of_pure_fe_metal_settles(t_eq, both): + """From pure Fe metal the first exchange holds no iron (x_Si 1.10 at 4800 K); the solve settles. + + The steps of a pass stop short of a metal without iron, and the converged metal holds the + exchange x' of its own dIW. At 6500 and 8000 K the exchange sum also exceeds 1 on later + passes while it falls toward its fixed point. + """ + from calliope.metal import exchange_metal + + ddict = {**_ddict(), **_metal(30.0, 1.9e24), 'partition_table': load_partition_table()} + ddict.update(Si_mode_core='partition', dIW_eq_mode='from_FeO', T_eq=t_eq) + if both: + ddict['O_mode_core'] = 'partition' + try: + out = _solve(ddict) + except Exception as exc: + pytest.fail(f'metal solve raised {type(exc).__name__}: {exc}') + elements = ('O', 'Si') if both else ('Si',) + x = exchange_metal(ddict['partition_table'], 50.0, t_eq, out['dIW_eq'], elements=elements) + w = {el: out['w_' + el + '_core'] for el in ('H', 'C', 'N', 'S', 'O', 'Si')} + m = {'N': 0.014007, 'S': 0.03206, 'O': 0.015999, 'Si': 0.028085} + n_free = (1 - sum(w.values())) / 0.055845 + sum(w[el] / m[el] for el in m) + for el in elements: + assert w[el] / m[el] / n_free == pytest.approx(x[el], rel=1e-4), el + assert sum(w.values()) < 0.6 + + +@pytest.mark.physics_invariant +@pytest.mark.reference_pinned +def test_equal_d_identity_plot(validation_plot): + """Equal D: partial pressures with metal against a melt larger by D M_metal, both solves. + + Buffered at D = 1 and 30 and authoritative-O at D = 30 (its O budget raised by the O of + the extra melt): every partial pressure agrees to relative 1e-6. + """ + sp = ('H2O', 'CO2', 'N2', 'S2', 'H2', 'CO', 'CH4') + pairs = {} + for d in (1.0, 30.0): + metal = _solve({**_ddict(), **_metal(d, 1.9e24)}) + larger = _solve(_ddict(4.03e24 + d * 1.9e24)) + pairs[f'D = {d:g}'] = (larger, metal) + metal_dict = {**_ddict(), **_metal(30.0, 1.9e24)} + target = dict(TARGET, O=_solve(metal_dict)['O_kg_total']) + auth = _solve_auth(metal_dict, target) + o_extra = 30.0 * 1.9e24 * auth['O_kg_liquid'] / 4.03e24 + big = _solve_auth(_ddict(4.03e24 + 30.0 * 1.9e24), dict(target, O=target['O'] + o_extra)) + pairs['D = 30, O budget'] = (big, auth) + for name, (ref, got) in pairs.items(): + for s in sp: + assert got[s + '_bar'] == pytest.approx(ref[s + '_bar'], rel=1e-6), (name, s) + # the metal changes the pressures: the solve without metal differs by far more + plain = _solve(_ddict()) + assert abs(plain['H2O_bar'] / pairs['D = 30'][1]['H2O_bar'] - 1) > 0.5 + + def figure(): + import matplotlib.pyplot as plt + + values = [r[s + '_bar'] for pair in pairs.values() for r in pair for s in sp] + lo, hi = math.floor(math.log10(min(values))), math.ceil(math.log10(max(values))) + edge, ticks = [10.0**lo, 10.0**hi], [10.0**k for k in range(lo, hi + 1, 4)] + fig, ax = plt.subplots() + for (name, (ref, got)), mk in zip(pairs.items(), ('o', 's', '+')): + x = [ref[s + '_bar'] for s in sp] + ax.loglog(x, [got[s + '_bar'] for s in sp], mk, mfc='none', ms=9, label=name) + ax.plot(edge, edge, ls='--', color=plt.rcParams['text.color'], label='1:1') + ax.set(xlabel='p, larger melt [bar]', ylabel='p, with metal [bar]') + ax.set(xlim=edge, ylim=edge, xticks=ticks, yticks=ticks) + ax.minorticks_off() + ax.legend(fontsize='small') + fig.tight_layout() + return fig + + validation_plot(figure, 'metal_equal_d_larger_melt') diff --git a/tests/test_validation_index.py b/tests/test_validation_index.py new file mode 100644 index 00000000..d2b27618 --- /dev/null +++ b/tests/test_validation_index.py @@ -0,0 +1,146 @@ +"""Tests for the case table of `docs/Validation/index.md`. + +Contract: every test that carries `reference_pinned` (on the function, its class or its +module) appears in the table, and every test id in the table names such a test. A +shorthand id `::name` replaces the last part of the id before it in the same cell. +""" + +from __future__ import annotations + +import ast +import re +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.unit, pytest.mark.timeout(30)] + +ROOT = Path(__file__).resolve().parents[1] +INDEX = ROOT / 'docs' / 'Validation' / 'index.md' + +# Carries the marker of its module but checks only an environment setting. +NOT_A_CASE = {'tests/test_cross_code_consistency.py::test_required_env_guards'} + + +def _pinned(nodes) -> bool: + """True when any expression in ``nodes`` names ``reference_pinned``.""" + return any( + isinstance(n, ast.Attribute) and n.attr == 'reference_pinned' + for node in nodes + for n in ast.walk(node) + ) + + +def _pytestmark(body) -> list[ast.expr]: + """Values assigned to ``pytestmark`` in a module or class body.""" + return [ + s.value + for s in body + if isinstance(s, ast.Assign) + and any(getattr(t, 'id', '') == 'pytestmark' for t in s.targets) + ] + + +def pinned_tests(tests_dir: Path) -> set[str]: + """Return ``file::[Class::]function`` of every reference-pinned test under ``tests_dir``.""" + found = set() + for path in sorted(tests_dir.glob('test_*.py')): + tree = ast.parse(path.read_text()) + rel = path.relative_to(tests_dir.parent).as_posix() + mod = _pinned(_pytestmark(tree.body)) + funcs = [(f'{rel}::', mod, n) for n in tree.body] + for c in tree.body: + if isinstance(c, ast.ClassDef) and c.name.startswith('Test'): + cls = mod or _pinned(c.decorator_list) or _pinned(_pytestmark(c.body)) + funcs += [(f'{rel}::{c.name}::', cls, n) for n in c.body] + for prefix, inherited, n in funcs: + if isinstance(n, ast.FunctionDef | ast.AsyncFunctionDef) and n.name.startswith( + 'test' + ): + if inherited or _pinned(n.decorator_list): + found.add(prefix + n.name) + return found + + +def index_ids(text: str) -> set[str]: + """Return the test ids of the case table, with each ``::name`` shorthand expanded. + + Raises + ------ + ValueError + A shorthand id has no full id before it in its cell. + """ + ids = set() + for line in text.splitlines(): + cells = line.split('|') + if not line.startswith('| [') or len(cells) < 5: + continue + last = None + for token in re.findall(r'`([^`]+)`', cells[3]): + if token.startswith('::'): + if last is None: + raise ValueError(f'shorthand {token} without a full id: {line}') + token = last.rsplit('::', 1)[0] + token + last = token + ids.add(token) + return ids + + +def test_index_lists_every_reference_pinned_test(): + """The table names each reference-pinned test once in some row, and nothing else.""" + pinned = pinned_tests(ROOT / 'tests') + listed = index_ids(INDEX.read_text()) + # 28 pinned test functions exist; an empty scan would pass the set checks vacuously + assert len(pinned) >= 28 + assert NOT_A_CASE <= pinned, 'stale NOT_A_CASE entry' + assert sorted(pinned - NOT_A_CASE - listed) == [], 'pinned tests missing from the index' + assert sorted(listed - pinned) == [], 'index ids that name no reference-pinned test' + + +def test_scan_follows_function_class_and_module_marks(tmp_path): + """A mark on the function, on the class or in pytestmark counts; an unmarked test does not.""" + tests = tmp_path / 'tests' + tests.mkdir() + (tests / 'test_a.py').write_text( + 'import pytest\n' + '@pytest.mark.reference_pinned\n' + 'def test_f(): pass\n' + 'def test_plain(): pass\n' + '@pytest.mark.reference_pinned\n' + 'class TestC:\n' + ' def test_m(self): pass\n' + 'class TestD:\n' + ' pytestmark = pytest.mark.reference_pinned\n' + ' def test_n(self): pass\n' + ' def helper(self): pass\n' + ) + (tests / 'test_b.py').write_text( + 'import pytest\npytestmark = [pytest.mark.unit, pytest.mark.reference_pinned]\n' + 'def test_g(): pass\n' + ) + (tests / 'test_c.py').write_text('import pytest\npytestmark = [pytest.mark.unit]\n') + found = pinned_tests(tests) + assert found == { + 'tests/test_a.py::test_f', + 'tests/test_a.py::TestC::test_m', + 'tests/test_a.py::TestD::test_n', + 'tests/test_b.py::test_g', + } + assert 'tests/test_a.py::test_plain' not in found + + +def test_index_parser_expands_shorthand_and_refuses_a_bare_one(): + """`::name` takes the class of the id before it; a cell that starts with one raises.""" + row = '| [P](p.md) | case | `tests/t.py::TestA::test_x`, `::test_y`, `tests/u.py::test_z` | ref | unit |' + assert index_ids(row) == { + 'tests/t.py::TestA::test_x', + 'tests/t.py::TestA::test_y', + 'tests/u.py::test_z', + } + # header, separator and prose lines carry no ids + assert ( + index_ids('| Process | Case | Test id |\n|---|---|---|\nSee `tests/t.py::test_q`.') + == set() + ) + with pytest.raises(ValueError, match='without a full id'): + index_ids('| [P](p.md) | case | `::test_y` | ref | unit |') diff --git a/tools/agents/check_agents_md.py b/tools/agents/check_agents_md.py new file mode 100755 index 00000000..556e2ed5 --- /dev/null +++ b/tools/agents/check_agents_md.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Check the agent instruction files of a PROTEUS-ecosystem repository. + +The same file is used in every ecosystem repository; the canonical copy is +``tools/agents/check_agents_md.py`` in FormingWorlds/PROTEUS. It checks: + +* every shared block (```` to + ````) still matches the hash written by + ``sync_core.py``, which catches a local edit of the shared text (whether the + text matches PROTEUS is checked there, by ``sync_core.py --check``); +* a root ``AGENTS.md`` exists with an ``fwl-core`` block, a ``tests/AGENTS.md`` has an + ``fwl-tests-core`` block, and the byte caps: 16,000 B for the root file, + 12,000 B for a nested one; +* next to every ``AGENTS.md`` a ``CLAUDE.md`` is a regular file that imports + ``AGENTS.md`` and nothing else; with a root ``CLAUDE.md`` present, Claude Code + reads a nested ``AGENTS.md`` only through its own ``CLAUDE.md``; +* ``.github/copilot-instructions.md`` has at most 60 lines. + +Usage: ``python tools/agents/check_agents_md.py [repo_root]``. Exit status 1 +lists every failure. +""" + +from __future__ import annotations + +import hashlib +import re +import subprocess +import sys +from pathlib import Path + +ROOT_MAX_BYTES = 16_000 +NESTED_MAX_BYTES = 12_000 +COPILOT_MAX_LINES = 60 +CLAUDE_MD_TEXT = '@AGENTS.md' +REQUIRED_BLOCKS = {Path('AGENTS.md'): 'core', Path('tests/AGENTS.md'): 'tests-core'} + +BLOCK_RE = re.compile( + r'^\n' + r'(?P.*?)' + r'^$', + re.MULTILINE | re.DOTALL, +) +MARKER_RE = re.compile(r'^\s*